Files
dukang/apps/h5-user/src/pages/StoreDetailPage.tsx
T
jacy 92cf51ba3d
CI / verify (pull_request) Has been cancelled
feat(store): dual business hours, avg price, and status lock
Support 1-2 hour segments and optional avgPrice; show bank settlement on HQ store detail; enforce pickup min 2 bottles; Chinese order statuses; block shop reopen after permanent close.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:16:22 +08:00

239 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
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 { STITCH_STORE_MAP, getStoreGalleryImages } from '../lib/store-images';
type StoreMedia = { url: string; mediaType?: string; sortOrder?: number };
type StoreDetail = {
id: string;
name: string;
phone: string;
province: string;
cityName?: string;
city?: string;
district: string;
address: string;
intro?: string | null;
coverUrl?: string | null;
status: string;
openTime?: string | null;
closeTime?: string | null;
openTime2?: string | null;
closeTime2?: string | null;
avgPrice?: number | null;
category?: { name: string } | null;
media?: StoreMedia[];
};
const SERVICES = [
{ icon: 'wifi', label: 'WiFi' },
{ icon: 'local_parking', label: '免费停车' },
{ icon: 'meeting_room', label: '独立包间' },
{ icon: 'table_restaurant', label: '宴会大厅' },
] as const;
const MOCK_DISTANCES = ['800m', '1.2km', '2.4km', '3.5km'];
const DEFAULT_INTRO =
'作为本地优质餐饮合作伙伴,门店融合地域饮食文化与高端社交场景,设有杜康文化体验区,让宾客在用餐之余领略中华酒祖的千年传承。主打精品地方菜与创意融合菜,氛围庄重而不失亲和力,是商务宴请、亲友小聚以及文化交流的理想场所。';
function formatHours(store: StoreDetail) {
const parts: string[] = [];
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`);
return parts.length ? parts.join('') : '09:30-22:00';
}
function fullAddress(store: StoreDetail) {
const city = store.cityName || store.city || '';
return `${store.province}${city}${store.district}${store.address}`;
}
export default function StoreDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const [store, setStore] = useState<StoreDetail | null>(null);
const [benefitBalance, setBenefitBalance] = useState(0);
const [headerSolid, setHeaderSolid] = useState(false);
const [toast, setToast] = useState('');
useEffect(() => {
if (id) {
request<StoreDetail>('USER_H5', `/stores/${id}`).then(setStore);
track('store_detail_view', { refType: 'STORE', refId: id, storeId: id });
}
}, [id]);
useEffect(() => {
request<Array<{ balance: number; status: string }>>('USER_H5', '/benefit/coupons')
.then((list) => {
const balance = list.reduce((sum, c) => {
if (c.status === 'ACTIVE') return sum + Number(c.balance || 0);
return sum;
}, 0);
setBenefitBalance(balance);
})
.catch(() => {});
}, []);
useEffect(() => {
function onScroll() {
setHeaderSolid(window.scrollY > 80);
}
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
const galleryImages = useMemo(() => {
if (!store) return [];
return getStoreGalleryImages(store.coverUrl, store.media);
}, [store]);
const distance = MOCK_DISTANCES[Number(id || 0) % MOCK_DISTANCES.length];
const isOpen = store?.status === 'OPEN';
if (!store) {
return <div className="empty store-detail-page">加载中...</div>;
}
function callStore() {
if (store?.phone) window.location.href = `tel:${store.phone}`;
}
function openMap() {
window.alert('preV1:导航功能即将开放');
}
return (
<div className="store-detail-page">
<header className={`store-detail-header${headerSolid ? ' solid' : ''}`}>
<button
type="button"
className="store-detail-header-btn"
aria-label="返回"
onClick={() => navigate('/stores')}
>
<span className="material-symbols-outlined">arrow_back</span>
</button>
<h1 className={`app-page-title store-detail-header-title${headerSolid ? ' visible' : ''}`}>门店详情</h1>
<button type="button" className="store-detail-header-btn" aria-label="分享" onClick={() => handleShareButtonClick(setToast)}>
<span className="material-symbols-outlined">share</span>
</button>
</header>
<AppToast message={toast} />
<main className="store-detail-main">
<section className="store-detail-hero">
<ProductCarousel images={galleryImages} alt={store.name} variant="store" />
</section>
<section className="store-detail-info-wrap">
<div className="store-detail-info-card">
<div className="store-detail-info-head">
<div>
<h2 className="store-detail-name">{store.name}</h2>
<p className="store-detail-hours">营业时间:{formatHours(store)}</p>
{store.avgPrice != null && Number(store.avgPrice) > 0 ? (
<p className="store-detail-hours">人均约 ¥{Number(store.avgPrice).toFixed(0)}</p>
) : null}
{store.category?.name && (
<span className="store-detail-category">{store.category.name}</span>
)}
</div>
<div className={`store-detail-status${isOpen ? '' : ' closed'}`}>
{isOpen && <span className="store-detail-status-dot" />}
<span>{isOpen ? '营业中' : '休息中'}</span>
</div>
</div>
</div>
</section>
<section className="store-detail-tip">
<span className="material-symbols-outlined">info</span>
<p>温馨提示:为保证服务品质,如用餐规模超过2桌,请提前电话联系门店确认可用性。</p>
</section>
<section className="store-detail-location">
<div className="store-detail-location-card">
<div className="store-detail-map">
<AppImage src={STITCH_STORE_MAP} alt="" wrapperClassName="app-image--fill" />
<div className="store-detail-map-gradient" aria-hidden />
</div>
<div className="store-detail-location-body">
<div className="store-detail-location-text">
<p>{fullAddress(store)}</p>
<p className="store-detail-distance">
<span className="material-symbols-outlined">near_me</span>
距离您 {distance}
</p>
</div>
<div className="store-detail-location-actions">
<button type="button" className="store-detail-action-btn" aria-label="电话" onClick={callStore}>
<span className="material-symbols-outlined">call</span>
</button>
<button
type="button"
className="store-detail-action-btn store-detail-action-btn--primary"
aria-label="导航"
onClick={openMap}
>
<span className="material-symbols-outlined">directions</span>
</button>
</div>
</div>
</div>
</section>
<section className="store-detail-services">
<h3>设施服务</h3>
<div className="store-detail-service-grid">
{SERVICES.map((s) => (
<div key={s.label} className="store-detail-service-item">
<span className="material-symbols-outlined">{s.icon}</span>
<span>{s.label}</span>
</div>
))}
</div>
</section>
<section className="store-detail-intro">
<h3>门店介绍</h3>
<div className="store-detail-intro-card">
<p>{store.intro || DEFAULT_INTRO}</p>
<div className="store-detail-intro-foot">
<div className="store-detail-intro-avatars" aria-hidden>
<span />
<span />
<span />
</div>
<span className="store-detail-intro-stat">已有 1.2w 人到店体验</span>
</div>
</div>
</section>
</main>
<footer className="store-detail-footer">
<div className="store-detail-footer-inner">
<div className="store-detail-balance">
<span className="store-detail-balance-label">可用额度</span>
<span className="store-detail-balance-value">
¥{benefitBalance.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</div>
<button type="button" className="store-detail-redeem-btn" onClick={() => navigate('/redeem')}>
<span className="material-symbols-outlined">qr_code_scanner</span>
去核销
</button>
</div>
</footer>
</div>
);
}