89fd333702
Add client-logging SDK, expanded event taxonomy, API observability, admin domain events UI, and move SENTRY_DSN to HQ system settings with @sentry/node bootstrap after config preload. Co-authored-by: Cursor <cursoragent@cursor.com>
169 lines
6.3 KiB
TypeScript
169 lines
6.3 KiB
TypeScript
import type { ProductDetailContentDto } from '@dukang/shared-types';
|
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
|
import { useEffect, useState } from 'react';
|
|
import AppImage from '@dukang/shared-ui/AppImage';
|
|
import ProductCarousel from '../components/ProductCarousel';
|
|
import AppToast from '../components/AppToast';
|
|
import { request } from '../lib/api';
|
|
import { track } from '../lib/analytics';
|
|
import { handleShareButtonClick } from '../lib/wechat-share';
|
|
import { getProductCarouselImages, getProductDetailImages } from '../lib/product-images';
|
|
import type { ProductImageSource } from '../lib/product-images';
|
|
|
|
type Product = ProductImageSource & {
|
|
name: string;
|
|
subtitle?: string;
|
|
price: number;
|
|
benefitAmount?: number;
|
|
detailContent?: ProductDetailContentDto | null;
|
|
};
|
|
|
|
export default function ProductDetailPage() {
|
|
const { id } = useParams();
|
|
const navigate = useNavigate();
|
|
const [product, setProduct] = useState<Product | null>(null);
|
|
const [headerSolid, setHeaderSolid] = useState(false);
|
|
const [toast, setToast] = useState('');
|
|
|
|
useEffect(() => {
|
|
if (id) request<Product>('USER_H5', `/catalog/products/${id}`).then(setProduct);
|
|
}, [id]);
|
|
|
|
useEffect(() => {
|
|
if (id) {
|
|
track('product_detail_view', { refType: 'PRODUCT', refId: id, productId: id });
|
|
}
|
|
}, [id]);
|
|
|
|
useEffect(() => {
|
|
function onScroll() {
|
|
setHeaderSolid(window.scrollY > 100);
|
|
}
|
|
window.addEventListener('scroll', onScroll, { passive: true });
|
|
return () => window.removeEventListener('scroll', onScroll);
|
|
}, []);
|
|
|
|
if (!product) return <div className="empty">加载中...</div>;
|
|
|
|
const benefit = Number(product.benefitAmount ?? product.price);
|
|
const carouselImages = getProductCarouselImages(product);
|
|
const detailImages = getProductDetailImages(product);
|
|
const detail = product.detailContent ?? {};
|
|
const features = detail.features ?? [];
|
|
|
|
return (
|
|
<div className="product-detail-page">
|
|
<header className={`product-detail-header${headerSolid ? ' solid' : ''}`}>
|
|
<button
|
|
type="button"
|
|
className="product-detail-header-btn"
|
|
aria-label="返回"
|
|
onClick={() => navigate('/')}
|
|
>
|
|
<span className="material-symbols-outlined">arrow_back</span>
|
|
</button>
|
|
<h1 className={`app-page-title product-detail-header-title${headerSolid ? ' visible' : ''}`}>
|
|
{product.name}
|
|
</h1>
|
|
<button
|
|
type="button"
|
|
className="product-detail-header-btn"
|
|
aria-label="分享"
|
|
onClick={() => handleShareButtonClick(setToast)}
|
|
>
|
|
<span className="material-symbols-outlined">share</span>
|
|
</button>
|
|
</header>
|
|
|
|
<AppToast message={toast} />
|
|
|
|
<main className="product-detail-main">
|
|
<section className="product-detail-hero">
|
|
<ProductCarousel images={carouselImages} alt={product.name} variant="detail" />
|
|
</section>
|
|
|
|
<section className="product-detail-info">
|
|
<div className="product-detail-price">
|
|
<span className="product-detail-price-symbol">¥</span>
|
|
<span className="product-detail-price-value">{product.price}</span>
|
|
</div>
|
|
<h2 className="product-detail-name">{product.name}</h2>
|
|
{product.subtitle && (
|
|
<p className="product-detail-subtitle">{product.subtitle}</p>
|
|
)}
|
|
|
|
<div className="product-detail-promo">
|
|
<div className="product-detail-promo-glow" aria-hidden />
|
|
<div className="product-detail-promo-head">
|
|
<div className="product-detail-promo-icon">
|
|
<span className="material-symbols-outlined fill-icon">confirmation_number</span>
|
|
</div>
|
|
<h3 className="product-detail-promo-title">
|
|
买杜康美酒 · 享全城好客礼遇
|
|
<span className="product-detail-promo-amount">¥{benefit}</span>
|
|
</h3>
|
|
</div>
|
|
<p className="product-detail-promo-desc">
|
|
购酒即享本城专属好客权益,为您安排一场地道饭局。权益金可直接用于本地签约门店消费,到店享用,醇香美酒配佳肴。
|
|
</p>
|
|
<span className="material-symbols-outlined product-detail-promo-deco" aria-hidden>
|
|
restaurant
|
|
</span>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="product-detail-content">
|
|
<div className="product-detail-section-head">
|
|
<span className="product-detail-section-bar" />
|
|
<h3>商品详情</h3>
|
|
</div>
|
|
|
|
{detailImages.map((src, index) => (
|
|
<AppImage key={`${src}-${index}`} src={src} alt="" wrapperClassName="product-detail-banner" />
|
|
))}
|
|
|
|
{(detail.storyTitle || detail.storyText || features.length > 0) && (
|
|
<div className="product-detail-copy">
|
|
{(detail.storyTitle || detail.storyText) && (
|
|
<div className="product-detail-story">
|
|
{detail.storyTitle && <h4>{detail.storyTitle}</h4>}
|
|
{detail.storyText && <p>{detail.storyText}</p>}
|
|
</div>
|
|
)}
|
|
|
|
{features.length > 0 && (
|
|
<div className="product-detail-features">
|
|
{features.map((f) => (
|
|
<div key={`${f.title}-${f.icon}`} className="product-detail-feature">
|
|
<span className="material-symbols-outlined">{f.icon}</span>
|
|
<div className="product-detail-feature-title">{f.title}</div>
|
|
<div className="product-detail-feature-desc">{f.desc}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</main>
|
|
|
|
<nav className="product-detail-bar">
|
|
<Link to="/" className="product-detail-bar-home">
|
|
<span className="material-symbols-outlined">home</span>
|
|
<span>首页</span>
|
|
</Link>
|
|
<button
|
|
type="button"
|
|
className="product-detail-buy-btn"
|
|
onClick={() => {
|
|
track('product_click', { refType: 'PRODUCT', refId: id, productId: id });
|
|
navigate(`/order/confirm?productId=${id}&qty=2`);
|
|
}}
|
|
>
|
|
立即购买
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
);
|
|
}
|