This commit is contained in:
2026-06-30 10:33:56 +08:00
commit 6e047dc0a5
607 changed files with 65966 additions and 0 deletions
+221
View File
@@ -0,0 +1,221 @@
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 { request } from '../lib/api';
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;
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) {
if (store.openTime && store.closeTime) return `${store.openTime} - ${store.closeTime}`;
return '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);
useEffect(() => {
if (id) request<StoreDetail>('USER_H5', `/stores/${id}`).then(setStore);
}, [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={() => {}}>
<span className="material-symbols-outlined">share</span>
</button>
</header>
<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.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>
);
}