Files
dukang/apps/h5-user/src/pages/StoreListPage.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

289 lines
9.5 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 { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage';
import { request } from '../lib/api';
import { track } from '../lib/analytics';
import TabMainHeader from '../components/TabMainHeader';
import RegionPicker from '../components/RegionPicker';
import {
DEFAULT_REGION,
FALLBACK_CITY_REGION,
formatRegionCity,
REGION_ALL,
toCityLevelRegion,
type RegionSelection,
} from '../lib/region-data';
import { FALLBACK_CITY_CODE, resolveUserCity } from '../lib/wechat-location';
type OpenCity = {
code: string;
name: string;
province: string;
};
type StoreItem = {
id: string;
name: string;
province?: string;
cityName?: string;
district: string;
address: string;
coverUrl?: string | null;
status: string;
openTime?: string | null;
closeTime?: string | null;
openTime2?: string | null;
closeTime2?: string | null;
avgPrice?: number | null;
category?: { name: string } | null;
};
function resolveCityCode(region: RegionSelection, cities: OpenCity[]): string | undefined {
if (region.province === REGION_ALL || region.city === REGION_ALL) return undefined;
return cities.find((c) => c.province === region.province && c.name === region.city)?.code;
}
const CATEGORY_TABS = ['全部', '火锅', '地方菜', '高端餐饮', '烧烤烤肉', '西餐'] as const;
const MOCK_DISTANCES = ['800m', '1.2km', '3.5km', '1.5km', '2.0km'];
function storeCover(store: StoreItem, index: number) {
if (store.coverUrl) return String(store.coverUrl);
const imgs = ['/images/1.png', '/images/2.png', '/images/3.png'];
return imgs[index % imgs.length];
}
function formatHours(store: StoreItem) {
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('')}` : '营业时间: 10:00-22:00';
}
export default function StoreListPage() {
const navigate = useNavigate();
const [cities, setCities] = useState<OpenCity[]>([]);
const [stores, setStores] = useState<StoreItem[]>([]);
const [loading, setLoading] = useState(false);
const [categoryTab, setCategoryTab] = useState<string>('全部');
const [keyword, setKeyword] = useState('');
const [region, setRegion] = useState<RegionSelection>(() => toCityLevelRegion(DEFAULT_REGION));
const [regionPickerOpen, setRegionPickerOpen] = useState(false);
const [filterMode, setFilterMode] = useState<'auto' | 'manual'>('auto');
const [usedFallback, setUsedFallback] = useState(false);
const [geoReady, setGeoReady] = useState(false);
const cityCode = useMemo(() => resolveCityCode(region, cities), [region, cities]);
useEffect(() => {
track('store_list_view', { pagePath: '/stores' });
}, []);
useEffect(() => {
request<OpenCity[]>('USER_H5', '/catalog/cities').then(setCities);
}, []);
useEffect(() => {
if (filterMode !== 'auto' || geoReady) return;
resolveUserCity().then((resolved) => {
if (resolved) {
setRegion(toCityLevelRegion(resolved.region));
}
setGeoReady(true);
});
}, [filterMode, geoReady]);
const fetchStores = useCallback(
async (targetRegion: RegionSelection, allowFallback: boolean) => {
const code = resolveCityCode(targetRegion, cities);
const qs = code ? `?cityCode=${encodeURIComponent(code)}` : '';
const data = await request<StoreItem[]>('USER_H5', `/stores${qs}`).catch(() => [] as StoreItem[]);
if (
allowFallback &&
filterMode === 'auto' &&
data.length === 0 &&
!usedFallback &&
code &&
code !== FALLBACK_CITY_CODE
) {
setUsedFallback(true);
setRegion(toCityLevelRegion(FALLBACK_CITY_REGION));
return null;
}
return data;
},
[cities, filterMode, usedFallback],
);
useEffect(() => {
if (filterMode === 'auto' && !geoReady) return;
let cancelled = false;
setLoading(true);
fetchStores(region, true)
.then((data) => {
if (!cancelled && data) setStores(data);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [region, cities, filterMode, geoReady, fetchStores, usedFallback]);
const filtered = useMemo(() => {
let list = stores;
if (categoryTab !== '全部') {
list = list.filter((s) => s.category?.name === categoryTab);
}
if (!cityCode) {
list = list.filter((s) => {
const province = s.province ?? '河南省';
const city = s.cityName ?? '郑州市';
if (region.province !== REGION_ALL && province !== region.province) return false;
if (region.city !== REGION_ALL && city !== region.city) return false;
return true;
});
}
const q = keyword.trim().toLowerCase();
if (q) {
list = list.filter(
(s) =>
s.name.toLowerCase().includes(q) ||
s.address.toLowerCase().includes(q) ||
s.district.toLowerCase().includes(q),
);
}
return list;
}, [stores, categoryTab, keyword, region, cityCode]);
const regionLabel = formatRegionCity(region.province, region.city);
const emptyMessage =
filterMode === 'manual' && filtered.length === 0
? '未找到匹配门店'
: '暂无门店';
return (
<div className="page store-page">
<header className="store-header">
<TabMainHeader title="门店" />
<div className="store-toolbar">
<button
type="button"
className="store-location"
onClick={() => setRegionPickerOpen(true)}
>
<span className="material-symbols-outlined">location_on</span>
<span>{regionLabel}</span>
<span className="material-symbols-outlined store-location-arrow">expand_more</span>
</button>
<div className="store-search">
<span className="material-symbols-outlined store-search-icon">search</span>
<input
type="search"
placeholder="搜索门店名称或地址"
value={keyword}
onChange={(e) => {
setFilterMode('manual');
setKeyword(e.target.value);
}}
/>
</div>
</div>
<div className="store-category-bar">
<div className="store-category-tabs">
{CATEGORY_TABS.map((tab) => (
<button
key={tab}
type="button"
className={`store-category-tab${categoryTab === tab ? ' active' : ''}`}
onClick={() => {
setFilterMode('manual');
setCategoryTab(tab);
}}
>
{tab}
</button>
))}
</div>
</div>
</header>
<main className="store-list">
{loading && <div className="store-empty">加载门店中...</div>}
{!loading && filtered.map((s, index) => (
<article
key={s.id}
className="store-card"
role="button"
tabIndex={0}
onClick={() => navigate(`/stores/${s.id}`)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
navigate(`/stores/${s.id}`);
}
}}
>
<div className="store-card-cover">
<AppImage src={storeCover(s, index)} alt={s.name} wrapperClassName="app-image--fill" />
</div>
<div className="store-card-body">
<div className="store-card-top">
<div className="store-card-title-row">
<h3 className="store-card-name">{s.name}</h3>
<span className="store-card-distance">
{MOCK_DISTANCES[index % MOCK_DISTANCES.length]}
</span>
</div>
<div className="store-card-meta">
<span className="status-open">营业中</span>
<span className="store-card-hours">{formatHours(s)}</span>
{s.avgPrice != null && Number(s.avgPrice) > 0 ? (
<span className="store-card-hours">人均¥{Number(s.avgPrice).toFixed(0)}</span>
) : null}
</div>
</div>
<div className="store-card-bottom">
<p className="store-card-address">
{s.district}
{s.address}
</p>
<Link
to="/redeem"
className="store-redeem-btn"
onClick={(e) => e.stopPropagation()}
>
去核销
</Link>
</div>
</div>
</article>
))}
{!loading && filtered.length === 0 && (
<div className="store-empty">{emptyMessage}</div>
)}
{!loading && filtered.length > 0 && (
<p className="store-list-end">没有更多门店了</p>
)}
</main>
<RegionPicker
open={regionPickerOpen}
value={region}
levels={2}
onClose={() => setRegionPickerOpen(false)}
onConfirm={(next) => {
setFilterMode('manual');
setRegion(toCityLevelRegion(next));
setRegionPickerOpen(false);
}}
/>
</div>
);
}