From f2cdc0222d9bfc77224ba47d4f50222a213754c7 Mon Sep 17 00:00:00 2001
From: jacy <18049821889@163.com>
Date: Sun, 12 Jul 2026 23:59:52 +0800
Subject: [PATCH] =?UTF-8?q?=E5=B0=8F=E7=A8=8B=E5=BA=8F=E4=BA=8C=E7=BB=B4?=
=?UTF-8?q?=E7=A0=81=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
apps/mini-user/package.json | 1 +
apps/mini-user/src/app.tsx | 1 +
.../mini-user/src/components/RedeemQrCode.tsx | 75 ++++++
apps/mini-user/src/lib/redeem-qr.ts | 42 ++++
.../src/lib/text-encoding-polyfill.ts | 52 ++++
.../mini-user/src/pages/redeem-code/index.tsx | 171 ++++++++++---
.../src/pages/redeem-success/index.tsx | 64 ++++-
apps/mini-user/src/pages/redeem/index.tsx | 79 +++++-
apps/mini-user/src/styles/redeem.css | 233 +++++++++++++++++-
pnpm-lock.yaml | 3 +
10 files changed, 658 insertions(+), 63 deletions(-)
create mode 100644 apps/mini-user/src/components/RedeemQrCode.tsx
create mode 100644 apps/mini-user/src/lib/redeem-qr.ts
create mode 100644 apps/mini-user/src/lib/text-encoding-polyfill.ts
diff --git a/apps/mini-user/package.json b/apps/mini-user/package.json
index f4c422d..c3f8418 100644
--- a/apps/mini-user/package.json
+++ b/apps/mini-user/package.json
@@ -27,6 +27,7 @@
"@tarojs/runtime": "4.2.0",
"@tarojs/shared": "4.2.0",
"@tarojs/taro": "4.2.0",
+ "qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
diff --git a/apps/mini-user/src/app.tsx b/apps/mini-user/src/app.tsx
index 3537d0a..85fa689 100644
--- a/apps/mini-user/src/app.tsx
+++ b/apps/mini-user/src/app.tsx
@@ -1,3 +1,4 @@
+import './lib/text-encoding-polyfill';
import { PropsWithChildren } from 'react';
import './app.css';
diff --git a/apps/mini-user/src/components/RedeemQrCode.tsx b/apps/mini-user/src/components/RedeemQrCode.tsx
new file mode 100644
index 0000000..6de502a
--- /dev/null
+++ b/apps/mini-user/src/components/RedeemQrCode.tsx
@@ -0,0 +1,75 @@
+import { useEffect, useState } from 'react';
+import { View, Canvas } from '@tarojs/components';
+import Taro from '@tarojs/taro';
+import {
+ REDEEM_QR_DISPLAY_SIZE,
+ buildRedeemQrDataUrl,
+ drawRedeemQrOnCanvas,
+} from '../lib/redeem-qr';
+
+const CANVAS_ID = 'redeem-qr-canvas';
+
+type RedeemQrCodeProps = {
+ token: string;
+};
+
+function drawOnWeappCanvas(token: string) {
+ const page = Taro.getCurrentInstance().page;
+ const query = page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery();
+ query
+ .select(`#${CANVAS_ID}`)
+ .fields({ node: true, size: true })
+ .exec((res) => {
+ const item = res[0] as { node?: WechatMiniprogram.Canvas; width?: number; height?: number } | undefined;
+ const canvas = item?.node;
+ if (!canvas) return;
+
+ const layoutW = item.width || REDEEM_QR_DISPLAY_SIZE;
+ const layoutH = item.height || REDEEM_QR_DISPLAY_SIZE;
+ const drawSize = Math.min(layoutW, layoutH);
+
+ const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
+ const dpr = Taro.getSystemInfoSync().pixelRatio || 2;
+ canvas.width = layoutW * dpr;
+ canvas.height = layoutH * dpr;
+ ctx.scale(dpr, dpr);
+ drawRedeemQrOnCanvas(ctx, token, drawSize);
+ });
+}
+
+export default function RedeemQrCode({ token }: RedeemQrCodeProps) {
+ const [imgSrc, setImgSrc] = useState('');
+ const isWeapp = process.env.TARO_ENV === 'weapp';
+
+ useEffect(() => {
+ if (!token) {
+ setImgSrc('');
+ return;
+ }
+
+ if (isWeapp) {
+ const timer = setTimeout(() => drawOnWeappCanvas(token), 120);
+ return () => clearTimeout(timer);
+ }
+
+ let cancelled = false;
+ void buildRedeemQrDataUrl(token).then((url) => {
+ if (!cancelled) setImgSrc(url);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [token, isWeapp]);
+
+ return (
+
+
+ {isWeapp ? (
+
+ ) : imgSrc ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/mini-user/src/lib/redeem-qr.ts b/apps/mini-user/src/lib/redeem-qr.ts
new file mode 100644
index 0000000..decc453
--- /dev/null
+++ b/apps/mini-user/src/lib/redeem-qr.ts
@@ -0,0 +1,42 @@
+import './text-encoding-polyfill';
+import QRCode from 'qrcode';
+
+const QR_SIZE = 240;
+const QR_OPTIONS = {
+ width: QR_SIZE * 2,
+ margin: 0,
+ color: { dark: '#1f1a17', light: '#ffffff' },
+} as const;
+
+/** 在 2d Canvas 上绘制二维码(小程序 / H5 通用) */
+export function drawRedeemQrOnCanvas(
+ ctx: CanvasRenderingContext2D,
+ token: string,
+ sizePx = QR_SIZE,
+) {
+ const qr = QRCode.create(token, { errorCorrectionLevel: 'M' });
+ const count = qr.modules.size;
+ const cell = sizePx / count;
+
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(0, 0, sizePx, sizePx);
+ ctx.fillStyle = '#1f1a17';
+ for (let row = 0; row < count; row++) {
+ for (let col = 0; col < count; col++) {
+ if (qr.modules.get(row, col)) {
+ ctx.fillRect(col * cell, row * cell, cell, cell);
+ }
+ }
+ }
+}
+
+/** H5:Data URL;失败时回退到与 h5-user 相同的外部 QR 服务 */
+export async function buildRedeemQrDataUrl(token: string): Promise {
+ try {
+ return await QRCode.toDataURL(token, QR_OPTIONS);
+ } catch {
+ return `https://api.qrserver.com/v1/create-qr-code/?size=${QR_SIZE * 2}x${QR_SIZE * 2}&data=${encodeURIComponent(token)}`;
+ }
+}
+
+export const REDEEM_QR_DISPLAY_SIZE = QR_SIZE;
diff --git a/apps/mini-user/src/lib/text-encoding-polyfill.ts b/apps/mini-user/src/lib/text-encoding-polyfill.ts
new file mode 100644
index 0000000..c952394
--- /dev/null
+++ b/apps/mini-user/src/lib/text-encoding-polyfill.ts
@@ -0,0 +1,52 @@
+/** 微信小程序基础库未内置 TextEncoder,qrcode 库依赖它编码 payload */
+function installTextEncodingPolyfill() {
+ const root = (typeof globalThis !== 'undefined'
+ ? globalThis
+ : typeof global !== 'undefined'
+ ? global
+ : typeof wx !== 'undefined'
+ ? wx
+ : {}) as typeof globalThis & { TextEncoder?: typeof TextEncoder };
+
+ if (typeof root.TextEncoder !== 'undefined') return;
+
+ class MiniTextEncoder implements TextEncoder {
+ readonly encoding = 'utf-8';
+
+ encode(input?: string): Uint8Array {
+ const str = input ?? '';
+ const bytes: number[] = [];
+ for (let i = 0; i < str.length; i++) {
+ let code = str.charCodeAt(i);
+ if (code < 0x80) {
+ bytes.push(code);
+ } else if (code < 0x800) {
+ bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
+ } else if (code >= 0xd800 && code <= 0xdbff && i + 1 < str.length) {
+ const next = str.charCodeAt(i + 1);
+ if (next >= 0xdc00 && next <= 0xdfff) {
+ i += 1;
+ code = 0x10000 + ((code - 0xd800) << 10) + (next - 0xdc00);
+ bytes.push(
+ 0xf0 | (code >> 18),
+ 0x80 | ((code >> 12) & 0x3f),
+ 0x80 | ((code >> 6) & 0x3f),
+ 0x80 | (code & 0x3f),
+ );
+ } else {
+ bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
+ }
+ } else {
+ bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
+ }
+ }
+ return new Uint8Array(bytes);
+ }
+ }
+
+ root.TextEncoder = MiniTextEncoder as unknown as typeof TextEncoder;
+}
+
+installTextEncodingPolyfill();
+
+export {};
diff --git a/apps/mini-user/src/pages/redeem-code/index.tsx b/apps/mini-user/src/pages/redeem-code/index.tsx
index d3bc11a..741851b 100644
--- a/apps/mini-user/src/pages/redeem-code/index.tsx
+++ b/apps/mini-user/src/pages/redeem-code/index.tsx
@@ -1,54 +1,157 @@
-import { useEffect, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
+import { REDEEM_TOKEN_TTL_SECONDS } from '@dukang/shared-types';
import PageShell from '../../components/PageShell';
+import RedeemQrCode from '../../components/RedeemQrCode';
import SubPageHeader from '../../components/SubPageHeader';
-import { toast } from '../../lib/api';
+import { request, toast } from '../../lib/api';
+
+const POLL_INTERVAL_MS = 2500;
+const LAST_REDEEM_RECORD_KEY = 'lastRedeemRecordId';
+const LAST_REDEEM_RESULT_KEY = 'lastRedeemResult';
+
+type RedeemTokenStatus =
+ | { status: 'PENDING'; expireInSeconds: number; amount: number }
+ | {
+ status: 'CONSUMED';
+ record: {
+ id: string;
+ redeemNo: string;
+ amount: number;
+ storeId: string;
+ storeName: string;
+ createdAt: string;
+ };
+ }
+ | { status: 'EXPIRED' };
+
+function formatMoney(amount: number) {
+ return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+}
+
+function formatTimer(seconds: number) {
+ const mins = Math.floor(seconds / 60);
+ const secs = seconds % 60;
+ return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
+}
export default function RedeemCodePage() {
const router = useRouter();
- const amount = router.params.amount ?? '0';
- const [seconds, setSeconds] = useState(180);
- const code = `DK${String(Date.now()).slice(-8)}`;
+ const token = decodeURIComponent(router.params.token ?? '');
+ const amount = Number(router.params.amount ?? 0);
- useEffect(() => {
- const timer = setInterval(() => {
- setSeconds((s) => {
- if (s <= 1) {
- clearInterval(timer);
- toast('核销码已过期');
- return 0;
- }
- return s - 1;
- });
- }, 1000);
- return () => clearInterval(timer);
+ const [timerSec, setTimerSec] = useState(REDEEM_TOKEN_TTL_SECONDS);
+ const timerRef = useRef | null>(null);
+ const successHandled = useRef(false);
+
+ const stopTimer = useCallback(() => {
+ if (timerRef.current != null) {
+ clearInterval(timerRef.current);
+ timerRef.current = null;
+ }
}, []);
- const mm = String(Math.floor(seconds / 60)).padStart(2, '0');
- const ss = String(seconds % 60).padStart(2, '0');
+ const handleRedeemExpired = useCallback(() => {
+ if (successHandled.current) return;
+ successHandled.current = true;
+ stopTimer();
+ toast('核销码已失效,请重新生成');
+ setTimeout(() => {
+ Taro.navigateBack();
+ }, 1500);
+ }, [stopTimer]);
+
+ const handleRedeemSuccess = useCallback(
+ (record: NonNullable['record']>) => {
+ if (successHandled.current) return;
+ successHandled.current = true;
+ stopTimer();
+ Taro.setStorageSync(LAST_REDEEM_RECORD_KEY, record.id);
+ Taro.setStorageSync(LAST_REDEEM_RESULT_KEY, JSON.stringify(record));
+ Taro.redirectTo({
+ url: `/pages/redeem-success/index?amount=${record.amount}`,
+ });
+ },
+ [stopTimer],
+ );
+
+ useEffect(() => {
+ if (!token) {
+ toast('核销码无效,请重新生成');
+ Taro.navigateBack();
+ return undefined;
+ }
+
+ successHandled.current = false;
+ setTimerSec(REDEEM_TOKEN_TTL_SECONDS);
+ timerRef.current = setInterval(() => {
+ setTimerSec((prev) => {
+ if (prev <= 1) {
+ stopTimer();
+ setTimeout(() => handleRedeemExpired(), 0);
+ return 0;
+ }
+ return prev - 1;
+ });
+ }, 1000);
+
+ return () => stopTimer();
+ }, [token, stopTimer, handleRedeemExpired]);
+
+ useEffect(() => {
+ if (!token) return undefined;
+
+ let cancelled = false;
+
+ async function pollStatus() {
+ try {
+ const status = await request(`/redeem/tokens/${token}/status`);
+ if (cancelled || successHandled.current) return;
+ if (status.status === 'CONSUMED' && status.record) {
+ handleRedeemSuccess(status.record);
+ } else if (status.status === 'EXPIRED') {
+ handleRedeemExpired();
+ } else if (status.status === 'PENDING' && status.expireInSeconds > 0) {
+ setTimerSec((prev) => Math.min(prev, status.expireInSeconds));
+ }
+ } catch {
+ /* 轮询失败忽略,下次重试 */
+ }
+ }
+
+ void pollStatus();
+ const pollId = setInterval(() => void pollStatus(), POLL_INTERVAL_MS);
+ return () => {
+ cancelled = true;
+ clearInterval(pollId);
+ };
+ }, [token, handleRedeemSuccess, handleRedeemExpired]);
return (
- 核销金额 ¥{Number(amount).toFixed(2)}
- {code}
- 剩余有效时间 {mm}:{ss}
-
- 请向门店店员出示此码完成核销
-
+ 请向收银员出示此码
+
+
+
+ 0 && timerSec < REDEEM_TOKEN_TTL_SECONDS ? ' redeem-timer--active' : ''}`}>
+ {formatTimer(timerSec)}
+ 失效倒计时
+
+ 待核销金额
+ ¥ {formatMoney(amount)}
+ {token ? (
+
+ 核销码编号(供追查)
+ {token}
+
+ ) : null}
-
- Taro.redirectTo({
- url: `/pages/redeem-success/index?amount=${amount}`,
- })
- }
- >
- 模拟核销成功
+ Taro.navigateBack()}>
+ 取消核销
diff --git a/apps/mini-user/src/pages/redeem-success/index.tsx b/apps/mini-user/src/pages/redeem-success/index.tsx
index 59bd1cf..024be82 100644
--- a/apps/mini-user/src/pages/redeem-success/index.tsx
+++ b/apps/mini-user/src/pages/redeem-success/index.tsx
@@ -1,11 +1,47 @@
+import { useMemo } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
+const LAST_REDEEM_RESULT_KEY = 'lastRedeemResult';
+
+type RedeemRecord = {
+ id: string;
+ redeemNo: string;
+ amount: number;
+ storeId: string;
+ storeName: string;
+ createdAt: string;
+};
+
+function formatMoney(amount: number) {
+ return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+}
+
export default function RedeemSuccessPage() {
const router = useRouter();
- const amount = router.params.amount ?? '0';
+
+ const record = useMemo(() => {
+ try {
+ const cached = Taro.getStorageSync(LAST_REDEEM_RESULT_KEY);
+ return cached ? (JSON.parse(String(cached)) as RedeemRecord) : null;
+ } catch {
+ return null;
+ }
+ }, []);
+
+ const amount = Number(record?.amount ?? router.params.amount ?? 0);
+ const storeName = record?.storeName || '门店';
+ const redeemNo = record?.redeemNo || '—';
+ const redeemedAt = record?.createdAt
+ ? new Date(record.createdAt).toLocaleString('zh-CN')
+ : new Date().toLocaleString('zh-CN');
+
+ function goBenefit() {
+ Taro.removeStorageSync(LAST_REDEEM_RESULT_KEY);
+ Taro.switchTab({ url: '/pages/benefit/index' });
+ }
return (
@@ -15,13 +51,25 @@ export default function RedeemSuccessPage() {
✓
核销成功
-
- 已核销好客权益 ¥{Number(amount).toFixed(2)}
-
- Taro.switchTab({ url: '/pages/benefit/index' })}
- >
+ ¥ {formatMoney(amount)}
+ 已在 {storeName} 完成核销
+
+
+
+ 核销门店
+ {storeName}
+
+
+ 核销时间
+ {redeemedAt}
+
+
+ 核销单号
+ {redeemNo}
+
+
+
+
返回权益
(null);
const [amount, setAmount] = useState(initialAmount);
+ const [loading, setLoading] = useState(false);
+
+ const redeemableMax = couponId ? (couponBalance ?? 0) : balance;
useEffect(() => {
if (!isLoggedIn()) {
@@ -27,19 +36,52 @@ export default function RedeemPage() {
.catch(() => {});
}, []);
- function submit() {
- const value = Number(amount);
+ useEffect(() => {
+ if (!couponId) {
+ setCouponBalance(null);
+ return;
+ }
+ request>('/benefit/coupons')
+ .then((list) => {
+ const found = list.find((c) => String(c.id) === couponId);
+ if (found) setCouponBalance(Number(found.balance));
+ })
+ .catch(() => {});
+ }, [couponId]);
+
+ function fillMaxAmount() {
+ if (redeemableMax <= 0) return;
+ setAmount(String(redeemableMax));
+ }
+
+ async function submit() {
+ const value = Math.round(Number(amount) * 100) / 100;
if (!(value > 0)) {
toast('请输入核销金额');
return;
}
- if (value > balance && balance > 0) {
- toast('超出可用余额');
+ if (value > redeemableMax) {
+ toast(couponId ? '核销金额不能超过该权益可用余额' : '超出可用余额');
return;
}
- Taro.navigateTo({
- url: `/pages/redeem-code/index?amount=${value}`,
- });
+
+ setLoading(true);
+ try {
+ const body: { amount: number; couponId?: string } = { amount: value };
+ if (couponId) body.couponId = couponId;
+
+ const data = await request<{ token: string; amount: number }>('/redeem/tokens', {
+ method: 'POST',
+ data: body,
+ });
+ Taro.navigateTo({
+ url: `/pages/redeem-code/index?token=${encodeURIComponent(data.token)}&amount=${data.amount}`,
+ });
+ } catch (e) {
+ toast(e instanceof Error ? e.message : '生成失败');
+ } finally {
+ setLoading(false);
+ }
}
return (
@@ -47,8 +89,12 @@ export default function RedeemPage() {
- 可用余额
- ¥{balance.toFixed(2)}
+
+ {couponId ? '当前权益可用余额' : '可用余额'}
+
+
+ ¥{redeemableMax > 0 ? formatMoney(redeemableMax) : '--'}
+
setAmount(e.detail.value)}
/>
+
+
+ 最高可核销 ¥{formatMoney(redeemableMax)}
+
+
+ 全部核销
+
+
直接核销:金额须大于 0 且不超过全部可用权益余额。核销码有效期 3 分钟,请到店出示。
-
- 生成核销码
+
+ {loading ? '生成中...' : '生成核销码'}
diff --git a/apps/mini-user/src/styles/redeem.css b/apps/mini-user/src/styles/redeem.css
index ad8174f..b182831 100644
--- a/apps/mini-user/src/styles/redeem.css
+++ b/apps/mini-user/src/styles/redeem.css
@@ -54,6 +54,19 @@
line-height: 1.6;
}
+.redeem-amount-foot {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin: 0 var(--space-page) 12px;
+}
+
+.redeem-fill-max {
+ font-size: 13px;
+ color: var(--color-heritage-red);
+ font-weight: 600;
+}
+
.redeem-submit {
margin: 24px var(--space-page);
height: 48px;
@@ -67,6 +80,25 @@
justify-content: center;
}
+.redeem-submit--disabled {
+ opacity: 0.5;
+}
+
+.redeem-cancel-btn {
+ margin: 0 var(--space-page) 24px;
+ height: 48px;
+ border-radius: var(--radius-md);
+ background: transparent;
+ color: var(--color-heritage-red);
+ border: 1px solid var(--color-heritage-red);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 15px;
+ font-weight: 600;
+ box-sizing: border-box;
+}
+
.redeem-code-panel {
margin: 32px var(--space-page);
padding: 32px 20px;
@@ -76,20 +108,162 @@
text-align: center;
}
-.redeem-code-value {
+.redeem-code-head {
display: block;
- font-family: var(--font-headline);
- font-size: 36px;
- font-weight: 700;
- letter-spacing: 0.12em;
- color: var(--color-heritage-red);
- margin: 16px 0;
+ font-size: 14px;
+ color: var(--color-subtle-gray);
+ margin-bottom: 20px;
}
-.redeem-code-timer {
+.redeem-qr-wrap {
+ padding: 12px;
+ border: 4px solid rgba(166, 29, 36, 0.1);
+ border-radius: var(--radius-lg);
+ margin: 0 auto 20px;
+ width: 240px;
+ max-width: calc(100% - 40px);
+ box-sizing: border-box;
+}
+
+.redeem-qr-box {
+ position: relative;
+ width: 100%;
+ height: 0;
+ padding-bottom: 100%;
+ background: #fff;
+ overflow: hidden;
+}
+
+.redeem-qr-canvas {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 1;
display: block;
- font-size: 13px;
+}
+
+.redeem-qr-img {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 1;
+ display: block;
+ background-size: 100% 100%;
+ background-repeat: no-repeat;
+ background-position: center;
+}
+
+.redeem-qr-placeholder {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 0;
+ background: var(--color-surface-container);
+}
+
+.redeem-qr-scanline {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 3;
+ height: 3px;
+ background: linear-gradient(
+ 180deg,
+ rgba(166, 29, 36, 0) 0%,
+ rgba(166, 29, 36, 0.85) 50%,
+ rgba(166, 29, 36, 0) 100%
+ );
+ box-shadow: 0 0 12px rgba(166, 29, 36, 0.55);
+ pointer-events: none;
+ animation: redeem-scan 3s ease-in-out infinite;
+}
+
+@keyframes redeem-scan {
+ 0%,
+ 100% {
+ top: 0;
+ opacity: 0.1;
+ }
+ 50% {
+ top: 95%;
+ opacity: 0.8;
+ }
+}
+
+.redeem-timer {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin-bottom: 16px;
+ border-radius: var(--radius-md);
+}
+
+.redeem-timer--active {
+ animation: redeem-pulse 2s infinite;
+}
+
+@keyframes redeem-pulse {
+ 0% {
+ box-shadow: 0 0 0 0 rgba(166, 29, 36, 0.2);
+ }
+ 70% {
+ box-shadow: 0 0 0 10px rgba(166, 29, 36, 0);
+ }
+ 100% {
+ box-shadow: 0 0 0 0 rgba(166, 29, 36, 0);
+ }
+}
+
+.redeem-timer-value {
+ font-family: var(--font-headline);
+ font-size: 24px;
+ font-weight: 700;
+ color: var(--color-ink-black);
+}
+
+.redeem-timer-label {
+ font-size: 10px;
+ letter-spacing: 0.12em;
color: var(--color-subtle-gray);
+ margin-top: 4px;
+}
+
+.redeem-code-amount {
+ display: block;
+ font-family: var(--font-headline);
+ font-size: 28px;
+ font-weight: 700;
+ color: var(--color-heritage-red);
+ margin: 8px 0 16px;
+}
+
+.redeem-code-token-wrap {
+ margin-top: 8px;
+ padding-top: 16px;
+ border-top: 1px solid var(--color-surface-container);
+}
+
+.redeem-code-token-label {
+ display: block;
+ font-size: 12px;
+ color: var(--color-subtle-gray);
+ margin-bottom: 4px;
+}
+
+.redeem-code-token {
+ display: block;
+ font-family: ui-monospace, monospace;
+ font-size: 11px;
+ word-break: break-all;
+ color: var(--color-on-surface);
+ line-height: 1.5;
}
.redeem-success-icon {
@@ -119,7 +293,46 @@
text-align: center;
font-size: 14px;
color: var(--color-subtle-gray);
- margin-bottom: 32px;
+ margin-bottom: 24px;
+}
+
+.redeem-success-amount {
+ display: block;
+ text-align: center;
+ font-family: var(--font-headline);
+ font-size: 28px;
+ font-weight: 700;
+ color: var(--color-heritage-red);
+ margin-bottom: 8px;
+}
+
+.redeem-success-details {
+ margin: 0 var(--space-page) 24px;
+ padding: 16px;
+ background: var(--color-card);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-card);
+}
+
+.redeem-success-detail-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 10px 0;
+ font-size: 13px;
+ border-bottom: 1px solid var(--color-surface-container);
+}
+
+.redeem-success-detail-row:last-child {
+ border-bottom: none;
+}
+
+.redeem-success-mono {
+ font-family: ui-monospace, monospace;
+ font-size: 12px;
+ text-align: right;
+ word-break: break-all;
}
.ledger-item {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5d70f44..a817b48 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -310,6 +310,9 @@ importers:
element-china-area-data:
specifier: ^6.1.0
version: 6.1.0
+ qrcode:
+ specifier: ^1.5.4
+ version: 1.5.4
react:
specifier: ^18.3.1
version: 18.3.1