464 lines
14 KiB
TypeScript
464 lines
14 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import { View, Text, Image } from '@tarojs/components';
|
||
import Taro, {
|
||
useDidShow,
|
||
useLoad,
|
||
usePageScroll,
|
||
useRouter,
|
||
useShareAppMessage,
|
||
useShareTimeline,
|
||
} from '@tarojs/taro';
|
||
import PageShell from '../../components/PageShell';
|
||
import PageNavBar from '../../components/PageNavBar';
|
||
import ProductCarousel from '../../components/ProductCarousel';
|
||
import ShareNavButton from '../../components/ShareNavButton';
|
||
import StoreRedeemMarquee from '../../components/StoreRedeemMarquee';
|
||
import WechatShareReady from '../../components/WechatShareReady';
|
||
import { request, toast } from '../../lib/api';
|
||
import { formatShanghaiDateTime } from '../../lib/datetime';
|
||
import {
|
||
DEFAULT_SHARE_DESC,
|
||
DEFAULT_SHARE_TITLE,
|
||
toWeappShareMessage,
|
||
} from '../../lib/wechat-share';
|
||
|
||
type StoreMedia = {
|
||
url?: string | null;
|
||
bizType?: string | null;
|
||
mediaType?: string | null;
|
||
};
|
||
|
||
type StorePackage = {
|
||
name: string;
|
||
price: string | number;
|
||
dishes: string;
|
||
usableTime?: string | null;
|
||
otherNotes?: string | null;
|
||
sortOrder?: number;
|
||
};
|
||
|
||
type Store = {
|
||
id: string;
|
||
name: string;
|
||
address?: string;
|
||
province?: string;
|
||
cityName?: string;
|
||
city?: string;
|
||
district?: string;
|
||
phone?: string;
|
||
intro?: string | null;
|
||
benefitUsageRule?: string | null;
|
||
coverUrl?: string | null;
|
||
carouselUrls?: string[] | null;
|
||
media?: StoreMedia[] | null;
|
||
packages?: StorePackage[] | null;
|
||
openTime?: string | null;
|
||
closeTime?: string | null;
|
||
openTime2?: string | null;
|
||
closeTime2?: string | null;
|
||
avgPrice?: number | null;
|
||
latitude?: number | string | null;
|
||
longitude?: number | string | null;
|
||
category?: { name: string } | null;
|
||
};
|
||
|
||
type RecentRedeem = {
|
||
userLabel: string;
|
||
amount: number | string;
|
||
createdAt: string;
|
||
text?: string;
|
||
};
|
||
|
||
function uniqueUrls(urls: Array<string | null | undefined>) {
|
||
const seen = new Set<string>();
|
||
const out: string[] = [];
|
||
for (const raw of urls) {
|
||
const url = String(raw || '').trim();
|
||
if (!url || seen.has(url)) continue;
|
||
seen.add(url);
|
||
out.push(url);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function envPhotoUrls(store: Store) {
|
||
return uniqueUrls(
|
||
(store.media || [])
|
||
.filter((m) => !m.bizType || m.bizType === 'ENV')
|
||
.map((m) => m.url),
|
||
);
|
||
}
|
||
|
||
function fullAddress(store: Store) {
|
||
const city = store.cityName || store.city || '';
|
||
return `${store.province || ''}${city}${store.district || ''}${store.address || ''}`.trim();
|
||
}
|
||
|
||
function pickStoreId(raw?: string | null) {
|
||
return String(raw || '')
|
||
.trim()
|
||
.replace(/[^\d]/g, '');
|
||
}
|
||
|
||
/** 与历史记录一致:2026-08-03 15:14:30(Asia/Shanghai) */
|
||
function formatRedeemTime(input?: string | null) {
|
||
return formatShanghaiDateTime(input);
|
||
}
|
||
|
||
function formatRedeemAmountYuan(amount: number | string) {
|
||
const n = typeof amount === 'number' ? amount : Number(amount);
|
||
if (!Number.isFinite(n)) return '0';
|
||
if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n));
|
||
return n.toFixed(2).replace(/\.?0+$/, '');
|
||
}
|
||
|
||
function formatRecentRedeemLine(row: RecentRedeem) {
|
||
try {
|
||
// 优先用服务端拼好的 text,避免客户端时区 / Intl 差异
|
||
if (row.text?.trim()) return row.text.trim();
|
||
const label = String(row.userLabel || '用户***').trim() || '用户***';
|
||
const rawTime = String(row.createdAt || '').trim();
|
||
const time =
|
||
/^\d{4}-\d{2}-\d{2}/.test(rawTime)
|
||
? rawTime.slice(0, 19).replace('T', ' ')
|
||
: formatRedeemTime(row.createdAt);
|
||
const amount = formatRedeemAmountYuan(row.amount);
|
||
return `${label} ${time} 核销${amount}元`;
|
||
} catch {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function normalizeRecentRedeems(payload: unknown): RecentRedeem[] {
|
||
try {
|
||
if (Array.isArray(payload)) return payload as RecentRedeem[];
|
||
if (payload && typeof payload === 'object') {
|
||
const list =
|
||
(payload as { list?: unknown; items?: unknown; data?: unknown }).list ??
|
||
(payload as { items?: unknown }).items ??
|
||
(payload as { data?: unknown }).data;
|
||
if (Array.isArray(list)) return list as RecentRedeem[];
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
return [];
|
||
}
|
||
|
||
export default function StoreDetailPage() {
|
||
const router = useRouter();
|
||
const [storeId, setStoreId] = useState(() => pickStoreId(router.params.id));
|
||
const [store, setStore] = useState<Store | null>(null);
|
||
const [recentRedeems, setRecentRedeems] = useState<RecentRedeem[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [loadError, setLoadError] = useState('');
|
||
const [headerSolid, setHeaderSolid] = useState(false);
|
||
const storeRef = useRef<Store | null>(null);
|
||
storeRef.current = store;
|
||
|
||
usePageScroll(({ scrollTop }) => {
|
||
setHeaderSolid(scrollTop > 100);
|
||
});
|
||
|
||
const loadStore = useCallback(async (id: string) => {
|
||
if (!id) {
|
||
setLoading(false);
|
||
setLoadError('缺少门店参数');
|
||
return;
|
||
}
|
||
setLoadError('');
|
||
if (!storeRef.current) setLoading(true);
|
||
try {
|
||
const data = await request<Store>(`/stores/${id}`);
|
||
if (!data || !data.id) {
|
||
setStore(null);
|
||
setLoadError('门店不存在或暂不可见');
|
||
toast('门店不存在或暂不可见');
|
||
return;
|
||
}
|
||
setStore(data);
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : '加载失败';
|
||
setLoadError(msg);
|
||
if (!storeRef.current) toast(msg);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
const loadRecentRedeems = useCallback(async (id: string) => {
|
||
if (!id) {
|
||
setRecentRedeems([]);
|
||
return;
|
||
}
|
||
try {
|
||
const list = await request<unknown>(`/stores/${id}/recent-redeems?limit=20`);
|
||
setRecentRedeems(normalizeRecentRedeems(list));
|
||
} catch {
|
||
setRecentRedeems([]);
|
||
}
|
||
}, []);
|
||
|
||
const bootstrap = useCallback(
|
||
(id: string) => {
|
||
const nextId = pickStoreId(id);
|
||
if (!nextId) {
|
||
setLoading(false);
|
||
setLoadError('缺少门店参数');
|
||
return;
|
||
}
|
||
setStoreId(nextId);
|
||
void loadStore(nextId);
|
||
void loadRecentRedeems(nextId);
|
||
},
|
||
[loadStore, loadRecentRedeems],
|
||
);
|
||
|
||
// 首屏:useLoad 带 options.id,比仅用 useDidShow 更稳(H5/小程序都覆盖)
|
||
useLoad((options) => {
|
||
bootstrap(options?.id || router.params.id || '');
|
||
});
|
||
|
||
useEffect(() => {
|
||
const fromRouter = pickStoreId(router.params.id);
|
||
if (fromRouter && fromRouter !== storeId) {
|
||
bootstrap(fromRouter);
|
||
}
|
||
}, [router.params.id, storeId, bootstrap]);
|
||
|
||
// 登录态变化后回到本页:重拉详情与走马灯
|
||
useDidShow(() => {
|
||
const id = pickStoreId(storeId || router.params.id);
|
||
if (!id) return;
|
||
void loadStore(id);
|
||
void loadRecentRedeems(id);
|
||
});
|
||
|
||
const sharePayload = useMemo(
|
||
() => {
|
||
const envFirst = store ? envPhotoUrls(store)[0] : undefined;
|
||
return {
|
||
title: store?.name || DEFAULT_SHARE_TITLE,
|
||
desc: store?.intro?.trim() || store?.address || DEFAULT_SHARE_DESC,
|
||
path: `/pages/store-detail/index?id=${storeId}`,
|
||
imgUrl: store?.coverUrl || envFirst || store?.carouselUrls?.[0] || undefined,
|
||
};
|
||
},
|
||
[store, storeId],
|
||
);
|
||
|
||
const marqueeLines = useMemo(
|
||
() => recentRedeems.map(formatRecentRedeemLine).filter(Boolean),
|
||
[recentRedeems],
|
||
);
|
||
|
||
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
||
useShareTimeline(() => ({
|
||
title: sharePayload.title || DEFAULT_SHARE_TITLE,
|
||
query: storeId ? `id=${storeId}` : '',
|
||
imageUrl: sharePayload.imgUrl,
|
||
}));
|
||
|
||
function goBack() {
|
||
const pages = Taro.getCurrentPages();
|
||
if (pages.length > 1) Taro.navigateBack();
|
||
else Taro.switchTab({ url: '/pages/stores/index' });
|
||
}
|
||
|
||
function callStore() {
|
||
if (!store?.phone) {
|
||
toast('暂无门店电话');
|
||
return;
|
||
}
|
||
Taro.makePhoneCall({ phoneNumber: store.phone }).catch(() => toast('无法拨打电话'));
|
||
}
|
||
|
||
function openMap() {
|
||
if (!store) return;
|
||
const lat = store.latitude != null ? Number(store.latitude) : NaN;
|
||
const lng = store.longitude != null ? Number(store.longitude) : NaN;
|
||
const address = fullAddress(store) || store.address || store.name;
|
||
|
||
if (Number.isFinite(lat) && Number.isFinite(lng)) {
|
||
Taro.openLocation({
|
||
latitude: lat,
|
||
longitude: lng,
|
||
name: store.name,
|
||
address,
|
||
scale: 16,
|
||
}).catch(() => {
|
||
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
|
||
window.location.href = `https://uri.amap.com/marker?position=${lng},${lat}&name=${encodeURIComponent(store.name)}&address=${encodeURIComponent(address)}`;
|
||
return;
|
||
}
|
||
toast('无法打开地图导航');
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined' && address) {
|
||
window.location.href = `https://uri.amap.com/search?keyword=${encodeURIComponent(address)}&src=dukang`;
|
||
return;
|
||
}
|
||
toast('门店位置待完善,暂无法导航');
|
||
}
|
||
|
||
if (!store) {
|
||
return (
|
||
<PageShell variant="scroll" className="store-detail-page">
|
||
<PageNavBar title="门店详情" solid onBack={goBack} />
|
||
<View className="page-with-nav-bar u-empty">
|
||
{loading ? '加载中…' : loadError || '门店不存在或暂不可见'}
|
||
</View>
|
||
</PageShell>
|
||
);
|
||
}
|
||
|
||
const envPhotos = envPhotoUrls(store);
|
||
const images = uniqueUrls([
|
||
store.coverUrl,
|
||
...(store.carouselUrls || []),
|
||
...envPhotos,
|
||
]);
|
||
|
||
const intro = store.intro?.trim() || '';
|
||
const benefitRuleRaw = store.benefitUsageRule?.trim() || '';
|
||
const benefitRule =
|
||
benefitRuleRaw && !/^null$/i.test(benefitRuleRaw) ? benefitRuleRaw : '';
|
||
|
||
function previewEnv(index: number) {
|
||
if (!envPhotos.length) return;
|
||
Taro.previewImage({
|
||
current: envPhotos[index],
|
||
urls: envPhotos,
|
||
}).catch(() => toast('无法预览图片'));
|
||
}
|
||
|
||
return (
|
||
<PageShell variant="scroll" className="store-detail-page" hasFixedFooter>
|
||
<WechatShareReady payload={sharePayload} />
|
||
<PageNavBar
|
||
title={store.name}
|
||
solid={headerSolid}
|
||
titleVisible={headerSolid}
|
||
onBack={goBack}
|
||
right={<ShareNavButton payload={sharePayload} />}
|
||
/>
|
||
|
||
<View className="store-detail-hero full-bleed">
|
||
<ProductCarousel images={images} alt={store.name} variant="store" />
|
||
</View>
|
||
|
||
<View className="store-detail-info-card">
|
||
<Text className="store-detail-name">{store.name}</Text>
|
||
|
||
<View className="store-detail-row">
|
||
<Text className="store-detail-meta store-detail-meta--flex">
|
||
{store.district ? `${store.district} · ` : ''}
|
||
{store.address || '地址待完善'}
|
||
</Text>
|
||
<Text className="store-detail-action" onClick={openMap}>
|
||
导航
|
||
</Text>
|
||
</View>
|
||
|
||
<Text className="store-detail-meta">
|
||
营业时间:{' '}
|
||
{(() => {
|
||
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';
|
||
})()}
|
||
</Text>
|
||
{store.avgPrice != null && Number(store.avgPrice) > 0 ? (
|
||
<Text className="store-detail-meta">人均约 ¥{Number(store.avgPrice).toFixed(0)}</Text>
|
||
) : null}
|
||
|
||
{store.phone ? (
|
||
<View className="store-detail-row">
|
||
<Text className="store-detail-meta store-detail-meta--flex">电话: {store.phone}</Text>
|
||
<Text className="store-detail-action" onClick={callStore}>
|
||
拨打
|
||
</Text>
|
||
</View>
|
||
) : null}
|
||
|
||
<View className="store-detail-tags">
|
||
{store.category?.name ? (
|
||
<Text className="store-detail-tag">{store.category.name}</Text>
|
||
) : null}
|
||
<Text className="store-detail-tag">可核销</Text>
|
||
<Text className="store-detail-tag">好客门店</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{marqueeLines.length > 0 ? (
|
||
<View className="store-detail-marquee-wrap">
|
||
<StoreRedeemMarquee key={marqueeLines.join('|')} lines={marqueeLines} />
|
||
</View>
|
||
) : null}
|
||
|
||
{store.packages && store.packages.length > 0 ? (
|
||
<View className="store-detail-section">
|
||
<Text className="store-detail-section-title">门店套餐</Text>
|
||
{store.packages.map((pkg, index) => (
|
||
<View key={`${pkg.name}-${index}`} className="store-detail-package-card">
|
||
<Text className="store-detail-package-name">{pkg.name}</Text>
|
||
<Text className="store-detail-package-body">
|
||
{formatRedeemAmountYuan(pkg.price)} 元 · {pkg.dishes}
|
||
</Text>
|
||
{pkg.usableTime ? (
|
||
<Text className="store-detail-package-meta">使用时间:{pkg.usableTime}</Text>
|
||
) : null}
|
||
{pkg.otherNotes ? (
|
||
<Text className="store-detail-package-meta">说明:{pkg.otherNotes}</Text>
|
||
) : null}
|
||
</View>
|
||
))}
|
||
</View>
|
||
) : null}
|
||
|
||
{intro ? (
|
||
<View className="store-detail-section">
|
||
<Text className="store-detail-section-title">门店详情</Text>
|
||
<Text className="store-detail-intro">{intro}</Text>
|
||
</View>
|
||
) : null}
|
||
|
||
{benefitRule ? (
|
||
<View className="store-detail-section">
|
||
<Text className="store-detail-section-title">好客权益券使用规则</Text>
|
||
<Text className="store-detail-intro">{benefitRule}</Text>
|
||
</View>
|
||
) : null}
|
||
|
||
{envPhotos.length > 0 ? (
|
||
<View className="store-detail-section">
|
||
<Text className="store-detail-section-title">店内环境</Text>
|
||
<View className="store-detail-env-grid">
|
||
{envPhotos.map((url, index) => (
|
||
<View
|
||
key={`${url}-${index}`}
|
||
className="store-detail-env-item"
|
||
onClick={() => previewEnv(index)}
|
||
>
|
||
<Image className="store-detail-env-img" src={url} mode="aspectFill" />
|
||
</View>
|
||
))}
|
||
</View>
|
||
</View>
|
||
) : null}
|
||
|
||
<View className="store-detail-bar">
|
||
<View
|
||
className="u-btn u-btn--block"
|
||
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
|
||
>
|
||
<Text>到店核销</Text>
|
||
</View>
|
||
</View>
|
||
</PageShell>
|
||
);
|
||
}
|