89fd333702
Add client-logging SDK, expanded event taxonomy, API observability, admin domain events UI, and move SENTRY_DSN to HQ system settings with @sentry/node bootstrap after config preload. Co-authored-by: Cursor <cursoragent@cursor.com>
360 lines
12 KiB
TypeScript
360 lines
12 KiB
TypeScript
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<BenefitSummary | null>(null);
|
|
const [couponBalance, setCouponBalance] = useState<number | null>(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<number | null>(null);
|
|
const presetApplied = useRef(false);
|
|
const successHandled = useRef(false);
|
|
|
|
const redeemableMax = couponId ? (couponBalance ?? 0) : (summary?.totalBalance ?? 0);
|
|
|
|
useEffect(() => {
|
|
request<BenefitSummary>('USER_H5', '/benefit/summary').then(setSummary).catch(() => {});
|
|
return () => stopTimer();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!couponId) {
|
|
setCouponBalance(null);
|
|
return;
|
|
}
|
|
request<Array<Record<string, unknown>>>('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<Extract<RedeemTokenStatus, { status: 'CONSUMED' }>['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<RedeemTokenStatus>('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 (
|
|
<div className="redeem-page">
|
|
<header className="redeem-header">
|
|
<button type="button" className="redeem-header-btn" aria-label="返回" onClick={() => navigate(-1)}>
|
|
<span className="material-symbols-outlined">arrow_back</span>
|
|
</button>
|
|
<h1 className="app-page-title">好客权益核销</h1>
|
|
<button
|
|
type="button"
|
|
className="redeem-header-btn"
|
|
aria-label="帮助"
|
|
onClick={() => window.alert('核销金额不超过可用余额,核销码 3 分钟内有效。')}
|
|
>
|
|
<span className="material-symbols-outlined">help_outline</span>
|
|
</button>
|
|
</header>
|
|
|
|
<main className="redeem-main">
|
|
<section className="redeem-balance-card">
|
|
<div className="redeem-balance-pattern" aria-hidden />
|
|
<div className="redeem-balance-inner">
|
|
<span className="redeem-balance-label">
|
|
{couponId ? '当前权益可用余额' : '当前好客权益可用余额'}
|
|
</span>
|
|
<div className="redeem-balance-amount">
|
|
<span className="redeem-balance-symbol">¥</span>
|
|
<span className="redeem-balance-value">
|
|
{redeemableMax > 0 ? formatMoney(redeemableMax) : '--'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="redeem-form-card">
|
|
<label className="redeem-form-label" htmlFor="redeem-amount">
|
|
核销金额 (¥)
|
|
</label>
|
|
<div className="redeem-amount-row">
|
|
<span className="redeem-amount-symbol">¥</span>
|
|
<input
|
|
id="redeem-amount"
|
|
className="redeem-amount-input"
|
|
type="number"
|
|
inputMode="decimal"
|
|
step="0.01"
|
|
min="0"
|
|
placeholder="0.00"
|
|
value={amountInput}
|
|
onChange={(e) => {
|
|
setAmountInput(e.target.value);
|
|
setMsg('');
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="redeem-amount-foot">
|
|
<span>最高可核销 ¥{formatMoney(redeemableMax)}</span>
|
|
<button type="button" className="redeem-fill-max" onClick={fillMaxAmount}>
|
|
全部核销
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
{msg && <p className="redeem-msg">{msg}</p>}
|
|
|
|
<button
|
|
type="button"
|
|
className="redeem-submit-btn"
|
|
disabled={loading || redeemableMax <= 0}
|
|
onClick={submit}
|
|
>
|
|
<span className="material-symbols-outlined">qr_code_2</span>
|
|
{loading ? '生成中...' : '生成核销码'}
|
|
</button>
|
|
|
|
<div className="redeem-security">
|
|
<span className="material-symbols-outlined">shield</span>
|
|
<p>安全提示:请在核销前核实金额,核销码将在 3 分钟后自动失效。</p>
|
|
</div>
|
|
</main>
|
|
|
|
<div className={`redeem-modal${modalOpen ? ' open' : ''}`} aria-hidden={!modalOpen}>
|
|
<div className="redeem-modal-backdrop" onClick={closeModal} aria-hidden />
|
|
<div className={`redeem-modal-panel${modalOpen ? ' open' : ''}`}>
|
|
<div className="redeem-modal-head">
|
|
<h3>正在核销</h3>
|
|
<p>请向收银员出示此码</p>
|
|
</div>
|
|
<div className="redeem-modal-body">
|
|
<div className="redeem-qr-wrap">
|
|
<div className="redeem-qr-box">
|
|
{qrUrl ? (
|
|
<img className="redeem-qr-img" src={qrUrl} alt="核销二维码" />
|
|
) : (
|
|
<div className="redeem-qr-placeholder" />
|
|
)}
|
|
<div className="redeem-qr-scanline" aria-hidden />
|
|
</div>
|
|
</div>
|
|
<div className={`redeem-timer${timerSec > 0 && timerSec < REDEEM_TOKEN_TTL_SECONDS ? ' active' : ''}`}>
|
|
<span className="redeem-timer-value">{formatTimer(timerSec)}</span>
|
|
<span className="redeem-timer-label">失效倒计时</span>
|
|
</div>
|
|
<div className="redeem-modal-amount">
|
|
<p>待核销金额</p>
|
|
<p className="redeem-modal-amount-value">¥ {formatMoney(confirmAmount)}</p>
|
|
</div>
|
|
{token && (
|
|
<div style={{ marginTop: 12, textAlign: 'center' }}>
|
|
<p className="label-md text-muted" style={{ marginBottom: 4 }}>核销码编号(供追查)</p>
|
|
<p
|
|
style={{
|
|
fontFamily: 'ui-monospace, monospace',
|
|
fontSize: 12,
|
|
wordBreak: 'break-all',
|
|
padding: '0 16px',
|
|
}}
|
|
>
|
|
{token}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button type="button" className="redeem-modal-cancel" onClick={closeModal}>
|
|
取消核销
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|