Files
dukang/apps/mini-user/src/pages/stores/index.tsx
T

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>
);
}