242 lines
8.4 KiB
TypeScript
242 lines
8.4 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;
|
|
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) {
|
|
if (store.openTime && store.closeTime) {
|
|
return `营业时间: ${store.openTime}-${store.closeTime}`;
|
|
}
|
|
return '营业时间: 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>
|
|
<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>
|
|
);
|
|
}
|