4d434c9c67
Co-authored-by: Cursor <cursoragent@cursor.com>
405 lines
14 KiB
TypeScript
405 lines
14 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { View, Text, Image } from '@tarojs/components';
|
|
import '../../styles/product-detail.css';
|
|
import Taro, {
|
|
useDidShow,
|
|
usePageScroll,
|
|
useRouter,
|
|
useShareAppMessage,
|
|
useShareTimeline,
|
|
} from '@tarojs/taro';
|
|
import type { ProductDetailContentDto, ProductSkuDto, ProductSpecAttrDto } from '@dukang/shared-types';
|
|
import PageShell from '../../components/PageShell';
|
|
import PageNavBar from '../../components/PageNavBar';
|
|
import ProductCarousel from '../../components/ProductCarousel';
|
|
import WechatShareReady from '../../components/WechatShareReady';
|
|
import BenefitFigure from '../../components/BenefitFigure';
|
|
import { goLogin } from '../../lib/auth-nav';
|
|
import { ensurePayReady } from '../../lib/pay-ready';
|
|
import { isLoggedIn, request, toast } from '../../lib/api';
|
|
import {
|
|
getProductCarouselImages,
|
|
getProductDetailImages,
|
|
getProductMainImage,
|
|
type ProductImageSource,
|
|
} from '../../lib/product-images';
|
|
import {
|
|
canBuyOnline,
|
|
canPickupOnSite,
|
|
normalizeFulfillmentFlags,
|
|
} from '../../lib/product-fulfillment';
|
|
import {
|
|
buildSceneSharePayload,
|
|
toWeappShareMessage,
|
|
toWeappShareTimeline,
|
|
} from '../../lib/wechat-share';
|
|
import iconHome from '../../assets/tabbar/home.png';
|
|
import { usePageView } from '../../lib/usePageView';
|
|
|
|
type Product = ProductImageSource & {
|
|
id: string;
|
|
name: string;
|
|
subtitle?: string | null;
|
|
price: number;
|
|
benefitAmount?: number;
|
|
benefitDisplay?: number;
|
|
detailContent?: ProductDetailContentDto | null;
|
|
allowOnSitePickup?: boolean;
|
|
allowOnlinePurchase?: boolean;
|
|
allowCrossCityDelivery?: boolean;
|
|
specEnabled?: boolean;
|
|
specAttrs?: ProductSpecAttrDto[];
|
|
skus?: ProductSkuDto[];
|
|
defaultSkuId?: string;
|
|
saleUnit?: 'BOTTLE' | 'BOX';
|
|
};
|
|
|
|
function findSku(
|
|
skus: ProductSkuDto[],
|
|
selected: Record<string, string>,
|
|
attrs: ProductSpecAttrDto[],
|
|
): ProductSkuDto | undefined {
|
|
const valueIds = attrs.map((a) => selected[a.id]).filter(Boolean);
|
|
if (valueIds.length !== attrs.length) return undefined;
|
|
const key = [...valueIds].sort().join('_');
|
|
return skus.find((s) => [...s.specValueIds].sort().join('_') === key);
|
|
}
|
|
|
|
function isValueAvailable(
|
|
skus: ProductSkuDto[],
|
|
attrs: ProductSpecAttrDto[],
|
|
selected: Record<string, string>,
|
|
attrId: string,
|
|
valueId: string,
|
|
): boolean {
|
|
const trial = { ...selected, [attrId]: valueId };
|
|
const partialIds = attrs.map((a) => trial[a.id]).filter(Boolean);
|
|
return skus.some((s) => {
|
|
if (s.status !== 'ON_SALE') return false;
|
|
return partialIds.every((id) => s.specValueIds.includes(id));
|
|
});
|
|
}
|
|
|
|
export default function ProductDetailPage() {
|
|
const router = useRouter();
|
|
const productId = router.params.id ?? '';
|
|
usePageView(
|
|
'product_detail_view',
|
|
productId ? { refType: 'PRODUCT', refId: productId, productId } : undefined,
|
|
);
|
|
const [product, setProduct] = useState<Product | null>(null);
|
|
const [headerSolid, setHeaderSolid] = useState(false);
|
|
const [selected, setSelected] = useState<Record<string, string>>({});
|
|
|
|
usePageScroll(({ scrollTop }) => {
|
|
setHeaderSolid(scrollTop > 100);
|
|
});
|
|
|
|
const loadProduct = useCallback(() => {
|
|
if (!productId) return;
|
|
request<Product | null>(`/catalog/products/${productId}`)
|
|
.then((p) => {
|
|
if (!p) {
|
|
setProduct(null);
|
|
toast('商品不存在或暂未开放');
|
|
return;
|
|
}
|
|
const normalized = normalizeFulfillmentFlags(p);
|
|
setProduct(normalized);
|
|
const attrs = p.specAttrs ?? [];
|
|
const skus = p.skus ?? [];
|
|
const def =
|
|
skus.find((s) => s.id === p.defaultSkuId) ||
|
|
skus.find((s) => s.isDefault && s.status === 'ON_SALE') ||
|
|
skus.find((s) => s.status === 'ON_SALE') ||
|
|
skus[0];
|
|
if (def && attrs.length) {
|
|
const next: Record<string, string> = {};
|
|
for (const attr of attrs) {
|
|
const hit = attr.values.find((v) => def.specValueIds.includes(v.id));
|
|
if (hit) next[attr.id] = hit.id;
|
|
}
|
|
setSelected(next);
|
|
} else {
|
|
setSelected({});
|
|
}
|
|
})
|
|
.catch((e) => {
|
|
setProduct(null);
|
|
toast(e instanceof Error ? e.message : '加载失败');
|
|
});
|
|
}, [productId]);
|
|
|
|
useEffect(() => {
|
|
loadProduct();
|
|
}, [loadProduct]);
|
|
|
|
useDidShow(() => {
|
|
loadProduct();
|
|
});
|
|
|
|
const attrs = product?.specAttrs ?? [];
|
|
const skus = product?.skus ?? [];
|
|
const specEnabled = !!(product?.specEnabled && attrs.length > 0);
|
|
const activeSku = useMemo(() => {
|
|
if (!product) return undefined;
|
|
if (!specEnabled) {
|
|
return (
|
|
skus.find((s) => s.id === product.defaultSkuId) ||
|
|
skus.find((s) => s.isDefault) ||
|
|
skus.find((s) => s.status === 'ON_SALE') ||
|
|
skus[0]
|
|
);
|
|
}
|
|
return findSku(skus, selected, attrs);
|
|
}, [product, specEnabled, skus, selected, attrs]);
|
|
|
|
const displayPrice = activeSku ? Number(activeSku.price) : Number(product?.price ?? 0);
|
|
const displayBenefit = activeSku
|
|
? Number(activeSku.benefitAmount)
|
|
: Number(product?.benefitDisplay ?? product?.benefitAmount ?? product?.price ?? 0);
|
|
const fulfillment = activeSku
|
|
? {
|
|
allowOnlinePurchase: activeSku.allowOnlinePurchase,
|
|
allowCrossCityDelivery: activeSku.allowCrossCityDelivery,
|
|
allowOnSitePickup: activeSku.allowOnSitePickup,
|
|
}
|
|
: product;
|
|
const carouselImages = (() => {
|
|
const base = getProductCarouselImages(product);
|
|
const skuImg = activeSku?.imageUrl?.trim();
|
|
if (!skuImg) return base;
|
|
return [skuImg, ...base.filter((url) => url !== skuImg)];
|
|
})();
|
|
|
|
const sharePayload = useMemo(
|
|
() =>
|
|
buildSceneSharePayload('productDetail', {
|
|
path: `/pages/product-detail/index?id=${productId}`,
|
|
dynamicTitle: product?.name,
|
|
dynamicDesc: product?.subtitle,
|
|
dynamicImageUrl: (activeSku?.imageUrl?.trim() || (product ? getProductMainImage(product) : undefined)),
|
|
}),
|
|
[product, productId, activeSku],
|
|
);
|
|
|
|
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
|
useShareTimeline(() =>
|
|
toWeappShareTimeline(sharePayload, productId ? `id=${productId}` : ''),
|
|
);
|
|
|
|
function goBack() {
|
|
const pages = Taro.getCurrentPages();
|
|
if (pages.length > 1) {
|
|
Taro.navigateBack();
|
|
return;
|
|
}
|
|
Taro.switchTab({ url: '/pages/home/index' });
|
|
}
|
|
|
|
function goHome() {
|
|
Taro.switchTab({ url: '/pages/home/index' });
|
|
}
|
|
|
|
function ensureSkuSelected(): string | null {
|
|
if (!specEnabled) return activeSku?.id ?? product?.defaultSkuId ?? null;
|
|
if (!activeSku || activeSku.status !== 'ON_SALE') {
|
|
toast('请选择完整规格');
|
|
return null;
|
|
}
|
|
return activeSku.id;
|
|
}
|
|
|
|
async function goBuy() {
|
|
if (!productId) return;
|
|
const skuId = ensureSkuSelected();
|
|
if (specEnabled && !skuId) return;
|
|
const qty = activeSku?.saleUnit === 'BOX' ? 1 : 2;
|
|
const qs = [`productId=${productId}`, `qty=${qty}`];
|
|
if (skuId) qs.push(`skuId=${skuId}`);
|
|
const returnPath = `/pages/order-confirm/index?${qs.join('&')}`;
|
|
if (!isLoggedIn()) {
|
|
goLogin(returnPath);
|
|
return;
|
|
}
|
|
const ready = await ensurePayReady(returnPath);
|
|
if (!ready) return;
|
|
Taro.navigateTo({ url: returnPath });
|
|
}
|
|
|
|
async function goOnSitePickup() {
|
|
if (!productId) return;
|
|
const skuId = ensureSkuSelected();
|
|
if (specEnabled && !skuId) return;
|
|
const qty = activeSku?.saleUnit === 'BOX' ? 1 : 1;
|
|
const qs = [`productId=${productId}`, `qty=${qty}`];
|
|
if (skuId) qs.push(`skuId=${skuId}`);
|
|
const returnPath = `/pages/order-confirm-pickup/index?${qs.join('&')}`;
|
|
if (!isLoggedIn()) {
|
|
goLogin(returnPath);
|
|
return;
|
|
}
|
|
const ready = await ensurePayReady(returnPath);
|
|
if (!ready) return;
|
|
Taro.navigateTo({ url: returnPath });
|
|
}
|
|
|
|
if (!product) {
|
|
return (
|
|
<PageShell variant="scroll" className="product-detail-page">
|
|
<PageNavBar title="商品详情" solid onBack={goBack} />
|
|
<View className="page-with-nav-bar u-empty">加载中…</View>
|
|
</PageShell>
|
|
);
|
|
}
|
|
|
|
const allowOnline = canBuyOnline(fulfillment ?? {});
|
|
const allowOnSite = canPickupOnSite(fulfillment ?? {});
|
|
const detailImages = getProductDetailImages(product);
|
|
const detail = product.detailContent ?? {};
|
|
const features = detail.features ?? [];
|
|
|
|
return (
|
|
<PageShell variant="scroll" className="product-detail-page" hasFixedFooter>
|
|
<WechatShareReady payload={sharePayload} />
|
|
<PageNavBar
|
|
title={product.name}
|
|
solid={headerSolid}
|
|
titleVisible={headerSolid}
|
|
onBack={goBack}
|
|
/>
|
|
|
|
<View className="product-detail-main">
|
|
<View className="product-detail-hero full-bleed">
|
|
<ProductCarousel images={carouselImages} alt={product.name} variant="detail" previewable />
|
|
</View>
|
|
|
|
<View className="product-detail-info">
|
|
<View className="product-detail-price">
|
|
<Text className="product-detail-price-symbol">¥</Text>
|
|
<Text className="product-detail-price-value">{displayPrice.toFixed(2)}</Text>
|
|
</View>
|
|
<Text className="product-detail-name">{product.name}</Text>
|
|
{product.subtitle ? (
|
|
<Text className="product-detail-subtitle">{product.subtitle}</Text>
|
|
) : null}
|
|
{activeSku?.specText ? (
|
|
<Text className="product-detail-subtitle">{activeSku.specText}</Text>
|
|
) : null}
|
|
|
|
{specEnabled ? (
|
|
<View className="product-detail-specs">
|
|
{attrs.map((attr) => (
|
|
<View key={attr.id} className="product-detail-spec-row">
|
|
<Text className="product-detail-spec-label">{attr.name}</Text>
|
|
<View className="product-detail-spec-chips">
|
|
{attr.values.map((val) => {
|
|
const active = selected[attr.id] === val.id;
|
|
const available = isValueAvailable(skus, attrs, selected, attr.id, val.id);
|
|
return (
|
|
<View
|
|
key={val.id}
|
|
className={`product-detail-spec-chip${active ? ' is-active' : ''}${
|
|
available ? '' : ' is-disabled'
|
|
}`}
|
|
onClick={() => {
|
|
if (!available) return;
|
|
setSelected((prev) => ({ ...prev, [attr.id]: val.id }));
|
|
}}
|
|
>
|
|
<Text className="product-detail-spec-chip-text">{val.name}</Text>
|
|
</View>
|
|
);
|
|
})}
|
|
</View>
|
|
</View>
|
|
))}
|
|
</View>
|
|
) : null}
|
|
|
|
<View className="product-detail-promo">
|
|
<View className="product-detail-promo-glow" />
|
|
<View className="product-detail-promo-head">
|
|
<View className="product-detail-promo-icon">
|
|
<Text className="product-detail-promo-icon-text">惠</Text>
|
|
</View>
|
|
<View className="product-detail-promo-title">
|
|
<Text>买杜康美酒 · 享全城好客礼遇</Text>
|
|
<BenefitFigure value={String(displayBenefit)} size="sm" className="product-detail-promo-amount" />
|
|
</View>
|
|
</View>
|
|
<Text className="product-detail-promo-desc">
|
|
购酒即享本城专属好客权益,为您安排一场地道饭局。权益金可直接用于本地签约门店消费,到店享用,醇香美酒配佳肴。
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
<View className="product-detail-content">
|
|
<View className="product-detail-section-head">
|
|
<View className="product-detail-section-bar" />
|
|
<Text className="product-detail-section-title">商品详情</Text>
|
|
</View>
|
|
|
|
{detailImages.map((src, index) => (
|
|
<Image
|
|
key={`${src}-${index}`}
|
|
className="product-detail-banner full-bleed"
|
|
src={src}
|
|
mode="widthFix"
|
|
/>
|
|
))}
|
|
|
|
{(detail.storyTitle || detail.storyText || features.length > 0) ? (
|
|
<View className="product-detail-copy">
|
|
{(detail.storyTitle || detail.storyText) ? (
|
|
<View className="product-detail-story">
|
|
{detail.storyTitle ? (
|
|
<Text className="product-detail-story-title">{detail.storyTitle}</Text>
|
|
) : null}
|
|
{detail.storyText ? (
|
|
<Text className="product-detail-story-text">{detail.storyText}</Text>
|
|
) : null}
|
|
</View>
|
|
) : null}
|
|
|
|
{features.length > 0 ? (
|
|
<View className="product-detail-features">
|
|
{features.map((f) => (
|
|
<View key={`${f.title}-${f.icon}`} className="product-detail-feature">
|
|
<Text className="product-detail-feature-icon">★</Text>
|
|
<Text className="product-detail-feature-title">{f.title}</Text>
|
|
<Text className="product-detail-feature-desc">{f.desc}</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
|
|
<View className="product-detail-bar">
|
|
<View className="product-detail-bar-home" onClick={goHome}>
|
|
<Image className="product-detail-bar-home-icon" src={iconHome} mode="aspectFit" />
|
|
<Text className="product-detail-bar-home-label">首页</Text>
|
|
</View>
|
|
{allowOnSite ? (
|
|
<View className="product-detail-pickup-btn" onClick={() => void goOnSitePickup()}>
|
|
<Text className="product-detail-pickup-btn-text">现场取货</Text>
|
|
</View>
|
|
) : null}
|
|
{allowOnline ? (
|
|
<View className="product-detail-buy-btn" onClick={() => void goBuy()}>
|
|
<Text className="product-detail-buy-btn-text">立即购买</Text>
|
|
</View>
|
|
) : null}
|
|
{!allowOnline && !allowOnSite ? (
|
|
<View className="product-detail-buy-btn product-detail-buy-btn--disabled">
|
|
<Text className="product-detail-buy-btn-text">暂不可购</Text>
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
</PageShell>
|
|
);
|
|
}
|