import { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { REDEEM_TOKEN_TTL_SECONDS } from '@dukang/shared-types'; import { request } from '../lib/api'; import { usePageView } from '../lib/usePageView'; const POLL_INTERVAL_MS = 2500; type BenefitSummary = { totalBalance: number; maxRedeemAmount: number; activeCouponCount: number; }; 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 RedeemPage() { usePageView('benefit_redeem_start'); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const couponId = searchParams.get('couponId') ?? undefined; const presetAmount = searchParams.get('amount'); const [summary, setSummary] = useState(null); const [couponBalance, setCouponBalance] = useState(null); const [amountInput, setAmountInput] = useState(''); const [msg, setMsg] = useState(''); const [loading, setLoading] = useState(false); const [modalOpen, setModalOpen] = useState(false); const [token, setToken] = useState(''); const [confirmAmount, setConfirmAmount] = useState(0); const [timerSec, setTimerSec] = useState(REDEEM_TOKEN_TTL_SECONDS); const timerRef = useRef(null); const presetApplied = useRef(false); const successHandled = useRef(false); const redeemableMax = couponId ? (couponBalance ?? 0) : (summary?.totalBalance ?? 0); useEffect(() => { request('USER_H5', '/benefit/summary').then(setSummary).catch(() => {}); return () => stopTimer(); }, []); useEffect(() => { if (!couponId) { setCouponBalance(null); return; } request>>('USER_H5', '/benefit/coupons') .then((list) => { const found = list.find((c) => String(c.id) === couponId); if (found) { setCouponBalance(Number(found.balance)); } }) .catch(() => {}); }, [couponId]); useEffect(() => { if (presetApplied.current) return; if (presetAmount && Number(presetAmount) > 0) { setAmountInput(formatMoney(Number(presetAmount))); presetApplied.current = true; } }, [presetAmount]); function stopTimer() { if (timerRef.current != null) { window.clearInterval(timerRef.current); timerRef.current = null; } } const closeModal = useCallback(() => { stopTimer(); setModalOpen(false); setToken(''); setTimerSec(REDEEM_TOKEN_TTL_SECONDS); successHandled.current = false; }, []); const handleRedeemExpired = useCallback(() => { if (successHandled.current) return; successHandled.current = true; closeModal(); sessionStorage.removeItem('redeemToken'); sessionStorage.removeItem('redeemAmount'); setMsg('核销码已失效,请重新生成'); }, [closeModal]); const handleRedeemSuccess = useCallback( (record: NonNullable['record']>) => { if (successHandled.current) return; successHandled.current = true; stopTimer(); setModalOpen(false); setToken(''); sessionStorage.setItem('lastRedeemRecordId', record.id); sessionStorage.setItem('lastRedeemResult', JSON.stringify(record)); sessionStorage.removeItem('redeemToken'); sessionStorage.removeItem('redeemAmount'); navigate('/redeem/success', { replace: true, state: { record }, }); }, [navigate], ); function startTimer() { stopTimer(); setTimerSec(REDEEM_TOKEN_TTL_SECONDS); timerRef.current = window.setInterval(() => { setTimerSec((prev) => { if (prev <= 1) { stopTimer(); window.setTimeout(() => handleRedeemExpired(), 0); return 0; } return prev - 1; }); }, 1000); } useEffect(() => { if (!modalOpen || !token) return undefined; let cancelled = false; async function pollStatus() { try { const status = await request('USER_H5', `/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 = window.setInterval(() => void pollStatus(), POLL_INTERVAL_MS); return () => { cancelled = true; window.clearInterval(pollId); }; }, [modalOpen, token, handleRedeemSuccess, handleRedeemExpired]); function parseAmount() { const value = Number(amountInput); return Number.isFinite(value) ? Math.round(value * 100) / 100 : 0; } function fillMaxAmount() { if (redeemableMax <= 0) return; setAmountInput(formatMoney(redeemableMax)); setMsg(''); } async function submit() { const amount = parseAmount(); if (!summary) return; const maxAllowed = couponId ? (couponBalance ?? 0) : summary.totalBalance; if (amount <= 0) { setMsg('请输入核销金额'); return; } if (amount > maxAllowed) { setMsg(couponId ? '核销金额不能超过该权益可用余额' : '核销金额不能超过可用余额'); return; } setLoading(true); setMsg(''); successHandled.current = false; try { const body: { amount: number; couponId?: string } = { amount }; if (couponId) body.couponId = couponId; const data = await request<{ token: string; amount: number }>('USER_H5', '/redeem/tokens', { method: 'POST', body: JSON.stringify(body), }); setToken(data.token); setConfirmAmount(data.amount); setModalOpen(true); startTimer(); sessionStorage.setItem('redeemToken', data.token); sessionStorage.setItem('redeemAmount', String(data.amount)); } catch (e) { setMsg(e instanceof Error ? e.message : '生成失败'); } finally { setLoading(false); } } const qrUrl = token ? `https://api.qrserver.com/v1/create-qr-code/?size=192x192&data=${encodeURIComponent(token)}` : ''; return (

好客权益核销

{couponId ? '当前权益可用余额' : '当前好客权益可用余额'}
¥ {redeemableMax > 0 ? formatMoney(redeemableMax) : '--'}
¥ { setAmountInput(e.target.value); setMsg(''); }} />
最高可核销 ¥{formatMoney(redeemableMax)}
{msg &&

{msg}

}
shield

安全提示:请在核销前核实金额,核销码将在 3 分钟后自动失效。

正在核销

请向收银员出示此码

{qrUrl ? ( 核销二维码 ) : (
)}
0 && timerSec < REDEEM_TOKEN_TTL_SECONDS ? ' active' : ''}`}> {formatTimer(timerSec)} 失效倒计时

待核销金额

¥ {formatMoney(confirmAmount)}

{token && (

核销码编号(供追查)

{token}

)}
); }