430 lines
14 KiB
TypeScript
430 lines
14 KiB
TypeScript
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<Address[]>([]);
|
||
const [addressId, setAddressId] = useState(checkoutCtx.addressId || '');
|
||
const [preview, setPreview] = useState<OrderPreview | null>(null);
|
||
const [previewLoading, setPreviewLoading] = useState(false);
|
||
const [loading, setLoading] = useState(false);
|
||
const [msg, setMsg] = useState('');
|
||
const phonePromptSkipped = useRef(false);
|
||
const toastedAddressBlockRef = useRef('');
|
||
|
||
useEffect(() => {
|
||
request<Address[]>('/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<OrderPreview>('/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 (
|
||
<PageShell variant="sub" className="order-confirm-page" hasFixedFooter>
|
||
<SubPageHeader title="确认订单" />
|
||
<View className="sub-page-body">
|
||
<View
|
||
className="order-card"
|
||
onClick={() =>
|
||
Taro.navigateTo({
|
||
url: buildAddressListUrl({
|
||
productId,
|
||
qty: String(quantity),
|
||
addressId,
|
||
cross: forceCross,
|
||
}),
|
||
})
|
||
}
|
||
>
|
||
<Text className="order-card-title">收货地址</Text>
|
||
{selectedAddress ? (
|
||
<View>
|
||
<View style={{ display: 'flex', gap: '8px', marginBottom: 4 }}>
|
||
<Text className="order-card-title" style={{ fontSize: 15 }}>{selectedAddress.receiverName}</Text>
|
||
<Text className="u-muted">{maskPhone(selectedAddress.phone)}</Text>
|
||
</View>
|
||
<Text className="u-muted">{formatAddress(selectedAddress)}</Text>
|
||
</View>
|
||
) : (
|
||
<Text className="u-muted">点击选择收货地址</Text>
|
||
)}
|
||
</View>
|
||
|
||
{!addressOk && addressId ? (
|
||
<View className="order-card order-card--warn">
|
||
<Text className="order-warn-text">{addressHint || CROSS_CITY_BLOCK_MSG}</Text>
|
||
</View>
|
||
) : null}
|
||
|
||
{isCross && addressOk ? (
|
||
<View className="order-card">
|
||
<Text className="u-muted">
|
||
该地址超出同城配送范围,将由总部物流发货,运费到付
|
||
{quantity < minQty ? `;跨城至少购买 ${minQty} 瓶(1箱)` : ''}。
|
||
</Text>
|
||
</View>
|
||
) : null}
|
||
|
||
{preview ? (
|
||
<>
|
||
<View className="order-card">
|
||
<Text className="order-card-title">商品信息</Text>
|
||
<View className="order-product-row">
|
||
<View className="order-product-thumb">
|
||
{productImage ? (
|
||
<Image
|
||
className="order-product-thumb-img"
|
||
src={productImage}
|
||
mode="aspectFill"
|
||
/>
|
||
) : null}
|
||
</View>
|
||
<View style={{ flex: 1 }}>
|
||
<Text className="order-product-name">{preview.product.name}</Text>
|
||
<Text className="order-product-price">¥{Number(preview.product.price).toFixed(2)}</Text>
|
||
</View>
|
||
</View>
|
||
<View className="order-qty-row">
|
||
<Text>购买数量</Text>
|
||
<View className="order-qty-controls">
|
||
<View
|
||
className="order-qty-btn"
|
||
onClick={() => updateQuantity(quantity - 1)}
|
||
>
|
||
<Text>−</Text>
|
||
</View>
|
||
<Text className="order-qty-value">{quantity}</Text>
|
||
<View
|
||
className="order-qty-btn"
|
||
onClick={() => updateQuantity(quantity + 1)}
|
||
>
|
||
<Text>+</Text>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
{!quantityOk ? (
|
||
<Text className="order-qty-hint">
|
||
{isCross
|
||
? `跨城配送至少购买 ${minQty} 瓶(1箱),请调整数量`
|
||
: `同城配送至少购买 ${minQty} 瓶,请调整数量`}
|
||
</Text>
|
||
) : null}
|
||
</View>
|
||
|
||
<View className="order-card">
|
||
<Text className="order-card-title">费用明细</Text>
|
||
<View className="order-row">
|
||
<Text className="order-row-label">商品金额</Text>
|
||
<Text className="order-row-value">¥{preview.productAmount.toFixed(2)}</Text>
|
||
</View>
|
||
<View className="order-row">
|
||
<Text className="order-row-label">好客权益</Text>
|
||
<Text className="order-row-value--price">¥{preview.benefitAmount.toFixed(2)}</Text>
|
||
</View>
|
||
<View className="order-row">
|
||
<Text className="order-row-label">运费</Text>
|
||
<Text className="order-row-value">{isCross ? '到付' : '免运费'}</Text>
|
||
</View>
|
||
</View>
|
||
</>
|
||
) : null}
|
||
|
||
{previewLoading && !preview && productId ? (
|
||
<View className="u-empty">加载订单信息…</View>
|
||
) : null}
|
||
{!previewLoading && !preview && productId ? (
|
||
<View className="u-empty">无法加载商品信息</View>
|
||
) : null}
|
||
{displayMsg ? (
|
||
<Text className="order-warn-text" style={{ display: 'block', marginTop: 8, textAlign: 'center' }}>
|
||
{displayMsg}
|
||
</Text>
|
||
) : null}
|
||
</View>
|
||
|
||
<View className="order-confirm-bar">
|
||
<View className="order-confirm-total">
|
||
<Text className="order-confirm-total-label">应付合计</Text>
|
||
<Text className="order-confirm-total-value">
|
||
¥{preview ? preview.payAmount.toFixed(2) : '—'}
|
||
</Text>
|
||
</View>
|
||
<View
|
||
className={`order-confirm-submit${canSubmit ? '' : ' order-confirm-submit--disabled'}`}
|
||
onClick={() => {
|
||
if (!canSubmit) return;
|
||
void submit();
|
||
}}
|
||
>
|
||
<Text>{submitLabel}</Text>
|
||
</View>
|
||
</View>
|
||
</PageShell>
|
||
);
|
||
}
|