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>
248 lines
8.8 KiB
TypeScript
248 lines
8.8 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import { View, Text, Image, Input } from '@tarojs/components';
|
||
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
|
||
import PageShell from '../../components/PageShell';
|
||
import TabMainHeader from '../../components/TabMainHeader';
|
||
import RegionPicker from '../../components/RegionPicker';
|
||
import CategoryPicker, {
|
||
EMPTY_CATEGORY,
|
||
formatCategoryLabel,
|
||
type CategorySelection,
|
||
type StoreCategoryNode,
|
||
} from '../../components/CategoryPicker';
|
||
import {
|
||
DEFAULT_REGION,
|
||
formatRegionLabel,
|
||
matchesRegionFilter,
|
||
type RegionSelection,
|
||
} from '../../lib/region-data';
|
||
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
||
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location';
|
||
import { FALLBACK_CITY_CODE } from '../../lib/product-images';
|
||
import { request, toast } from '../../lib/api';
|
||
|
||
type Store = {
|
||
id: string;
|
||
name: string;
|
||
address?: string;
|
||
province?: string;
|
||
cityName?: string;
|
||
district?: string;
|
||
coverUrl?: string | null;
|
||
openTime?: string | null;
|
||
closeTime?: string | null;
|
||
openTime2?: string | null;
|
||
closeTime2?: string | null;
|
||
avgPrice?: number | null;
|
||
status?: string;
|
||
categoryId?: string | null;
|
||
category?: { id?: string; name?: string; parentId?: string | null } | null;
|
||
};
|
||
|
||
const MOCK_DISTANCES = ['800m', '1.2km', '3.5km', '1.5km', '2.0km'];
|
||
|
||
export default function StoresPage() {
|
||
const [stores, setStores] = useState<Store[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [keywordInput, setKeywordInput] = useState('');
|
||
const [keyword, setKeyword] = useState('');
|
||
const [region, setRegion] = useState<RegionSelection>(DEFAULT_REGION);
|
||
const [regionOpen, setRegionOpen] = useState(false);
|
||
const [category, setCategory] = useState<CategorySelection>(EMPTY_CATEGORY);
|
||
const [categoryOpen, setCategoryOpen] = useState(false);
|
||
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
|
||
const [cityCode, setCityCode] = useState<string>(FALLBACK_CITY_CODE);
|
||
const regionLabel = formatRegionLabel(region);
|
||
const categoryLabel = formatCategoryLabel(category);
|
||
|
||
const childIdsByParent = useMemo(() => {
|
||
const map = new Map<string, string[]>();
|
||
for (const root of categoryTree) {
|
||
map.set(
|
||
root.id,
|
||
(root.children ?? []).map((c) => c.id),
|
||
);
|
||
}
|
||
return map;
|
||
}, [categoryTree]);
|
||
|
||
useDidShow(() => {
|
||
syncTabBarSelected(1);
|
||
void resolveUserCity().then((resolved) => {
|
||
setRegion(resolved.region);
|
||
setCityCode(getCityCodeForCatalog(resolved));
|
||
});
|
||
});
|
||
|
||
useEffect(() => {
|
||
void request<StoreCategoryNode[]>('/store-categories')
|
||
.then((tree) => setCategoryTree(Array.isArray(tree) ? tree : []))
|
||
.catch(() => setCategoryTree([]));
|
||
}, []);
|
||
|
||
const loadStores = useCallback(() => {
|
||
setLoading(true);
|
||
const path = cityCode ? `/stores?cityCode=${encodeURIComponent(cityCode)}` : '/stores';
|
||
return request<Store[]>(path)
|
||
.then((list) => setStores(Array.isArray(list) ? list : []))
|
||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'))
|
||
.finally(() => setLoading(false));
|
||
}, [cityCode]);
|
||
|
||
useEffect(() => {
|
||
void loadStores();
|
||
}, [loadStores]);
|
||
|
||
usePullDownRefresh(() => {
|
||
void (async () => {
|
||
try {
|
||
const resolved = await resolveUserCity();
|
||
setRegion(resolved.region);
|
||
const nextCode = getCityCodeForCatalog(resolved);
|
||
setCityCode(nextCode);
|
||
setLoading(true);
|
||
const path = nextCode ? `/stores?cityCode=${encodeURIComponent(nextCode)}` : '/stores';
|
||
const list = await request<Store[]>(path);
|
||
setStores(Array.isArray(list) ? list : []);
|
||
} catch (e) {
|
||
toast(e instanceof Error ? e.message : '加载失败');
|
||
} finally {
|
||
setLoading(false);
|
||
Taro.stopPullDownRefresh();
|
||
}
|
||
})();
|
||
});
|
||
|
||
function matchesCategory(store: Store): boolean {
|
||
if (!category.parentId) return true;
|
||
const storeCatId = String(store.categoryId || store.category?.id || '');
|
||
const storeParentId = String(store.category?.parentId || '');
|
||
if (category.childId) {
|
||
return storeCatId === category.childId;
|
||
}
|
||
if (storeParentId && storeParentId === category.parentId) return true;
|
||
const siblings = childIdsByParent.get(category.parentId) ?? [];
|
||
return siblings.includes(storeCatId);
|
||
}
|
||
|
||
const filtered = stores.filter((s) => {
|
||
if (!matchesRegionFilter(s, region)) return false;
|
||
if (!matchesCategory(s)) return false;
|
||
if (!keyword.trim()) return true;
|
||
const q = keyword.trim();
|
||
return s.name.includes(q) || (s.address ?? '').includes(q) || (s.district ?? '').includes(q);
|
||
});
|
||
|
||
function applySearch() {
|
||
setKeyword(keywordInput.trim());
|
||
}
|
||
|
||
function resetFilters() {
|
||
setKeywordInput('');
|
||
setKeyword('');
|
||
setCategory(EMPTY_CATEGORY);
|
||
setRegion(DEFAULT_REGION);
|
||
}
|
||
|
||
function formatHours(store: Store) {
|
||
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';
|
||
}
|
||
|
||
return (
|
||
<PageShell variant="tab" className="store-page no-tab-header">
|
||
<TabMainHeader title="门店" />
|
||
|
||
<View className="store-filter">
|
||
<View className="store-search-row">
|
||
<Input
|
||
className="store-search-input"
|
||
placeholder="搜索门店名称/地址"
|
||
value={keywordInput}
|
||
confirmType="search"
|
||
onInput={(e) => setKeywordInput(e.detail.value)}
|
||
onConfirm={applySearch}
|
||
/>
|
||
<View className="store-search-btn" onClick={applySearch} aria-label="搜索">
|
||
<View className="store-search-icon" />
|
||
</View>
|
||
</View>
|
||
|
||
<View className="store-filter-row">
|
||
<View className="store-filter-chip" onClick={() => setRegionOpen(true)}>
|
||
<Text className="store-filter-chip-text">{regionLabel}</Text>
|
||
<Text className="store-filter-chip-arrow">▾</Text>
|
||
</View>
|
||
<View className="store-filter-chip" onClick={() => setCategoryOpen(true)}>
|
||
<Text className="store-filter-chip-text">{categoryLabel}</Text>
|
||
<Text className="store-filter-chip-arrow">▾</Text>
|
||
</View>
|
||
<Text className="store-filter-reset" onClick={resetFilters}>
|
||
重置
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
|
||
<View className="store-list">
|
||
{loading ? <View className="u-empty">加载中…</View> : null}
|
||
{!loading && filtered.length === 0 ? <View className="u-empty">暂无营业中门店</View> : null}
|
||
{!loading &&
|
||
filtered.map((s, index) => (
|
||
<View
|
||
key={s.id}
|
||
className="store-card"
|
||
onClick={() => Taro.navigateTo({ url: `/pages/store-detail/index?id=${s.id}` })}
|
||
>
|
||
{s.coverUrl ? (
|
||
<Image className="store-card-cover" src={s.coverUrl} mode="aspectFill" />
|
||
) : (
|
||
<View className="store-card-cover--empty" />
|
||
)}
|
||
<View className="store-card-body">
|
||
<Text className="store-card-name">{s.name}</Text>
|
||
<Text className="store-card-meta">
|
||
{s.district ? `${s.district} · ` : ''}
|
||
{s.address || '地址待完善'}
|
||
</Text>
|
||
<Text className="store-card-meta">{formatHours(s)}</Text>
|
||
{s.avgPrice != null && Number(s.avgPrice) > 0 ? (
|
||
<Text className="store-card-meta">人均¥{Number(s.avgPrice).toFixed(0)}</Text>
|
||
) : null}
|
||
<View className="store-card-footer">
|
||
<Text className="store-card-distance">{MOCK_DISTANCES[index % MOCK_DISTANCES.length]}</Text>
|
||
<Text
|
||
className="store-card-cta"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
Taro.navigateTo({ url: '/pages/redeem/index' });
|
||
}}
|
||
>
|
||
去核销
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
))}
|
||
</View>
|
||
|
||
{shouldRenderPageTabBar() ? <UserTabBar selected={1} /> : null}
|
||
<RegionPicker
|
||
open={regionOpen}
|
||
value={region}
|
||
levels={3}
|
||
onClose={() => setRegionOpen(false)}
|
||
onConfirm={(next) => setRegion(next)}
|
||
/>
|
||
<CategoryPicker
|
||
open={categoryOpen}
|
||
tree={categoryTree}
|
||
value={category}
|
||
onClose={() => setCategoryOpen(false)}
|
||
onConfirm={(next) => setCategory(next)}
|
||
/>
|
||
</PageShell>
|
||
);
|
||
}
|