Files
dukang/apps/mini-user/src/pages/redeem-code/index.tsx
T
jacy fc2e5b65de
CI / verify (pull_request) Has been cancelled
v3.5.3版本更新1
2026-08-20 18:54:15 +08:00

174 lines
5.7 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { View, Text } from '@tarojs/components';
import '../../styles/redeem.css';
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 { request, toast } from '../../lib/api';
import { formatMoney } from '../../lib/money';
import BenefitFigure from '../../components/BenefitFigure';
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 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 token = decodeURIComponent(router.params.token ?? '');
const amount = Number(router.params.amount ?? 0);
const [timerSec, setTimerSec] = useState(REDEEM_TOKEN_TTL_SECONDS);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const successHandled = useRef(false);
const lastTokenTapAt = useRef(0);
function onTokenTap() {
if (!token) return;
const now = Date.now();
if (now - lastTokenTapAt.current < 350) {
lastTokenTapAt.current = 0;
void Taro.setClipboardData({ data: token })
.then(() => toast('核销码编号已复制', 'success'))
.catch(() => toast('复制失败'));
return;
}
lastTokenTapAt.current = now;
}
const stopTimer = useCallback(() => {
if (timerRef.current != null) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);
const handleRedeemExpired = useCallback(() => {
if (successHandled.current) return;
successHandled.current = true;
stopTimer();
toast('核销码已失效,请重新生成');
setTimeout(() => {
Taro.navigateBack();
}, 1500);
}, [stopTimer]);
const handleRedeemSuccess = useCallback(
(record: NonNullable<Extract<RedeemTokenStatus, { status: 'CONSUMED' }>['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<RedeemTokenStatus>(`/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 (
<PageShell variant="sub" className="redeem-code-page">
<SubPageHeader title="核销码" />
<View className="sub-page-body">
<View className="redeem-code-panel">
<Text className="redeem-code-head">请向收银员出示此码</Text>
<View className="redeem-qr-wrap">
<RedeemQrCode token={token} />
</View>
<View className={`redeem-timer${timerSec > 0 && timerSec < REDEEM_TOKEN_TTL_SECONDS ? ' redeem-timer--active' : ''}`}>
<Text className="redeem-timer-value">{formatTimer(timerSec)}</Text>
<Text className="redeem-timer-label">失效倒计时</Text>
</View>
<Text className="u-muted">待核销金额</Text>
<BenefitFigure value={formatMoney(amount)} size="lg" className="redeem-code-amount" />
{token ? (
<View className="redeem-code-token-wrap" onClick={onTokenTap}>
<Text className="redeem-code-token-label">核销码编号(供追查)</Text>
<Text className="redeem-code-token">{token}</Text>
<Text className="redeem-code-token-hint">双击复制</Text>
</View>
) : null}
</View>
<View className="redeem-cancel-btn" onClick={() => Taro.navigateBack()}>
<Text>取消核销</Text>
</View>
</View>
</PageShell>
);
}