443 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|