Files
dukang/apps/h5-user/src/pages/OrderConfirmPage.tsx
T
2026-07-09 21:52:17 +08:00

443 lines
15 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import SubPageHeader from '../components/SubPageHeader';
import AppImage from '@dukang/shared-ui/AppImage';
import { request } from '../lib/api';
import { buildProductDetailUrl } from '../lib/navigation';
import { getProductMainImage } from '../lib/product-images';
import { track } from '../lib/analytics';
import PhoneVerifySheet from '../components/PhoneVerifySheet';
import { useUserSession } from '../contexts/UserSessionContext';
import { tryGetClientGpsLocation } from '../lib/client-location';
import {
applyWechatLoginResult,
ensureWechatAuthForPay,
handleWechatAuthCallback,
} from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin';
type Address = {
id: string;
receiverName: string;
phone: string;
province: string;
city: string;
district: string;
detail: string;
isDefault?: number;
};
type PreviewProduct = {
id: string;
name: string;
spec: string;
subtitle?: string;
price: number;
mainImageUrl?: string | null;
carouselUrls?: string[] | null;
};
type OrderPreview = {
product: PreviewProduct;
quantity: number;
deliveryType: 'LOCAL' | 'CROSS_CITY';
productAmount: number;
freightPayType: 'COD' | null;
payAmount: number;
benefitAmount: number;
city?: { localMinQty: number; crossMinQty: number };
};
function maskPhone(phone: string) {
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
}
function formatAddress(a: Address) {
return `${a.province}${a.city}${a.district}${a.detail}`;
}
export default function OrderConfirmPage() {
const [params] = useSearchParams();
const navigate = useNavigate();
const { phoneVerified, refreshProfile } = useUserSession();
const [showPhoneVerify, setShowPhoneVerify] = useState(false);
const [showWechatBindPhone, setShowWechatBindPhone] = useState(false);
const [wxSessionKey, setWxSessionKey] = useState<string | null>(null);
const [pendingSubmit, setPendingSubmit] = useState(false);
const productId = params.get('productId') || '';
const forceCross = params.get('cross') === '1';
const [quantity, setQuantity] = useState(Number(params.get('qty') || 2));
const [addresses, setAddresses] = useState<Address[]>([]);
const [addressId, setAddressId] = useState(params.get('addressId') || '');
const [preview, setPreview] = useState<OrderPreview | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState('');
useEffect(() => {
request<Address[]>('USER_H5', '/user/addresses').then((list) => {
setAddresses(list);
const fromUrl = params.get('addressId');
if (fromUrl && list.some((a) => String(a.id) === fromUrl)) {
setAddressId(fromUrl);
return;
}
const def = list.find((a) => a.isDefault === 1) || list[0];
if (def) setAddressId(String(def.id));
});
}, [params]);
useEffect(() => {
if (productId) {
track('order_confirm_view', { refType: 'PRODUCT', refId: productId, productId, quantity });
}
}, [productId, quantity]);
useEffect(() => {
if (!isWechatEnv()) return;
handleWechatAuthCallback()
.then((result) => {
if (result && applyWechatLoginResult(result)) {
void refreshProfile();
}
})
.catch(() => {});
}, [refreshProfile]);
useEffect(() => {
if (!productId) return;
let cancelled = false;
setPreviewLoading(true);
const body: { productId: string; quantity: number; addressId?: string } = {
productId,
quantity,
};
if (addressId) body.addressId = addressId;
request<OrderPreview>('USER_H5', '/trade/orders/preview', {
method: 'POST',
body: JSON.stringify(body),
})
.then((data) => {
if (!cancelled) setPreview(data);
})
.catch((e) => {
if (!cancelled) {
setPreview(null);
setMsg(e instanceof Error ? e.message : String(e));
}
})
.finally(() => {
if (!cancelled) setPreviewLoading(false);
});
return () => {
cancelled = true;
};
}, [productId, quantity, addressId]);
function updateQuantity(next: number) {
const delivery = forceCross || preview?.deliveryType === 'CROSS_CITY' ? 'CROSS_CITY' : 'LOCAL';
const localMin = preview?.city?.localMinQty ?? 2;
const crossMin = preview?.city?.crossMinQty ?? 6;
const min = delivery === 'LOCAL' ? localMin : crossMin;
if (next < min) {
setMsg(
delivery === 'LOCAL'
? `同城配送至少购买 ${min} 瓶`
: `跨城配送至少购买 ${min} 瓶(1箱)`,
);
return;
}
setMsg('');
setQuantity(next);
const qs = new URLSearchParams(params);
qs.set('qty', String(next));
navigate({ search: qs.toString() }, { replace: true });
}
const selectedAddress = useMemo(
() => addresses.find((a) => String(a.id) === addressId),
[addresses, addressId],
);
const isCross = forceCross || preview?.deliveryType === 'CROSS_CITY';
const minQty = isCross ? (preview?.city?.crossMinQty ?? 6) : (preview?.city?.localMinQty ?? 2);
const productImage = preview?.product ? getProductMainImage(preview.product) : getProductMainImage();
async function doSubmit() {
let clientLocation = null;
try {
clientLocation = await tryGetClientGpsLocation();
} catch {
/* GPS 获取失败不阻塞下单 */
}
const order = await request<{ id: string }>('USER_H5', '/trade/orders', {
method: 'POST',
body: JSON.stringify({
productId,
quantity,
addressId,
...(clientLocation ? { clientLocation } : {}),
}),
});
const qs = new URLSearchParams();
qs.set('orderId', order.id);
qs.set('productId', productId);
qs.set('qty', String(quantity));
qs.set('addressId', addressId);
if (forceCross) qs.set('cross', '1');
navigate(`/pay?${qs.toString()}`);
}
async function submit() {
if (!addressId) {
setMsg('请选择收货地址');
return;
}
if (!phoneVerified) {
setPendingSubmit(true);
setShowPhoneVerify(true);
return;
}
const authResult = await ensureWechatAuthForPay();
if (!authResult.ok) {
if ('needBindPhone' in authResult) {
setWxSessionKey(authResult.wxSessionKey);
setShowWechatBindPhone(true);
setPendingSubmit(true);
return;
}
return;
}
setLoading(true);
setMsg('');
try {
await doSubmit();
} catch (e) {
setMsg(e instanceof Error ? e.message : '下单失败');
} finally {
setLoading(false);
}
}
async function handleWechatPhoneBound() {
await refreshProfile();
if (!pendingSubmit) return;
setPendingSubmit(false);
setLoading(true);
setMsg('');
try {
await doSubmit();
} catch (e) {
setMsg(e instanceof Error ? e.message : '下单失败');
} finally {
setLoading(false);
}
}
async function handlePhoneVerified() {
await refreshProfile();
if (!pendingSubmit) return;
setPendingSubmit(false);
setLoading(true);
setMsg('');
try {
await doSubmit();
} catch (e) {
setMsg(e instanceof Error ? e.message : '下单失败');
} finally {
setLoading(false);
}
}
return (
<div className="order-confirm-page">
<SubPageHeader
title="确认订单"
onBack={() => navigate(buildProductDetailUrl(productId))}
/>
<main className="order-confirm-main sub-page-body">
<button
type="button"
className="order-confirm-card order-confirm-address"
onClick={() => {
const qs = new URLSearchParams();
if (productId) qs.set('productId', productId);
qs.set('qty', String(quantity));
if (forceCross) qs.set('cross', '1');
if (addressId) qs.set('addressId', addressId);
qs.set('select', '1');
navigate(`/addresses?${qs.toString()}`);
}}
>
<span className="material-symbols-outlined order-confirm-pin fill-icon">location_on</span>
{selectedAddress ? (
<div className="order-confirm-address-body">
<div className="order-confirm-address-row">
<span className="order-confirm-address-name">{selectedAddress.receiverName}</span>
<span className="order-confirm-address-phone">{maskPhone(selectedAddress.phone)}</span>
</div>
<p className="order-confirm-address-detail">{formatAddress(selectedAddress)}</p>
</div>
) : (
<span className="order-confirm-address-placeholder">请选择收货地址</span>
)}
<span className="material-symbols-outlined order-confirm-chevron">chevron_right</span>
</button>
{isCross && (
<div className="order-confirm-card order-confirm-warning">
<span className="material-symbols-outlined order-confirm-warning-icon">warning</span>
<p className="order-confirm-warning-text">
提示:该地址超出同城配送范围,将由总部通过物流快递发货。物流费用需由您承担(到付),请确认是否继续。
</p>
</div>
)}
{preview && (
<>
<section className="order-confirm-card order-confirm-product">
<div className="order-confirm-product-thumb">
<AppImage
src={productImage}
alt={preview.product.name}
wrapperClassName="app-image--fill"
/>
</div>
<div className="order-confirm-product-info">
<div>
<h3 className="order-confirm-product-name">{preview.product.name}</h3>
<p className="order-confirm-product-spec">
{preview.product.spec || preview.product.subtitle}
</p>
</div>
<div className="order-confirm-product-meta">
<span className="order-confirm-product-price">¥{preview.product.price}</span>
<div className="order-confirm-qty-stepper">
<button
type="button"
className="order-confirm-qty-btn"
disabled={quantity <= minQty}
aria-label="减少数量"
onClick={() => updateQuantity(quantity - 1)}
>
<span className="material-symbols-outlined">remove</span>
</button>
<span className="order-confirm-qty-value">{quantity}</span>
<button
type="button"
className="order-confirm-qty-btn order-confirm-qty-btn--plus"
aria-label="增加数量"
onClick={() => updateQuantity(quantity + 1)}
>
<span className="material-symbols-outlined">add</span>
</button>
</div>
</div>
</div>
</section>
<section className="order-confirm-card order-confirm-benefit coupon-notch">
<div className="order-confirm-benefit-inner">
<span className="order-confirm-benefit-badge">好客权益</span>
<span className="order-confirm-benefit-text">
本单可享好客权益 ¥{preview.benefitAmount}
</span>
</div>
</section>
<section className="order-confirm-card order-confirm-row-card">
<span className="order-confirm-row-label">配送方式</span>
<div className="order-confirm-delivery-value">
<p className="order-confirm-row-value">
{!addressId
? '选择地址后确认'
: isCross
? '物流配送'
: '小飞侠配送'}
</p>
{addressId && !isCross && (
<p className="order-confirm-delivery-hint">预计24小时内送达</p>
)}
</div>
</section>
<section className="order-confirm-card order-confirm-summary">
<div className="order-confirm-summary-line">
<span>商品总额</span>
<span>¥{preview.productAmount}</span>
</div>
<div className="order-confirm-summary-line">
<span>运费</span>
<span className={isCross ? 'order-confirm-freight-cod' : ''}>
{isCross ? '到付' : '免运费'}
</span>
</div>
<div className="order-confirm-summary-total">
<span>合计</span>
<span className="order-confirm-total-amount">¥{preview.payAmount}</span>
</div>
</section>
</>
)}
{previewLoading && !preview && productId && (
<div className="order-confirm-loading">加载订单信息...</div>
)}
{!previewLoading && !preview && productId && (
<div className="order-confirm-loading">无法加载商品信息</div>
)}
{msg && <p className="order-confirm-msg">{msg}</p>}
</main>
<footer className="order-confirm-footer">
<div className="order-confirm-footer-inner">
<div className="order-confirm-pay-label">
<span className="order-confirm-pay-prefix">实付:</span>
<span className="order-confirm-pay-amount">
¥{preview?.payAmount ?? '—'}
</span>
</div>
<button
type="button"
className="order-confirm-pay-btn"
disabled={loading || !preview || !addressId}
onClick={submit}
>
{loading ? '支付中...' : !addressId ? '请选择地址' : '微信支付'}
</button>
</div>
</footer>
<PhoneVerifySheet
open={showPhoneVerify}
defaultPhone={selectedAddress?.phone}
onClose={() => {
setShowPhoneVerify(false);
setPendingSubmit(false);
}}
onSuccess={handlePhoneVerified}
/>
<PhoneVerifySheet
open={showWechatBindPhone}
mode="wechat_bind_phone"
wxSessionKey={wxSessionKey ?? undefined}
defaultPhone={selectedAddress?.phone}
onClose={() => {
setShowWechatBindPhone(false);
setWxSessionKey(null);
setPendingSubmit(false);
}}
onSuccess={handleWechatPhoneBound}
/>
</div>
);
}