92cf51ba3d
CI / verify (pull_request) Has been cancelled
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>
239 lines
9.0 KiB
TypeScript
239 lines
9.0 KiB
TypeScript
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>
|
||
);
|
||
}
|