Files
dukang/apps/mini-user/src/pages/product-detail/index.tsx
T

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