259 lines
8.9 KiB
TypeScript
259 lines
8.9 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import type { RedeemPhoneDirectPrepareResult } from '@dukang/shared-types';
|
|
import { request } from '../lib/api';
|
|
import { toastError, toastSuccess } from '../lib/toast';
|
|
import { useStorePageView } from '../lib/usePageView';
|
|
|
|
function formatAmount(n: number) {
|
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
}
|
|
|
|
export default function PhoneRedeemPage() {
|
|
useStorePageView('store_phone_redeem_view');
|
|
const navigate = useNavigate();
|
|
const [phone, setPhone] = useState('');
|
|
const [amount, setAmount] = useState('');
|
|
const [confirmCode, setConfirmCode] = useState('');
|
|
const [prepared, setPrepared] = useState<RedeemPhoneDirectPrepareResult | null>(null);
|
|
const [storeName, setStoreName] = useState('');
|
|
const [storeClosed, setStoreClosed] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
const [confirmCooldown, setConfirmCooldown] = useState(0);
|
|
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(() => {
|
|
if (confirmCooldown <= 0) return;
|
|
const timer = window.setTimeout(() => setConfirmCooldown((v) => v - 1), 1000);
|
|
return () => window.clearTimeout(timer);
|
|
}, [confirmCooldown]);
|
|
|
|
async function prepareDirectRedeem() {
|
|
const value = Number(amount);
|
|
if (!Number.isFinite(value) || value <= 0) {
|
|
toastError('请输入有效核销金额');
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
try {
|
|
const result = await request<RedeemPhoneDirectPrepareResult>('SHOP_H5', '/shop/redeem/phone/prepare-direct', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ phone: phone.trim(), amount: value }),
|
|
});
|
|
setPrepared(result);
|
|
setConfirmCode('');
|
|
setConfirmCooldown(60);
|
|
toastSuccess(`验证码已发送至 ${result.maskedPhone},验证成功后将直接核销`);
|
|
} catch (e) {
|
|
setPrepared(null);
|
|
toastError(e instanceof Error ? e.message : '发送验证码失败');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function sendConfirmSms() {
|
|
if (!/^1\d{10}$/.test(phone.trim())) {
|
|
toastError('请输入正确的手机号');
|
|
return;
|
|
}
|
|
if (storeClosed) {
|
|
setShowOpenModal(true);
|
|
return;
|
|
}
|
|
await prepareDirectRedeem();
|
|
}
|
|
|
|
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);
|
|
await prepareDirectRedeem();
|
|
} catch (e) {
|
|
setShowOpenModal(false);
|
|
toastError(e instanceof Error ? e.message : '开启营业失败');
|
|
} finally {
|
|
setOpening(false);
|
|
}
|
|
}
|
|
|
|
async function confirmRedeem() {
|
|
if (!prepared) {
|
|
toastError('请先发送核销验证码');
|
|
return;
|
|
}
|
|
if (!confirmCode.trim()) {
|
|
toastError('请输入确认验证码');
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
try {
|
|
const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/phone/confirm', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
sessionId: prepared.sessionId,
|
|
code: confirmCode.trim(),
|
|
}),
|
|
});
|
|
sessionStorage.setItem('lastRedeemResult', JSON.stringify(result));
|
|
navigate('/redeem/success', {
|
|
state: { result, storeName, user: prepared.user },
|
|
});
|
|
} catch (e) {
|
|
toastError(e instanceof Error ? e.message : '核销失败');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
const amountValue = Number(amount);
|
|
const canSendCode =
|
|
/^1\d{10}$/.test(phone.trim()) && Number.isFinite(amountValue) && amountValue > 0;
|
|
|
|
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">smartphone</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-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>
|
|
|
|
<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>
|
|
|
|
<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-phone-code-btn"
|
|
disabled={loading || confirmCooldown > 0 || !canSendCode}
|
|
onClick={() => void sendConfirmSms()}
|
|
>
|
|
{confirmCooldown > 0 ? `${confirmCooldown}s` : '发送验证码'}
|
|
</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>
|
|
</div>
|
|
</section>
|
|
</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>
|
|
);
|
|
}
|