import { useEffect, useMemo, useRef, useState } from 'react';
import { View, Text, Image } from '@tarojs/components';
import '../../styles/order.css';
import Taro, { useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import { goLogin } from '../../lib/auth-nav';
import { buildAddressListUrl, buildPayUrl, readCheckoutContext } from '../../lib/checkout-nav';
import { tryGetClientGpsLocation } from '../../lib/client-location';
import { maskPhone } from '../../lib/phone';
import { ensurePayReady } from '../../lib/pay-ready';
import { fetchUserProfile } from '../../lib/pay-wechat';
import { request, toast } from '../../lib/api';
import { canCrossCity, isCrossCityAddress } from '../../lib/product-fulfillment';
import { getProductMainImage } from '../../lib/product-images';
type Address = {
id: string;
receiverName: string;
phone: string;
province: string;
city: string;
district: string;
detail: string;
isDefault?: number | boolean;
};
type PreviewProduct = {
id: string;
name: string;
spec?: string;
subtitle?: string;
price: number;
mainImageUrl?: string | null;
carouselUrls?: string[] | null;
allowCrossCityDelivery?: boolean;
allowOnlinePurchase?: boolean;
};
type OrderPreview = {
product: PreviewProduct;
quantity: number;
deliveryType: 'LOCAL' | 'CROSS_CITY';
productAmount: number;
freightPayType: 'COD' | null;
payAmount: number;
benefitAmount: number;
city?: { name?: string; localMinQty: number; crossMinQty: number };
quantityOk?: boolean;
quantityMessage?: string | null;
addressOk?: boolean;
addressMessage?: string | null;
minQty?: number;
allowCrossCityDelivery?: boolean;
allowOnlinePurchase?: boolean;
};
const CROSS_CITY_BLOCK_MSG = '该商品不支持跨城配送,请更换为开城城市内的收货地址';
function formatAddress(a: Address) {
return `${a.province}${a.city}${a.district}${a.detail}`;
}
export default function OrderConfirmPage() {
const router = useRouter();
const checkoutCtx = readCheckoutContext(router.params);
const productId = checkoutCtx.productId ?? '';
const forceCross = checkoutCtx.cross === true;
const [quantity, setQuantity] = useState(Math.max(1, Number(checkoutCtx.qty || 2)));
const [addresses, setAddresses] = useState
([]);
const [addressId, setAddressId] = useState(checkoutCtx.addressId || '');
const [preview, setPreview] = useState(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState('');
const phonePromptSkipped = useRef(false);
const toastedAddressBlockRef = useRef('');
useEffect(() => {
request('/user/addresses')
.then((list) => {
setAddresses(list);
const fromUrl = checkoutCtx.addressId;
if (fromUrl && list.some((a) => String(a.id) === fromUrl)) {
setAddressId(fromUrl);
return;
}
const def = list.find((a) => a.isDefault === 1 || a.isDefault === true) || list[0];
if (def) setAddressId(String(def.id));
})
.catch(() => setAddresses([]));
}, [checkoutCtx.addressId]);
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('/trade/orders/preview', { method: 'POST', data: body })
.then((data) => {
if (!cancelled) {
setPreview(data);
const nextMsg =
data.addressOk === false
? data.addressMessage || CROSS_CITY_BLOCK_MSG
: data.quantityOk === false
? data.quantityMessage || ''
: '';
setMsg(nextMsg);
if (
data.addressOk === false &&
addressId &&
toastedAddressBlockRef.current !== addressId
) {
toastedAddressBlockRef.current = addressId;
toast(data.addressMessage || CROSS_CITY_BLOCK_MSG);
}
if (data.addressOk !== false) {
toastedAddressBlockRef.current = '';
}
}
})
.catch((e) => {
if (!cancelled) {
setPreview(null);
setMsg(e instanceof Error ? e.message : '加载失败');
}
})
.finally(() => {
if (!cancelled) setPreviewLoading(false);
});
return () => {
cancelled = true;
};
}, [productId, quantity, addressId]);
const selectedAddress = useMemo(
() => addresses.find((a) => String(a.id) === addressId),
[addresses, addressId],
);
const allowCross =
preview?.allowCrossCityDelivery !== undefined
? canCrossCity({ allowCrossCityDelivery: preview.allowCrossCityDelivery })
: canCrossCity(preview?.product ?? {});
const localCross =
!!selectedAddress &&
isCrossCityAddress(selectedAddress.city, preview?.city?.name);
const isCross =
forceCross || preview?.deliveryType === 'CROSS_CITY' || localCross;
const crossBlocked = isCross && !allowCross;
const addressOk = preview ? preview.addressOk !== false && !crossBlocked : !crossBlocked;
const minQty =
preview?.minQty ??
(isCross ? (preview?.city?.crossMinQty ?? 6) : (preview?.city?.localMinQty ?? 2));
const quantityOk = preview ? preview.quantityOk !== false && quantity >= minQty : false;
const canSubmit =
!!addressId && !!preview && quantityOk && addressOk && !loading && !previewLoading;
const addressHint = !addressOk
? preview?.addressMessage || CROSS_CITY_BLOCK_MSG
: '';
function updateQuantity(next: number) {
if (next < minQty) {
const tip = isCross
? `跨城配送至少购买 ${minQty} 瓶(1箱)`
: `同城配送至少购买 ${minQty} 瓶`;
toast(tip);
setMsg(tip);
if (next < 1) return;
setQuantity(next);
return;
}
setQuantity(next);
}
async function doSubmit() {
let clientLocation = null;
try {
clientLocation = await tryGetClientGpsLocation();
} catch {
/* GPS 获取失败不阻塞下单 */
}
const order = await request<{ id: string }>('/trade/orders', {
method: 'POST',
data: {
productId,
quantity,
addressId,
...(clientLocation ? { clientLocation } : {}),
},
});
Taro.redirectTo({
url: buildPayUrl({
orderId: order.id,
productId,
qty: String(quantity),
addressId,
cross: forceCross,
}),
});
}
async function submit() {
if (!canSubmit) {
if (!addressId) {
setMsg('请选择收货地址');
return;
}
if (!addressOk) {
const tip = addressHint || CROSS_CITY_BLOCK_MSG;
setMsg(tip);
toast(tip);
return;
}
if (!quantityOk) {
const tip = isCross
? `跨城配送至少购买 ${minQty} 瓶(1箱)`
: `同城配送至少购买 ${minQty} 瓶`;
setMsg(tip);
toast(tip);
}
return;
}
const returnPath = `/pages/order-confirm/index?productId=${productId}&qty=${quantity}&addressId=${addressId}${forceCross ? '&cross=1' : ''}`;
if (!phonePromptSkipped.current) {
try {
const profile = await fetchUserProfile();
const phoneBound =
!!profile.phoneVerified ||
(!!profile.phone && /^1[3-9]\d{9}$/.test(String(profile.phone)));
if (!phoneBound) {
const { confirm, cancel } = await Taro.showModal({
title: '建议绑定手机号',
content: '绑定后便于订单通知与售后联系;也可跳过,不绑定也能继续下单。',
confirmText: '去绑定',
cancelText: '暂不绑定',
});
if (confirm) {
goLogin(returnPath, { needPhone: '1' });
return;
}
if (cancel) {
phonePromptSkipped.current = true;
}
}
} catch {
/* 拉取档案失败不阻塞下单 */
}
}
const ready = await ensurePayReady(returnPath);
if (!ready) return;
setLoading(true);
setMsg('');
try {
await doSubmit();
} catch (e) {
setMsg(e instanceof Error ? e.message : '下单失败');
} finally {
setLoading(false);
}
}
const productImage = preview?.product ? getProductMainImage(preview.product) : '';
const submitLabel = loading
? '提交中…'
: !addressId
? '请选择地址'
: !addressOk
? '请更换地址'
: !quantityOk
? `至少购买 ${minQty} 瓶`
: '提交订单';
const displayMsg = msg || addressHint;
return (
Taro.navigateTo({
url: buildAddressListUrl({
productId,
qty: String(quantity),
addressId,
cross: forceCross,
}),
})
}
>
收货地址
{selectedAddress ? (
{selectedAddress.receiverName}
{maskPhone(selectedAddress.phone)}
{formatAddress(selectedAddress)}
) : (
点击选择收货地址
)}
{!addressOk && addressId ? (
{addressHint || CROSS_CITY_BLOCK_MSG}
) : null}
{isCross && addressOk ? (
该地址超出同城配送范围,将由总部物流发货,运费到付
{quantity < minQty ? `;跨城至少购买 ${minQty} 瓶(1箱)` : ''}。
) : null}
{preview ? (
<>
商品信息
{productImage ? (
) : null}
{preview.product.name}
¥{Number(preview.product.price).toFixed(2)}
购买数量
updateQuantity(quantity - 1)}
>
−
{quantity}
updateQuantity(quantity + 1)}
>
+
{!quantityOk ? (
{isCross
? `跨城配送至少购买 ${minQty} 瓶(1箱),请调整数量`
: `同城配送至少购买 ${minQty} 瓶,请调整数量`}
) : null}
费用明细
商品金额
¥{preview.productAmount.toFixed(2)}
好客权益
¥{preview.benefitAmount.toFixed(2)}
运费
{isCross ? '到付' : '免运费'}
>
) : null}
{previewLoading && !preview && productId ? (
加载订单信息…
) : null}
{!previewLoading && !preview && productId ? (
无法加载商品信息
) : null}
{displayMsg ? (
{displayMsg}
) : null}
应付合计
¥{preview ? preview.payAmount.toFixed(2) : '—'}
{
if (!canSubmit) return;
void submit();
}}
>
{submitLabel}
);
}