276 lines
9.8 KiB
TypeScript
276 lines
9.8 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
|
import WeakNetFallbackPanel from '../components/WeakNetFallbackPanel';
|
|
import { request } from '../lib/api';
|
|
import { reportRedeemFailure } from '../lib/redeem-failure';
|
|
import { toastError } from '../lib/toast';
|
|
import { useStorePageView } from '../lib/usePageView';
|
|
|
|
function formatAmount(n: number) {
|
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
}
|
|
|
|
type Preview = {
|
|
amount: number;
|
|
expireInSeconds: number;
|
|
user?: { userNo?: string; phone?: string; nickname?: string };
|
|
redeemType?: string;
|
|
boundStoreId?: string | null;
|
|
};
|
|
|
|
export default function RedeemConfirmPage() {
|
|
useStorePageView('store_redeem_confirm_view');
|
|
const navigate = useNavigate();
|
|
const [searchParams] = useSearchParams();
|
|
const [token, setToken] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const [storeName, setStoreName] = useState('');
|
|
const [preview, setPreview] = useState<Preview | null>(null);
|
|
const [storeClosed, setStoreClosed] = useState(false);
|
|
const [failCount, setFailCount] = useState(0);
|
|
const [showWeakNet, setShowWeakNet] = useState(false);
|
|
const [showOpenModal, setShowOpenModal] = useState(false);
|
|
const [opening, setOpening] = useState(false);
|
|
|
|
useEffect(() => {
|
|
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
|
|
.then((s) => {
|
|
setStoreName(String(s.name || '当前门店'));
|
|
if (s.status && s.status !== 'OPEN') setStoreClosed(true);
|
|
})
|
|
.catch(() => setStoreName('当前门店'));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const scanned = searchParams.get('token')?.trim();
|
|
if (!scanned) {
|
|
navigate('/', { replace: true });
|
|
return;
|
|
}
|
|
setToken(scanned);
|
|
}, [searchParams, navigate]);
|
|
|
|
async function loadPreview() {
|
|
if (!token.trim()) {
|
|
setPreview(null);
|
|
return;
|
|
}
|
|
try {
|
|
const p = await request<Preview>('SHOP_H5', '/shop/redeem/preview', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ token }),
|
|
});
|
|
setPreview(p);
|
|
} catch (e) {
|
|
setPreview(null);
|
|
toastError(e instanceof Error ? e.message : '无法预览核销码');
|
|
const report = await reportRedeemFailure(token, 'preview', e);
|
|
if (report?.thresholdReached) {
|
|
setFailCount(report.failCount);
|
|
setShowWeakNet(true);
|
|
} else if (report) {
|
|
setFailCount(report.failCount);
|
|
}
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
void loadPreview();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [token]);
|
|
|
|
async function doConfirm() {
|
|
if (!token.trim()) {
|
|
toastError('请先扫码获取核销码');
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
try {
|
|
const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/confirm', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ token }),
|
|
});
|
|
sessionStorage.setItem('lastRedeemResult', JSON.stringify(result));
|
|
navigate('/redeem/success', { state: { result, storeName, user: preview?.user } });
|
|
} catch (e) {
|
|
toastError(e instanceof Error ? e.message : '核销失败');
|
|
const report = await reportRedeemFailure(token, 'confirm', e);
|
|
if (report?.thresholdReached) {
|
|
setFailCount(report.failCount);
|
|
setShowWeakNet(true);
|
|
} else if (report) {
|
|
setFailCount(report.failCount);
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function confirm() {
|
|
if (storeClosed) {
|
|
setShowOpenModal(true);
|
|
return;
|
|
}
|
|
await doConfirm();
|
|
}
|
|
|
|
async function openStoreAndContinue() {
|
|
if (opening) return;
|
|
setOpening(true);
|
|
try {
|
|
await request('SHOP_H5', '/shop/store/status', {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ status: 'OPEN' }),
|
|
});
|
|
setStoreClosed(false);
|
|
setShowOpenModal(false);
|
|
// 开张后重新拉取预览(门店已 OPEN,后端不再拦截),再继续核销
|
|
await loadPreview();
|
|
await doConfirm();
|
|
} catch (e) {
|
|
setShowOpenModal(false);
|
|
toastError(e instanceof Error ? e.message : '开启营业失败');
|
|
} finally {
|
|
setOpening(false);
|
|
}
|
|
}
|
|
|
|
const previewAmount = preview?.amount ?? 0;
|
|
const userLabel = preview?.user?.nickname || preview?.user?.phone || '—';
|
|
|
|
return (
|
|
<div className="shop-redeem-page">
|
|
<header className="shop-redeem-header">
|
|
<button type="button" className="shop-redeem-back" onClick={() => navigate(-1)} aria-label="返回">
|
|
<span className="material-symbols-outlined">arrow_back</span>
|
|
</button>
|
|
<h1 className="app-page-title">核销确认</h1>
|
|
</header>
|
|
|
|
<main className="shop-redeem-main">
|
|
{storeClosed && (
|
|
<p className="shop-redeem-error" style={{ marginBottom: 12 }}>门店目前休息中无法核销,开启营业后可继续</p>
|
|
)}
|
|
<section className="shop-redeem-card">
|
|
<div className="shop-redeem-banner">
|
|
<div className="shop-redeem-banner-icon">
|
|
<span className="material-symbols-outlined shop-fill-icon">verified_user</span>
|
|
</div>
|
|
<div>
|
|
<p className="shop-redeem-banner-label">当前登录核销门店</p>
|
|
<h2 className="shop-redeem-banner-name">{storeName}</h2>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="shop-redeem-body">
|
|
<div className="shop-redeem-user">
|
|
<div className="shop-redeem-user-left">
|
|
<span className="material-symbols-outlined">person</span>
|
|
<span>下单用户</span>
|
|
</div>
|
|
<span className="headline-md" style={{ fontFamily: 'var(--font-headline)', fontWeight: 600 }}>
|
|
{userLabel}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="shop-redeem-amount-section">
|
|
<span className="shop-redeem-notch shop-redeem-notch--left" />
|
|
<span className="shop-redeem-notch shop-redeem-notch--right" />
|
|
<p className="shop-redeem-amount-label">核销金额</p>
|
|
<div className="shop-redeem-amount">
|
|
<span className="shop-redeem-amount-symbol">¥</span>
|
|
<span className="shop-redeem-amount-value">{formatAmount(previewAmount)}</span>
|
|
</div>
|
|
<span className="shop-redeem-benefit">
|
|
<span className="material-symbols-outlined shop-fill-icon" style={{ fontSize: 16, color: 'var(--color-aged-amber)' }}>
|
|
confirmation_number
|
|
</span>
|
|
好客权益 · {preview?.redeemType === 'COUPON' ? '单据核销' : '直接核销'}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="shop-redeem-details">
|
|
<div className="shop-redeem-detail-row">
|
|
<span>核销码 ID</span>
|
|
<span style={{ wordBreak: 'break-all', maxWidth: '60%', textAlign: 'right' }}>
|
|
{token || '扫码后显示'}
|
|
</span>
|
|
</div>
|
|
<div className="shop-redeem-detail-row">
|
|
<span>有效期</span>
|
|
<span>{preview ? `${preview.expireInSeconds} 秒` : '—'}</span>
|
|
</div>
|
|
{preview?.boundStoreId && (
|
|
<div className="shop-redeem-detail-row">
|
|
<span>绑定门店</span>
|
|
<span>仅限指定门店</span>
|
|
</div>
|
|
)}
|
|
{failCount > 0 && !showWeakNet && (
|
|
<div className="shop-redeem-detail-row">
|
|
<span>网络失败</span>
|
|
<span>{failCount} / 5 次</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{!showWeakNet && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className={`shop-redeem-confirm-btn${loading ? ' success' : ''}`}
|
|
disabled={loading || (!preview && !storeClosed)}
|
|
onClick={() => void confirm()}
|
|
>
|
|
<span className="material-symbols-outlined shop-fill-icon">
|
|
{loading ? 'sync' : 'check_circle'}
|
|
</span>
|
|
<span>{loading ? '正在核销...' : preview ? `确认核销 ¥${formatAmount(previewAmount)}` : storeClosed ? '确认核销' : '加载中…'}</span>
|
|
</button>
|
|
<p className="shop-redeem-hint">请核对金额后点击确认</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
{showWeakNet && token && (
|
|
<WeakNetFallbackPanel redeemToken={token} failCount={failCount} />
|
|
)}
|
|
|
|
<div className="shop-redeem-ornament">
|
|
<span className="material-symbols-outlined" style={{ fontSize: 64, color: 'var(--color-heritage-red)' }}>
|
|
wine_bar
|
|
</span>
|
|
</div>
|
|
</main>
|
|
|
|
{showOpenModal && (
|
|
<div className="shop-redeem-modal" role="dialog" aria-modal="true">
|
|
<div className="shop-redeem-modal-card">
|
|
<h4 className="shop-redeem-modal-title">门店休息中</h4>
|
|
<p className="shop-redeem-modal-desc">门店目前休息中无法核销,是否开启营业?</p>
|
|
<div className="shop-redeem-modal-actions">
|
|
<button
|
|
type="button"
|
|
className="shop-redeem-modal-cancel"
|
|
disabled={opening}
|
|
onClick={() => setShowOpenModal(false)}
|
|
>
|
|
取消
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="shop-redeem-modal-confirm"
|
|
disabled={opening}
|
|
onClick={() => void openStoreAndContinue()}
|
|
>
|
|
{opening ? '开启中…' : '确认开启'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|