83b1b0e5f6
Add HQ group for default and per-page share title/desc/image; expose via client-config and wire all mini-user share entry points. Co-authored-by: Cursor <cursoragent@cursor.com>
400 lines
13 KiB
TypeScript
400 lines
13 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { View, Text, Image, Swiper, SwiperItem } from '@tarojs/components';
|
|
import Taro, {
|
|
useDidShow,
|
|
usePageScroll,
|
|
usePullDownRefresh,
|
|
useShareAppMessage,
|
|
useShareTimeline,
|
|
} from '@tarojs/taro';
|
|
import PageShell from '../../components/PageShell';
|
|
import TabMainHeader from '../../components/TabMainHeader';
|
|
import CouponBadge from '../../components/CouponBadge';
|
|
import WechatShareReady from '../../components/WechatShareReady';
|
|
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
|
import { goLogin } from '../../lib/auth-nav';
|
|
import { getToken, isLoggedIn, request, toast } from '../../lib/api';
|
|
import {
|
|
getHomeCatalogCache,
|
|
isHomeCatalogBootstrapped,
|
|
setHomeCatalogCache,
|
|
} from '../../lib/home-catalog-session';
|
|
import { ensurePayReady } from '../../lib/pay-ready';
|
|
import { capturePromoSceneAndTouchScan } from '../../lib/promo';
|
|
import { getProductMainImage } from '../../lib/product-images';
|
|
import {
|
|
canBuyOnline,
|
|
canPickupOnSite,
|
|
normalizeFulfillmentFlags,
|
|
} from '../../lib/product-fulfillment';
|
|
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location';
|
|
import type { ClientRuntimeConfig } from '@dukang/shared-types';
|
|
import {
|
|
applyShareFromClientConfig,
|
|
buildSceneSharePayload,
|
|
toWeappShareMessage,
|
|
toWeappShareTimeline,
|
|
} from '../../lib/wechat-share';
|
|
import { trackPageView } from '../../lib/analytics';
|
|
type Product = {
|
|
id: string;
|
|
name: string;
|
|
subtitle?: string;
|
|
spec?: string;
|
|
price: number;
|
|
benefitDisplay?: number;
|
|
mainImageUrl?: string | null;
|
|
carouselUrls?: string[] | null;
|
|
aromaType: string;
|
|
allowOnSitePickup?: boolean;
|
|
allowOnlinePurchase?: boolean;
|
|
allowCrossCityDelivery?: boolean;
|
|
};
|
|
|
|
type MiniHomeConfig = {
|
|
banners: string[];
|
|
footerUrl: string | null;
|
|
};
|
|
|
|
const AROMA_TABS = [
|
|
{ key: 'QINGXIANG', label: '清香型' },
|
|
{ key: 'JIANGXIANG', label: '酱香型' },
|
|
{ key: 'NONGXIANG', label: '浓香型' },
|
|
] as const;
|
|
|
|
type AromaKey = (typeof AROMA_TABS)[number]['key'];
|
|
|
|
/** sticky 香型导航高度(与 CSS 大致一致),锚点滚动时预留 */
|
|
const AROMA_NAV_OFFSET_PX = 44;
|
|
|
|
function aromaSectionId(key: AromaKey) {
|
|
return `aroma-section-${key}`;
|
|
}
|
|
|
|
export default function HomePage() {
|
|
const [activeAroma, setActiveAroma] = useState<AromaKey>('QINGXIANG');
|
|
const [products, setProducts] = useState<Product[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [displayCity, setDisplayCity] = useState('郑州市');
|
|
const [cityCode, setCityCode] = useState('410100');
|
|
const [miniHome, setMiniHome] = useState<MiniHomeConfig>({ banners: [], footerUrl: null });
|
|
const scrollingToRef = useRef<AromaKey | null>(null);
|
|
const scrollLockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const lastScrollSyncAtRef = useRef(0);
|
|
|
|
useEffect(() => {
|
|
trackPageView('home_view', { pagePath: '/pages/home/index', cityCode });
|
|
}, [cityCode]);
|
|
|
|
const loadMiniHome = useCallback(() => {
|
|
return request<ClientRuntimeConfig>('/common/client-config')
|
|
.then((cfg) => {
|
|
applyShareFromClientConfig(cfg);
|
|
const banners = Array.isArray(cfg.miniHome?.banners)
|
|
? cfg.miniHome!.banners.filter((u) => typeof u === 'string' && !!u.trim())
|
|
: [];
|
|
const footerUrl =
|
|
typeof cfg.miniHome?.footerUrl === 'string' && cfg.miniHome.footerUrl.trim()
|
|
? cfg.miniHome.footerUrl.trim()
|
|
: null;
|
|
setMiniHome({ banners, footerUrl });
|
|
})
|
|
.catch(() => {
|
|
/* 首页装饰图失败不阻断商品列表 */
|
|
});
|
|
}, []);
|
|
|
|
const applyProductList = useCallback((list: Product[], nextCode: string, authKey: string) => {
|
|
const normalized = Array.isArray(list) ? list.map((p) => normalizeFulfillmentFlags(p)) : [];
|
|
setProducts(normalized);
|
|
setHomeCatalogCache({ cityCode: nextCode, authKey, products: normalized });
|
|
}, []);
|
|
|
|
const fetchProducts = useCallback(
|
|
(nextCode: string, authKey: string) => {
|
|
setLoading(true);
|
|
return request<Product[]>(`/catalog/products?cityCode=${encodeURIComponent(nextCode)}`)
|
|
.then((list) => applyProductList(list, nextCode, authKey))
|
|
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'))
|
|
.finally(() => setLoading(false));
|
|
},
|
|
[applyProductList],
|
|
);
|
|
|
|
/**
|
|
* 首次进入 / 城市或登录态变化:拉商品。
|
|
* 同次再切 tab:只同步选中态,不重复请求(对齐门店页)。
|
|
*/
|
|
useDidShow(() => {
|
|
syncTabBarSelected(0);
|
|
void capturePromoSceneAndTouchScan();
|
|
void loadMiniHome();
|
|
|
|
const authKey = getToken() || '';
|
|
void (async () => {
|
|
const resolved = await resolveUserCity();
|
|
const nextCode = getCityCodeForCatalog(resolved);
|
|
setDisplayCity(resolved.displayCity);
|
|
setCityCode(nextCode);
|
|
|
|
const cache = getHomeCatalogCache();
|
|
if (
|
|
isHomeCatalogBootstrapped() &&
|
|
cache &&
|
|
cache.cityCode === nextCode &&
|
|
cache.authKey === authKey &&
|
|
Array.isArray(cache.products)
|
|
) {
|
|
setProducts(cache.products as Product[]);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
await fetchProducts(nextCode, authKey);
|
|
})();
|
|
});
|
|
|
|
usePullDownRefresh(() => {
|
|
void (async () => {
|
|
try {
|
|
const authKey = getToken() || '';
|
|
const resolved = await resolveUserCity();
|
|
setDisplayCity(resolved.displayCity);
|
|
const nextCode = getCityCodeForCatalog(resolved);
|
|
setCityCode(nextCode);
|
|
setLoading(true);
|
|
const [list] = await Promise.all([
|
|
request<Product[]>(`/catalog/products?cityCode=${encodeURIComponent(nextCode)}`),
|
|
loadMiniHome(),
|
|
]);
|
|
applyProductList(
|
|
Array.isArray(list) ? list : [],
|
|
nextCode,
|
|
authKey,
|
|
);
|
|
} catch (e) {
|
|
toast(e instanceof Error ? e.message : '加载失败');
|
|
} finally {
|
|
setLoading(false);
|
|
Taro.stopPullDownRefresh();
|
|
}
|
|
})();
|
|
});
|
|
function openProductDetail(id: string) {
|
|
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` });
|
|
}
|
|
|
|
async function goOnSitePickup(productId: string) {
|
|
const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=1`;
|
|
if (!isLoggedIn()) {
|
|
goLogin(returnPath);
|
|
return;
|
|
}
|
|
const ready = await ensurePayReady(returnPath);
|
|
if (!ready) return;
|
|
Taro.navigateTo({ url: returnPath });
|
|
}
|
|
|
|
const productsByAroma = useMemo(() => {
|
|
const map: Record<AromaKey, Product[]> = {
|
|
QINGXIANG: [],
|
|
JIANGXIANG: [],
|
|
NONGXIANG: [],
|
|
};
|
|
for (const p of products) {
|
|
const key = p.aromaType as AromaKey;
|
|
if (key in map) map[key].push(p);
|
|
}
|
|
return map;
|
|
}, [products]);
|
|
|
|
const visibleAromaTabs = useMemo(
|
|
() => AROMA_TABS.filter((t) => productsByAroma[t.key].length > 0),
|
|
[productsByAroma],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (loading || visibleAromaTabs.length === 0) return;
|
|
if (!visibleAromaTabs.some((t) => t.key === activeAroma)) {
|
|
setActiveAroma(visibleAromaTabs[0].key);
|
|
}
|
|
}, [loading, visibleAromaTabs, activeAroma]);
|
|
|
|
const banners = miniHome.banners;
|
|
const footerUrl = miniHome.footerUrl;
|
|
|
|
const sharePayload = useMemo(
|
|
() =>
|
|
buildSceneSharePayload('home', {
|
|
path: '/pages/home/index',
|
|
dynamicImageUrl: banners[0] || undefined,
|
|
}),
|
|
[banners],
|
|
);
|
|
|
|
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
|
useShareTimeline(() => toWeappShareTimeline(sharePayload));
|
|
|
|
function scrollToAroma(key: AromaKey) {
|
|
setActiveAroma(key);
|
|
scrollingToRef.current = key;
|
|
if (scrollLockTimerRef.current) clearTimeout(scrollLockTimerRef.current);
|
|
scrollLockTimerRef.current = setTimeout(() => {
|
|
scrollingToRef.current = null;
|
|
}, 450);
|
|
|
|
const query = Taro.createSelectorQuery();
|
|
query.select(`#${aromaSectionId(key)}`).boundingClientRect();
|
|
query.selectViewport().scrollOffset();
|
|
query.exec((res) => {
|
|
const rect = res?.[0] as { top?: number } | undefined;
|
|
const viewport = res?.[1] as { scrollTop?: number } | undefined;
|
|
if (rect?.top == null || viewport?.scrollTop == null) return;
|
|
const scrollTop = Math.max(0, viewport.scrollTop + rect.top - AROMA_NAV_OFFSET_PX);
|
|
void Taro.pageScrollTo({ scrollTop, duration: 280 });
|
|
});
|
|
}
|
|
|
|
usePageScroll(() => {
|
|
if (scrollingToRef.current) return;
|
|
const now = Date.now();
|
|
if (now - lastScrollSyncAtRef.current < 80) return;
|
|
lastScrollSyncAtRef.current = now;
|
|
const query = Taro.createSelectorQuery();
|
|
visibleAromaTabs.forEach((t) => {
|
|
query.select(`#${aromaSectionId(t.key)}`).boundingClientRect();
|
|
});
|
|
query.exec((rects) => {
|
|
if (!Array.isArray(rects) || rects.length === 0) return;
|
|
let next: AromaKey = visibleAromaTabs[0]?.key ?? AROMA_TABS[0].key;
|
|
for (let i = 0; i < visibleAromaTabs.length; i++) {
|
|
const rect = rects[i] as { top?: number } | null;
|
|
if (!rect || rect.top == null) continue;
|
|
// 区块顶进入导航下方一带时视为当前香型
|
|
if (rect.top <= AROMA_NAV_OFFSET_PX + 24) {
|
|
next = visibleAromaTabs[i].key;
|
|
}
|
|
}
|
|
setActiveAroma((prev) => (prev === next ? prev : next));
|
|
});
|
|
});
|
|
|
|
function renderProductCard(p: Product) {
|
|
const thumb = getProductMainImage(p);
|
|
const spec = p.subtitle || p.spec || '';
|
|
return (
|
|
<View key={p.id} className="home-product-card">
|
|
<View className="home-product-card-inner" onClick={() => openProductDetail(p.id)}>
|
|
<View className="home-product-thumb-wrap">
|
|
{thumb ? (
|
|
<Image className="home-product-thumb" src={thumb} mode="aspectFill" />
|
|
) : (
|
|
<View className="home-product-thumb home-product-thumb--empty" />
|
|
)}
|
|
</View>
|
|
<View className="home-product-main">
|
|
<View className="home-product-row">
|
|
<Text className="home-product-name">{p.name}</Text>
|
|
<Text className="home-product-price">¥{Number(p.price).toFixed(0)}</Text>
|
|
</View>
|
|
{spec ? <Text className="home-product-sub">{spec}</Text> : null}
|
|
<View className="home-product-footer">
|
|
<CouponBadge amount={p.benefitDisplay ?? p.price} label="好客权益" />
|
|
</View>
|
|
<View className="home-product-actions">
|
|
{canPickupOnSite(p) ? (
|
|
<Text
|
|
className="home-pickup-btn"
|
|
onClick={(e) => {
|
|
e.stopPropagation?.();
|
|
void goOnSitePickup(p.id);
|
|
}}
|
|
>
|
|
现场取货
|
|
</Text>
|
|
) : null}
|
|
{canBuyOnline(p) ? (
|
|
<Text
|
|
className="home-buy-btn"
|
|
onClick={(e) => {
|
|
e.stopPropagation?.();
|
|
openProductDetail(p.id);
|
|
}}
|
|
>
|
|
立即购买
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<PageShell variant="tab" className="home-page no-tab-header">
|
|
<WechatShareReady payload={sharePayload} />
|
|
<TabMainHeader title="杜康好客" />
|
|
|
|
{banners.length > 0 ? (
|
|
<View className="home-promo-banner">
|
|
<Swiper
|
|
className="home-promo-banner-swiper"
|
|
indicatorDots={banners.length > 1}
|
|
autoplay={banners.length > 1}
|
|
circular={banners.length > 1}
|
|
interval={2500}
|
|
>
|
|
{banners.map((url) => (
|
|
<SwiperItem key={url}>
|
|
<Image className="home-promo-banner-img" src={url} mode="aspectFill" />
|
|
</SwiperItem>
|
|
))}
|
|
</Swiper>
|
|
</View>
|
|
) : null}
|
|
|
|
<View className="home-aroma-nav">
|
|
<View className="home-aroma-tabs">
|
|
{visibleAromaTabs.map((t) => (
|
|
<Text
|
|
key={t.key}
|
|
className={`home-aroma-tab${activeAroma === t.key ? ' home-aroma-tab--active' : ''}`}
|
|
onClick={() => scrollToAroma(t.key)}
|
|
>
|
|
{t.label}
|
|
</Text>
|
|
))}
|
|
</View>
|
|
<Text className="home-aroma-city">{displayCity}</Text>
|
|
</View>
|
|
|
|
<View className="home-product-list">
|
|
{loading ? <View className="home-empty">加载中…</View> : null}
|
|
{!loading && products.length === 0 ? (
|
|
<View className="home-empty">当前城市暂无在售商品</View>
|
|
) : null}
|
|
{!loading &&
|
|
products.length > 0 &&
|
|
visibleAromaTabs.map((t) => {
|
|
const list = productsByAroma[t.key];
|
|
return (
|
|
<View key={t.key} id={aromaSectionId(t.key)} className="home-aroma-section">
|
|
<Text className="home-aroma-section-title">{t.label}</Text>
|
|
{list.map((p) => renderProductCard(p))}
|
|
</View>
|
|
);
|
|
})}
|
|
</View>
|
|
|
|
{footerUrl ? (
|
|
<View className="home-promo-footer">
|
|
<Image className="home-promo-footer-img" src={footerUrl} mode="aspectFill" />
|
|
</View>
|
|
) : null}
|
|
|
|
{shouldRenderPageTabBar() ? <UserTabBar selected={0} /> : null}
|
|
</PageShell>
|
|
);
|
|
}
|