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} ); }