feat(redeem): streamline phone redemption confirmation
CI / verify (pull_request) Has been cancelled
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,33 +1,22 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { RedeemPhoneDirectPrepareResult } from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
type BalanceResult = {
|
||||
sessionId: string;
|
||||
totalBalance: number;
|
||||
maskedPhone: string;
|
||||
user?: { nickname?: string; phone?: string; userNo?: string };
|
||||
};
|
||||
|
||||
function formatAmount(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
type Step = 'lookup' | 'amount' | 'confirm';
|
||||
|
||||
export default function PhoneRedeemPage() {
|
||||
const navigate = useNavigate();
|
||||
const [step, setStep] = useState<Step>('lookup');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [lookupCode, setLookupCode] = useState('');
|
||||
const [confirmCode, setConfirmCode] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [balance, setBalance] = useState<BalanceResult | null>(null);
|
||||
const [confirmCode, setConfirmCode] = useState('');
|
||||
const [prepared, setPrepared] = useState<RedeemPhoneDirectPrepareResult | null>(null);
|
||||
const [storeName, setStoreName] = useState('');
|
||||
const [storeClosed, setStoreClosed] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [lookupCooldown, setLookupCooldown] = useState(0);
|
||||
const [confirmCooldown, setConfirmCooldown] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -39,62 +28,17 @@ export default function PhoneRedeemPage() {
|
||||
.catch(() => setStoreName('当前门店'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (lookupCooldown <= 0) return;
|
||||
const timer = window.setTimeout(() => setLookupCooldown((v) => v - 1), 1000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [lookupCooldown]);
|
||||
|
||||
useEffect(() => {
|
||||
if (confirmCooldown <= 0) return;
|
||||
const timer = window.setTimeout(() => setConfirmCooldown((v) => v - 1), 1000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [confirmCooldown]);
|
||||
|
||||
async function sendLookupSms() {
|
||||
async function sendConfirmSms() {
|
||||
if (!/^1\d{10}$/.test(phone.trim())) {
|
||||
setMsg('请输入正确的手机号');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
await request('SHOP_H5', '/shop/redeem/phone/send-lookup-sms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: phone.trim() }),
|
||||
});
|
||||
setLookupCooldown(60);
|
||||
setMsg('验证码已发送至用户手机');
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '发送失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function queryBalance() {
|
||||
if (!lookupCode.trim()) {
|
||||
setMsg('请输入验证码');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const res = await request<BalanceResult>('SHOP_H5', '/shop/redeem/phone/balance', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: phone.trim(), code: lookupCode.trim() }),
|
||||
});
|
||||
setBalance(res);
|
||||
setStep('amount');
|
||||
setMsg('');
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '查询失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareRedeem() {
|
||||
if (storeClosed) {
|
||||
setMsg('门店未营业,无法核销');
|
||||
return;
|
||||
@@ -104,28 +48,30 @@ export default function PhoneRedeemPage() {
|
||||
setMsg('请输入有效核销金额');
|
||||
return;
|
||||
}
|
||||
if (balance && value > balance.totalBalance) {
|
||||
setMsg('核销金额不能超过可用权益');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
await request('SHOP_H5', '/shop/redeem/phone/prepare', {
|
||||
const result = await request<RedeemPhoneDirectPrepareResult>('SHOP_H5', '/shop/redeem/phone/prepare-direct', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sessionId: balance?.sessionId, amount: value }),
|
||||
body: JSON.stringify({ phone: phone.trim(), amount: value }),
|
||||
});
|
||||
setPrepared(result);
|
||||
setConfirmCode('');
|
||||
setConfirmCooldown(60);
|
||||
setStep('confirm');
|
||||
setMsg('确认验证码已发送至用户手机,请向用户索取后输入');
|
||||
setMsg(`验证码已发送至 ${result.maskedPhone},验证成功后将直接核销`);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '发起核销失败');
|
||||
setPrepared(null);
|
||||
setMsg(e instanceof Error ? e.message : '发送验证码失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRedeem() {
|
||||
if (!prepared) {
|
||||
setMsg('请先发送核销验证码');
|
||||
return;
|
||||
}
|
||||
if (!confirmCode.trim()) {
|
||||
setMsg('请输入确认验证码');
|
||||
return;
|
||||
@@ -136,13 +82,13 @@ export default function PhoneRedeemPage() {
|
||||
const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/phone/confirm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: balance?.sessionId,
|
||||
sessionId: prepared.sessionId,
|
||||
code: confirmCode.trim(),
|
||||
}),
|
||||
});
|
||||
sessionStorage.setItem('lastRedeemResult', JSON.stringify(result));
|
||||
navigate('/redeem/success', {
|
||||
state: { result, storeName, user: balance?.user },
|
||||
state: { result, storeName, user: prepared.user },
|
||||
});
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '核销失败');
|
||||
@@ -151,7 +97,9 @@ export default function PhoneRedeemPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const userLabel = balance?.user?.nickname || balance?.maskedPhone || '—';
|
||||
const amountValue = Number(amount);
|
||||
const canSendCode =
|
||||
/^1\d{10}$/.test(phone.trim()) && Number.isFinite(amountValue) && amountValue > 0;
|
||||
|
||||
return (
|
||||
<div className="shop-redeem-page">
|
||||
@@ -179,150 +127,77 @@ export default function PhoneRedeemPage() {
|
||||
</div>
|
||||
|
||||
<div className="shop-redeem-body">
|
||||
{step === 'lookup' && (
|
||||
<>
|
||||
<div className="shop-phone-field">
|
||||
<label className="shop-phone-label">用户手机号</label>
|
||||
<input
|
||||
className="shop-phone-input"
|
||||
type="tel"
|
||||
maxLength={11}
|
||||
placeholder="请输入用户手机号"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
</div>
|
||||
<div className="shop-phone-field">
|
||||
<label className="shop-phone-label">验证码</label>
|
||||
<div className="shop-phone-code-row">
|
||||
<input
|
||||
className="shop-phone-input"
|
||||
type="text"
|
||||
maxLength={6}
|
||||
placeholder="用户收到的验证码"
|
||||
value={lookupCode}
|
||||
onChange={(e) => setLookupCode(e.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-phone-code-btn"
|
||||
disabled={loading || lookupCooldown > 0 || !phone.trim()}
|
||||
onClick={() => void sendLookupSms()}
|
||||
>
|
||||
{lookupCooldown > 0 ? `${lookupCooldown}s` : '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-redeem-confirm-btn"
|
||||
disabled={loading || storeClosed}
|
||||
onClick={() => void queryBalance()}
|
||||
>
|
||||
查询权益
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="shop-phone-field">
|
||||
<label className="shop-phone-label">用户手机号</label>
|
||||
<input
|
||||
className="shop-phone-input"
|
||||
type="tel"
|
||||
maxLength={11}
|
||||
placeholder="请输入用户手机号"
|
||||
value={phone}
|
||||
disabled={loading}
|
||||
onChange={(e) => {
|
||||
setPhone(e.target.value.replace(/\D/g, ''));
|
||||
setPrepared(null);
|
||||
setConfirmCode('');
|
||||
setConfirmCooldown(0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{step === 'amount' && balance && (
|
||||
<>
|
||||
<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">
|
||||
<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(balance.totalBalance)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shop-phone-field">
|
||||
<label className="shop-phone-label">核销金额</label>
|
||||
<input
|
||||
className="shop-phone-input"
|
||||
type="number"
|
||||
min={0.01}
|
||||
step={0.01}
|
||||
placeholder="请输入核销金额"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-redeem-confirm-btn"
|
||||
disabled={loading || storeClosed || balance.totalBalance <= 0}
|
||||
onClick={() => void prepareRedeem()}
|
||||
>
|
||||
发送确认验证码并核销
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-phone-link-btn"
|
||||
onClick={() => {
|
||||
setStep('lookup');
|
||||
setBalance(null);
|
||||
setAmount('');
|
||||
setLookupCode('');
|
||||
}}
|
||||
>
|
||||
更换手机号
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="shop-phone-field">
|
||||
<label className="shop-phone-label">待核销金额</label>
|
||||
<input
|
||||
className="shop-phone-input"
|
||||
type="number"
|
||||
min={0.01}
|
||||
step={0.01}
|
||||
placeholder="请输入待核销金额"
|
||||
value={amount}
|
||||
disabled={loading}
|
||||
onChange={(e) => {
|
||||
setAmount(e.target.value);
|
||||
setPrepared(null);
|
||||
setConfirmCode('');
|
||||
setConfirmCooldown(0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{step === 'confirm' && balance && (
|
||||
<>
|
||||
<div className="shop-redeem-details">
|
||||
<div className="shop-redeem-detail-row">
|
||||
<span>用户</span>
|
||||
<span>{userLabel}</span>
|
||||
</div>
|
||||
<div className="shop-redeem-detail-row">
|
||||
<span>核销金额</span>
|
||||
<span>¥{formatAmount(Number(amount))}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shop-phone-field">
|
||||
<label className="shop-phone-label">核销确认验证码</label>
|
||||
<input
|
||||
className="shop-phone-input"
|
||||
type="text"
|
||||
maxLength={6}
|
||||
placeholder="用户手机收到的确认码"
|
||||
value={confirmCode}
|
||||
onChange={(e) => setConfirmCode(e.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
<p className="shop-redeem-hint" style={{ marginTop: 8 }}>
|
||||
{confirmCooldown > 0 ? `${confirmCooldown}s 后可重新发送` : '未收到可向用户确认或返回上一步重发'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="shop-phone-field">
|
||||
<label className="shop-phone-label">核销验证码</label>
|
||||
<div className="shop-phone-code-row">
|
||||
<input
|
||||
className="shop-phone-input"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
placeholder="输入用户收到的验证码"
|
||||
value={confirmCode}
|
||||
onChange={(e) => setConfirmCode(e.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-redeem-confirm-btn"
|
||||
disabled={loading || storeClosed}
|
||||
onClick={() => void confirmRedeem()}
|
||||
className="shop-phone-code-btn"
|
||||
disabled={loading || confirmCooldown > 0 || storeClosed || !canSendCode}
|
||||
onClick={() => void sendConfirmSms()}
|
||||
>
|
||||
{loading ? '正在核销…' : `确认核销 ¥${formatAmount(Number(amount))}`}
|
||||
{confirmCooldown > 0 ? `${confirmCooldown}s` : '发送验证码'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-phone-link-btn"
|
||||
onClick={() => {
|
||||
setStep('amount');
|
||||
setConfirmCode('');
|
||||
}}
|
||||
>
|
||||
返回修改金额
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="shop-redeem-hint" style={{ marginTop: 8 }}>
|
||||
验证码将发送到用户手机号,验证成功后直接完成核销。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="shop-redeem-confirm-btn"
|
||||
disabled={loading || storeClosed || !prepared || !confirmCode.trim()}
|
||||
onClick={() => void confirmRedeem()}
|
||||
>
|
||||
{loading ? '正在核销…' : `确认核销 ¥${formatAmount(amountValue || 0)}`}
|
||||
</button>
|
||||
|
||||
{msg && <p className="shop-redeem-error">{msg}</p>}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user