diff --git a/apps/h5-user/.env.example b/apps/h5-user/.env.example deleted file mode 100644 index 62dfccb..0000000 --- a/apps/h5-user/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -# 企业微信客服链接(覆盖 shared-types 默认值) -# VITE_CS_WECOM_URL=https://work.weixin.qq.com/kfid/kfc8b88659a1dffa8cd diff --git a/apps/h5-user/index.html b/apps/h5-user/index.html deleted file mode 100644 index 24a7e13..0000000 --- a/apps/h5-user/index.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - 杜康好客 - - - - - -
- - - diff --git a/apps/h5-user/package.json b/apps/h5-user/package.json deleted file mode 100644 index 7d9941e..0000000 --- a/apps/h5-user/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "@dukang/h5-user", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "dev": "vite --port 5173", - "build": "vite build", - "lint": "echo ok" - }, - "dependencies": { - "@dukang/client-logging": "workspace:*", - "@dukang/shared-types": "workspace:*", - "@dukang/shared-ui": "workspace:*", - "@dukang/weixin-sdk": "workspace:*", - "element-china-area-data": "^6.1.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-router-dom": "^6.26.0" - }, - "devDependencies": { - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", - "@vitejs/plugin-react": "^4.3.1", - "typescript": "^5.4.5", - "vite": "^5.4.0" - } -} diff --git a/apps/h5-user/public/MP_verify_ayPJ4CQqbUcec3jX.txt b/apps/h5-user/public/MP_verify_ayPJ4CQqbUcec3jX.txt deleted file mode 100644 index af78290..0000000 --- a/apps/h5-user/public/MP_verify_ayPJ4CQqbUcec3jX.txt +++ /dev/null @@ -1 +0,0 @@ -ayPJ4CQqbUcec3jX diff --git a/apps/h5-user/public/images/1.png b/apps/h5-user/public/images/1.png deleted file mode 100644 index 380b479..0000000 Binary files a/apps/h5-user/public/images/1.png and /dev/null differ diff --git a/apps/h5-user/public/images/2.png b/apps/h5-user/public/images/2.png deleted file mode 100644 index 83dec32..0000000 Binary files a/apps/h5-user/public/images/2.png and /dev/null differ diff --git a/apps/h5-user/public/images/3.png b/apps/h5-user/public/images/3.png deleted file mode 100644 index 6276b30..0000000 Binary files a/apps/h5-user/public/images/3.png and /dev/null differ diff --git a/apps/h5-user/public/logo.png b/apps/h5-user/public/logo.png deleted file mode 100644 index 1b06d2b..0000000 Binary files a/apps/h5-user/public/logo.png and /dev/null differ diff --git a/apps/h5-user/public/logo1.png b/apps/h5-user/public/logo1.png deleted file mode 100644 index 3e1ecfb..0000000 Binary files a/apps/h5-user/public/logo1.png and /dev/null differ diff --git a/apps/h5-user/src/App.tsx b/apps/h5-user/src/App.tsx deleted file mode 100644 index 99bd957..0000000 --- a/apps/h5-user/src/App.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { Routes, Route, Navigate } from 'react-router-dom'; -import { useEffect } from 'react'; -import TabLayout from './layouts/TabLayout'; -import LoginPage from './pages/LoginPage'; -import LegalPage from './pages/LegalPage'; -import HomePage from './pages/HomePage'; -import ProductDetailPage from './pages/ProductDetailPage'; -import OrderConfirmPage from './pages/OrderConfirmPage'; -import AddressListPage from './pages/AddressListPage'; -import AddressEditPage from './pages/AddressEditPage'; -import OrderListPage from './pages/OrderListPage'; -import OrderDetailPage from './pages/OrderDetailPage'; -import StoreListPage from './pages/StoreListPage'; -import StoreDetailPage from './pages/StoreDetailPage'; -import BenefitPage from './pages/BenefitPage'; -import BenefitDetailPage from './pages/BenefitDetailPage'; -import MinePage from './pages/MinePage'; -import RedeemPage from './pages/RedeemPage'; -import RedeemCodePage from './pages/RedeemCodePage'; -import RedeemSuccessPage from './pages/RedeemSuccessPage'; -import PayPage from './pages/PayPage'; -import CustomerServicePage from './pages/CustomerServicePage'; -import AfterSalePage from './pages/AfterSalePage'; -import AfterSaleListPage from './pages/AfterSaleListPage'; -import InvoiceApplyPage from './pages/InvoiceApplyPage'; -import InvoiceListPage from './pages/InvoiceListPage'; -import { UserSessionProvider } from './contexts/UserSessionContext'; -import { capturePromoFromUrl } from './lib/promo'; -import WechatShareBootstrap from './components/WechatShareBootstrap'; - -function PromoBootstrap() { - useEffect(() => { - capturePromoFromUrl(); - }, []); - return null; -} - -export default function App() { - return ( - - - - - } /> - } /> - } /> - }> - } /> - } /> - } /> - } /> - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - ); -} diff --git a/apps/h5-user/src/components/AppToast.tsx b/apps/h5-user/src/components/AppToast.tsx deleted file mode 100644 index 8446ca5..0000000 --- a/apps/h5-user/src/components/AppToast.tsx +++ /dev/null @@ -1,8 +0,0 @@ -type AppToastProps = { - message: string; -}; - -export default function AppToast({ message }: AppToastProps) { - if (!message) return null; - return
{message}
; -} diff --git a/apps/h5-user/src/components/ContactCustomerSheet.tsx b/apps/h5-user/src/components/ContactCustomerSheet.tsx deleted file mode 100644 index ddcdb87..0000000 --- a/apps/h5-user/src/components/ContactCustomerSheet.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { useEffect, useState } from 'react'; -import { track } from '../lib/analytics'; -import { - getCustomerServicePhone, - loadCustomerServicePhone, - openWecomCustomerService, -} from '../lib/customer-service'; - -type ContactCustomerSheetProps = { - orderId?: string; - orderNo?: string; - onClose: () => void; -}; - -export default function ContactCustomerSheet({ orderId, onClose }: ContactCustomerSheetProps) { - const [phone, setPhone] = useState(getCustomerServicePhone); - - useEffect(() => { - void loadCustomerServicePhone().then(setPhone); - }, []); - - const tel = phone.replace(/-/g, ''); - - function openPhone() { - track('cs_contact', { type: 'phone', orderId }); - window.location.href = `tel:${tel}`; - onClose(); - } - - function openOnline() { - track('cs_contact', { type: 'wecom_kf', orderId }); - if (openWecomCustomerService()) { - onClose(); - } - } - - return ( -
-
e.stopPropagation()}> -
-

联系客服

- -
- -
- - - -
- - -
-
- ); -} diff --git a/apps/h5-user/src/components/PhoneVerifySheet.tsx b/apps/h5-user/src/components/PhoneVerifySheet.tsx deleted file mode 100644 index ed6ad19..0000000 --- a/apps/h5-user/src/components/PhoneVerifySheet.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { useEffect, useState } from 'react'; -import type { WechatLoginResult } from '@dukang/shared-types'; -import { SmsScene } from '@dukang/shared-types'; -import { bindPhone, request, type SessionPayload } from '../lib/api'; -import { normalizePhoneInput, validateMobilePhone } from '../lib/phone'; -import { useSmsCode } from '../lib/use-sms-code'; -import { useUserSession } from '../contexts/UserSessionContext'; - -type PhoneVerifySheetProps = { - open: boolean; - /** 打开时预填手机号(如收货地址中的手机号) */ - defaultPhone?: string; - mode?: 'bind_phone' | 'wechat_bind_phone'; - wxSessionKey?: string; - title?: string; - description?: string; - onClose: () => void; - onSuccess: () => void; -}; - -export default function PhoneVerifySheet({ - open, - defaultPhone, - mode = 'bind_phone', - wxSessionKey, - title, - description, - onClose, - onSuccess, -}: PhoneVerifySheetProps) { - const { applySession } = useUserSession(); - const [phone, setPhone] = useState(''); - const [code, setCode] = useState(''); - const [loading, setLoading] = useState(false); - const { sendCode, sending, codeCooldown, sentHint, error, setError, clearMessages } = useSmsCode(); - - useEffect(() => { - if (!open) { - setPhone(''); - setCode(''); - setError(''); - clearMessages(); - return; - } - if (defaultPhone) { - const normalized = normalizePhoneInput(defaultPhone); - if (validateMobilePhone(normalized).ok) { - setPhone(normalized); - } - } - }, [open, defaultPhone, clearMessages, setError]); - - async function onSendCode() { - clearMessages(); - await sendCode(phone, SmsScene.BIND_PHONE); - } - - async function submit() { - const phoneCheck = validateMobilePhone(phone); - if (!phoneCheck.ok) { - setError(phoneCheck.message ?? '请输入正确的手机号码'); - return; - } - if (!code.trim()) { - setError('请输入验证码'); - return; - } - setLoading(true); - setError(''); - try { - if (mode === 'wechat_bind_phone') { - if (!wxSessionKey) { - setError('微信会话已过期,请重新授权'); - return; - } - const data = await request('USER_H5', '/auth/wechat/bind-phone', { - method: 'POST', - body: JSON.stringify({ wxSessionKey, phone, code }), - }); - if (data.accessToken) { - applySession({ - accessToken: data.accessToken, - refreshToken: data.refreshToken ?? '', - deviceKey: data.deviceKey, - phoneVerified: !!data.phoneVerified, - user: data.user as SessionPayload['user'], - }); - } - } else { - const session = await bindPhone(phone, code); - applySession(session as SessionPayload); - } - onSuccess(); - onClose(); - } catch (e) { - setError(e instanceof Error ? e.message : '验证失败'); - } finally { - setLoading(false); - } - } - - if (!open) return null; - - const sheetTitle = title ?? (mode === 'wechat_bind_phone' ? '绑定手机号' : '验证手机号'); - const sheetDesc = - description ?? - (mode === 'wechat_bind_phone' - ? '建议绑定手机号,便于订单通知与售后;关闭可跳过继续支付' - : '建议绑定手机号,便于订单通知与售后;关闭可跳过继续下单'); - - return ( -
- -
- {(error || sentHint) && ( -

{error || sentHint}

- )} - - - - - ); -} diff --git a/apps/h5-user/src/components/ProductCarousel.tsx b/apps/h5-user/src/components/ProductCarousel.tsx deleted file mode 100644 index 35f49e5..0000000 --- a/apps/h5-user/src/components/ProductCarousel.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { useRef, useState } from 'react'; -import AppImage from '@dukang/shared-ui/AppImage'; - -type Props = { - images: string[]; - alt: string; - variant?: 'home' | 'detail' | 'store'; -}; - -export default function ProductCarousel({ images, alt, variant = 'home' }: Props) { - const scrollRef = useRef(null); - const [activeIndex, setActiveIndex] = useState(0); - const slides = images.length > 0 ? images : ['']; - - function onScroll() { - const el = scrollRef.current; - if (!el || el.offsetWidth === 0) return; - setActiveIndex(Math.round(el.scrollLeft / el.offsetWidth)); - } - - const wrapClass = - variant === 'store' - ? 'store-detail-carousel-wrap' - : variant === 'detail' - ? 'detail-carousel-wrap' - : 'home-carousel-wrap'; - const trackClass = - variant === 'store' - ? 'store-detail-carousel' - : variant === 'detail' - ? 'detail-carousel' - : 'home-carousel'; - const dotClass = - variant === 'store' - ? 'store-detail-carousel-dot' - : variant === 'detail' - ? 'detail-carousel-dot' - : 'home-carousel-dot'; - const itemClass = - variant === 'store' - ? 'store-detail-carousel-item' - : variant === 'detail' - ? 'detail-carousel-item' - : 'home-carousel-item'; - const placeholderClass = - variant === 'store' - ? 'store-detail-carousel-placeholder' - : variant === 'detail' - ? 'detail-carousel-placeholder' - : 'home-carousel-placeholder'; - - return ( -
-
- {slides.map((src, i) => ( -
- {src ? ( - - ) : ( -
- )} -
- ))} -
- {slides.length > 1 && ( -
- {slides.map((_, i) => ( - - ))} -
- )} -
- ); -} diff --git a/apps/h5-user/src/components/RegionPicker.tsx b/apps/h5-user/src/components/RegionPicker.tsx deleted file mode 100644 index d47ac48..0000000 --- a/apps/h5-user/src/components/RegionPicker.tsx +++ /dev/null @@ -1,226 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; -import { - REGION_ALL, - getCities, - getCitiesForPicker, - getDistricts, - getDistrictsForPicker, - getProvincesForPicker, - normalizeRegionSelection, - toCityLevelRegion, - type RegionSelection, -} from '../lib/region-data'; - -type RegionPickerProps = { - open: boolean; - value: RegionSelection; - onClose: () => void; - onConfirm: (region: RegionSelection) => void; - /** 2 = 仅省/市(门店列表);3 = 省/市/区(地址等) */ - levels?: 2 | 3; -}; - -type PickerLevel = 'province' | 'city' | 'district'; - -const ALL_TABS: Array<{ key: PickerLevel; label: string }> = [ - { key: 'province', label: '省份' }, - { key: 'city', label: '城市' }, - { key: 'district', label: '区县' }, -]; - -function initialTab(value: RegionSelection, levels: 2 | 3): PickerLevel { - const normalized = levels === 2 ? toCityLevelRegion(value) : normalizeRegionSelection(value); - if (levels === 2) { - return normalized.province && normalized.province !== REGION_ALL ? 'city' : 'province'; - } - if (normalized.district && normalized.district !== REGION_ALL) return 'district'; - if (normalized.city && normalized.city !== REGION_ALL) return 'city'; - return 'province'; -} - -function tabLabel(tab: PickerLevel, draft: RegionSelection, fallback: string) { - if (tab === 'province') { - return draft.province && draft.province !== REGION_ALL ? draft.province : fallback; - } - if (tab === 'city') { - return draft.city && draft.city !== REGION_ALL ? draft.city : fallback; - } - return draft.district && draft.district !== REGION_ALL ? draft.district : fallback; -} - -export default function RegionPicker({ - open, - value, - onClose, - onConfirm, - levels = 3, -}: RegionPickerProps) { - const [draft, setDraft] = useState(value); - const [activeTab, setActiveTab] = useState('province'); - const listRef = useRef(null); - - const tabs = levels === 2 ? ALL_TABS.slice(0, 2) : ALL_TABS; - - useEffect(() => { - if (!open) return; - const normalized = levels === 2 ? toCityLevelRegion(value) : normalizeRegionSelection(value); - setDraft(normalized); - setActiveTab(initialTab(value, levels)); - }, [open, value, levels]); - - const listItems = useMemo(() => { - if (activeTab === 'province') return getProvincesForPicker(); - if (activeTab === 'city') return getCitiesForPicker(draft.province); - return getDistrictsForPicker(draft.province, draft.city); - }, [activeTab, draft.province, draft.city]); - - const selectedValue = - activeTab === 'province' ? draft.province : activeTab === 'city' ? draft.city : draft.district; - - const canConfirm = - levels === 2 - ? Boolean(draft.province && draft.city) - : Boolean(draft.province && draft.city && draft.district); - - useEffect(() => { - if (!open) return; - scrollActiveIntoView(listRef.current, selectedValue); - }, [open, activeTab, selectedValue, listItems.length]); - - if (!open) return null; - - function scrollActiveIntoView(container: HTMLDivElement | null, label: string) { - if (!container || !label) return; - const active = container.querySelector(`[data-label="${CSS.escape(label)}"]`); - active?.scrollIntoView({ block: 'nearest' }); - } - - function selectProvince(province: string) { - if (province === REGION_ALL) { - setDraft({ province: REGION_ALL, city: REGION_ALL, district: REGION_ALL }); - setActiveTab('city'); - return; - } - const nextCities = getCities(province); - const city = nextCities[0] ?? ''; - if (levels === 2) { - setDraft({ - province, - city, - district: REGION_ALL, - }); - setActiveTab('city'); - return; - } - const nextDistricts = getDistricts(province, city); - setDraft({ - province, - city, - district: nextDistricts[0] ?? '', - }); - setActiveTab('city'); - } - - function selectCity(city: string) { - if (city === REGION_ALL) { - setDraft({ ...draft, city: REGION_ALL, district: REGION_ALL }); - if (levels === 3) setActiveTab('district'); - return; - } - if (levels === 2) { - setDraft({ - ...draft, - city, - district: REGION_ALL, - }); - return; - } - const nextDistricts = getDistricts(draft.province, city); - setDraft({ - ...draft, - city, - district: nextDistricts[0] ?? '', - }); - setActiveTab('district'); - } - - function selectDistrict(district: string) { - setDraft({ ...draft, district }); - } - - function onSelectItem(item: string) { - if (activeTab === 'province') selectProvince(item); - else if (activeTab === 'city') selectCity(item); - else selectDistrict(item); - } - - function onTabClick(tab: PickerLevel) { - if (tab === 'city' && !draft.province) return; - if (tab === 'district' && (!draft.province || !draft.city)) return; - setActiveTab(tab); - } - - function handleConfirm() { - if (!canConfirm) return; - const next = levels === 2 ? toCityLevelRegion(draft) : normalizeRegionSelection(draft); - onConfirm(next); - } - - return ( -
-
e.stopPropagation()} - > -
-
- {tabs.map((tab) => { - const disabled = - (tab.key === 'city' && !draft.province) || - (tab.key === 'district' && (!draft.province || !draft.city)); - return ( - - ); - })} -
- -
- -
- {listItems.map((item) => ( - - ))} -
-
-
- ); -} diff --git a/apps/h5-user/src/components/SubPageHeader.tsx b/apps/h5-user/src/components/SubPageHeader.tsx deleted file mode 100644 index ddad7b0..0000000 --- a/apps/h5-user/src/components/SubPageHeader.tsx +++ /dev/null @@ -1,14 +0,0 @@ -type SubPageHeaderProps = { - title: string; - onBack: () => void; -}; - -export default function SubPageHeader({ title, onBack }: SubPageHeaderProps) { - return ( -
- -
- ); -} diff --git a/apps/h5-user/src/components/TabMainHeader.tsx b/apps/h5-user/src/components/TabMainHeader.tsx deleted file mode 100644 index 5120a7e..0000000 --- a/apps/h5-user/src/components/TabMainHeader.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { ReactNode } from 'react'; - -type TabMainHeaderProps = { - title: string; - extra?: ReactNode; - className?: string; -}; - -export default function TabMainHeader({ title, extra, className = '' }: TabMainHeaderProps) { - // H5:系统标题已展示;无右侧内容时整栏不渲染,避免顶部留白 - if (!extra) { - return null; - } - - return ( -
- {extra ?
{extra}
: null} -
- ); -} diff --git a/apps/h5-user/src/components/WechatShareBootstrap.tsx b/apps/h5-user/src/components/WechatShareBootstrap.tsx deleted file mode 100644 index d49165c..0000000 --- a/apps/h5-user/src/components/WechatShareBootstrap.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { useEffect } from 'react'; -import { useLocation } from 'react-router-dom'; -import { applyDefaultWechatShare } from '../lib/wechat-share'; - -/** 路由变化时刷新微信右上角分享卡片 */ -export default function WechatShareBootstrap() { - const location = useLocation(); - - useEffect(() => { - void applyDefaultWechatShare().catch(() => {}); - }, [location.pathname, location.search]); - - return null; -} diff --git a/apps/h5-user/src/contexts/UserSessionContext.tsx b/apps/h5-user/src/contexts/UserSessionContext.tsx deleted file mode 100644 index ad91f8e..0000000 --- a/apps/h5-user/src/contexts/UserSessionContext.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useState, - type ReactNode, -} from 'react'; -import { - bootstrapSession, - clearAuth, - ensureSession, - getDeviceKey, - request, - saveSession, - type SessionPayload, - type UserProfile, -} from '../lib/api'; -import { touchPromoIfNeeded } from '../lib/promo'; - -type UserSessionContextValue = { - ready: boolean; - profile: UserProfile | null; - phoneVerified: boolean; - applySession: (session: SessionPayload) => void; - refreshProfile: () => Promise; - resetSession: () => Promise; -}; - -const UserSessionContext = createContext(null); - -export function UserSessionProvider({ children }: { children: ReactNode }) { - const [ready, setReady] = useState(false); - const [profile, setProfile] = useState(null); - const [phoneVerified, setPhoneVerified] = useState(false); - - const applySession = useCallback((session: SessionPayload) => { - saveSession(session); - if (session.user) setProfile(session.user); - setPhoneVerified(!!session.phoneVerified || !!session.user?.phoneVerified); - }, []); - - const refreshProfile = useCallback(async () => { - const me = await request('USER_H5', '/auth/me'); - setProfile(me); - setPhoneVerified(!!me.phoneVerified); - }, []); - - const resetSession = useCallback(async () => { - clearAuth(); - const session = await bootstrapSession(); - applySession(session); - }, [applySession]); - - useEffect(() => { - let cancelled = false; - (async () => { - try { - const session = await ensureSession(); - if (cancelled) return; - applySession(session); - if (!session.user) { - await refreshProfile(); - } - await touchPromoIfNeeded(); - } catch { - if (!cancelled) { - try { - const session = await bootstrapSession(); - applySession(session); - await refreshProfile(); - } catch { - /* ignore */ - } - } - } finally { - if (!cancelled) setReady(true); - } - })(); - return () => { - cancelled = true; - }; - }, [applySession, refreshProfile]); - - const value = useMemo( - () => ({ - ready, - profile, - phoneVerified, - applySession, - refreshProfile, - resetSession, - }), - [ready, profile, phoneVerified, applySession, refreshProfile, resetSession], - ); - - if (!ready) { - return ( -
-

加载中...

-
- ); - } - - return {children}; -} - -export function useUserSession() { - const ctx = useContext(UserSessionContext); - if (!ctx) throw new Error('useUserSession must be used within UserSessionProvider'); - return ctx; -} - -export function useOptionalUserSession() { - return useContext(UserSessionContext); -} - -/** @deprecated use profile from useUserSession */ -export { getDeviceKey }; diff --git a/apps/h5-user/src/layouts/TabLayout.tsx b/apps/h5-user/src/layouts/TabLayout.tsx deleted file mode 100644 index 86a08b4..0000000 --- a/apps/h5-user/src/layouts/TabLayout.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { NavLink, Outlet } from 'react-router-dom'; - -const TABS = [ - { to: '/', end: true, icon: 'home', label: '首页', fillActive: false }, - { to: '/stores', icon: 'storefront', label: '门店', fillActive: true }, - { to: '/benefit', icon: 'card_giftcard', label: '好客权益', fillActive: false }, - { to: '/mine', icon: 'person', label: '我的', fillActive: true }, -] as const; - -export default function TabLayout() { - return ( - <> - - - - ); -} diff --git a/apps/h5-user/src/lib/analytics.ts b/apps/h5-user/src/lib/analytics.ts deleted file mode 100644 index 06915a3..0000000 --- a/apps/h5-user/src/lib/analytics.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { createUserTracker, getSessionId } from '@dukang/client-logging'; -import { apiBase } from './api'; - -const tracker = createUserTracker({ - apiBase, - clientApp: 'USER_H5', -}); - -export { getSessionId }; - -export function track(eventName: string, params?: Record) { - tracker.track(eventName, params); -} - -export function trackPageView(eventName: string, params?: Record) { - tracker.trackPageView(eventName, params); -} - -export function initUserAnalytics() { - tracker.trackSessionStart(); -} diff --git a/apps/h5-user/src/lib/api.ts b/apps/h5-user/src/lib/api.ts deleted file mode 100644 index e83b58e..0000000 --- a/apps/h5-user/src/lib/api.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { reportApiError } from '@dukang/client-logging'; - -export const BRAND = { - red: '#A02D30', - yellow: '#FFC107', - bg: '#f5f5f5', - text: '#333', - muted: '#999', -}; - -export const apiBase = '/api/v1'; -const CLIENT_APP = 'USER_H5'; - -export type UserProfile = { - id: string; - userNo: string; - phone: string | null; - phoneVerified: boolean; - nickname: string | null; - avatarUrl: string | null; - hasWechat: boolean; -}; - -export type SessionPayload = { - accessToken: string; - refreshToken: string; - deviceKey?: string; - phoneVerified: boolean; - user?: UserProfile; -}; - -const DEVICE_KEY = 'deviceKey'; -const ACCESS_TOKEN = 'accessToken'; -const REFRESH_TOKEN = 'refreshToken'; - -export function getDeviceKey() { - return localStorage.getItem(DEVICE_KEY); -} - -export function saveSession(data: SessionPayload) { - localStorage.setItem(ACCESS_TOKEN, data.accessToken); - localStorage.setItem(REFRESH_TOKEN, data.refreshToken); - if (data.deviceKey) localStorage.setItem(DEVICE_KEY, data.deviceKey); -} - -export function saveAuth(data: { accessToken: string; refreshToken?: string; deviceKey?: string }) { - localStorage.setItem(ACCESS_TOKEN, data.accessToken); - if (data.refreshToken) localStorage.setItem(REFRESH_TOKEN, data.refreshToken); - if (data.deviceKey) localStorage.setItem(DEVICE_KEY, data.deviceKey); -} - -export function clearAuth() { - localStorage.removeItem(ACCESS_TOKEN); - localStorage.removeItem(REFRESH_TOKEN); -} - -export function isLoggedIn() { - return !!localStorage.getItem(ACCESS_TOKEN); -} - -const AUTH_RECOVERY_EXEMPT_PATHS = ['/auth/session/bootstrap', '/auth/token/refresh']; - -async function recoverSession(): Promise { - const refreshed = await refreshSession(); - if (refreshed) return refreshed; - clearAuth(); - return bootstrapSession(); -} - -async function rawRequest( - path: string, - options: RequestInit = {}, - token?: string | null, -): Promise { - const headers: Record = { - 'Content-Type': 'application/json', - 'X-Client-App': CLIENT_APP, - ...(options.headers as Record), - }; - const authToken = token ?? localStorage.getItem(ACCESS_TOKEN); - if (authToken) headers.Authorization = `Bearer ${authToken}`; - - const res = await fetch(`${apiBase}${path}`, { ...options, headers }); - const json = await res.json(); - if (json.code !== 0) { - const err = new Error(json.message || '请求失败') as Error & { status?: number }; - err.status = json.code; - if (json.code === 400) { - reportApiError( - { apiBase, clientApp: CLIENT_APP, getToken: () => localStorage.getItem(ACCESS_TOKEN) }, - { message: json.message || '请求失败', status: 400, url: path, category: 'validation_error' }, - ); - } - throw err; - } - return json.data as T; -} - -async function requestWithAuthRetry( - path: string, - options: RequestInit = {}, - retried = false, -): Promise { - try { - return await rawRequest(path, options); - } catch (e) { - const err = e as Error & { status?: number }; - const canRecover = - err.status === 401 && - !retried && - !AUTH_RECOVERY_EXEMPT_PATHS.some((p) => path.startsWith(p)); - if (!canRecover) throw e; - await recoverSession(); - return requestWithAuthRetry(path, options, true); - } -} - -export async function request( - _clientApp: string, - path: string, - options: RequestInit = {}, -): Promise { - if (!isLoggedIn() && !AUTH_RECOVERY_EXEMPT_PATHS.some((p) => path.startsWith(p))) { - await bootstrapSession(); - } - return requestWithAuthRetry(path, options); -} - -export async function bootstrapSession(): Promise { - const deviceKey = getDeviceKey(); - const data = await rawRequest( - '/auth/session/bootstrap', - { - method: 'POST', - body: JSON.stringify(deviceKey ? { deviceKey } : {}), - }, - null, - ); - saveSession(data); - return data; -} - -export async function refreshSession(): Promise { - const refreshToken = localStorage.getItem(REFRESH_TOKEN); - if (!refreshToken) return null; - try { - const data = await rawRequest( - '/auth/token/refresh', - { - method: 'POST', - body: JSON.stringify({ refreshToken }), - }, - null, - ); - saveSession(data); - return data; - } catch { - return null; - } -} - -export async function ensureSession(): Promise { - if (isLoggedIn()) { - try { - const me = await rawRequest('/auth/me'); - return { - accessToken: localStorage.getItem(ACCESS_TOKEN) ?? '', - refreshToken: localStorage.getItem(REFRESH_TOKEN) ?? '', - deviceKey: getDeviceKey() ?? undefined, - phoneVerified: !!me.phoneVerified, - user: me, - }; - } catch (e) { - const err = e as Error & { status?: number }; - if (err.status === 401) { - clearAuth(); - } else { - const refreshed = await refreshSession(); - if (refreshed) return refreshed; - } - } - } - return bootstrapSession(); -} - -export async function bindPhone(phone: string, code: string): Promise { - if (!isLoggedIn()) { - await bootstrapSession(); - } - const data = await requestWithAuthRetry('/auth/phone/bind', { - method: 'POST', - body: JSON.stringify({ phone, code }), - }); - saveSession(data); - return data; -} diff --git a/apps/h5-user/src/lib/client-location.ts b/apps/h5-user/src/lib/client-location.ts deleted file mode 100644 index 58df920..0000000 --- a/apps/h5-user/src/lib/client-location.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { getWechatLocation } from '@dukang/weixin-sdk'; -import { weixinSdk } from './weixin'; - -export type ClientGpsLocation = { - province?: string; - city?: string; - district?: string; - latitude: number; - longitude: number; - address?: string; -}; - -/** 尝试获取客户端 GPS(微信优先,其次 H5 Geolocation),失败返回 null 不阻塞下单 */ -export async function tryGetClientGpsLocation(): Promise { - const loc = await weixinSdk.getLocation(); - if (!loc) return null; - return { - latitude: loc.latitude, - longitude: loc.longitude, - }; -} - -/** @deprecated 使用 tryGetClientGpsLocation */ -export { getWechatLocation }; diff --git a/apps/h5-user/src/lib/customer-service.ts b/apps/h5-user/src/lib/customer-service.ts deleted file mode 100644 index 7aa6e50..0000000 --- a/apps/h5-user/src/lib/customer-service.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { CUSTOMER_SERVICE_PHONE, CUSTOMER_SERVICE_WECOM_URL } from '@dukang/shared-types'; -import { fetchClientConfig } from './pay-wechat'; -import { isWechatEnv } from './weixin'; - -let cachedPhone = CUSTOMER_SERVICE_PHONE; - -/** 企微客服链接:优先 Vite env,否则 shared-types 默认 */ -export function getCustomerServiceWecomUrl(): string { - const fromEnv = import.meta.env.VITE_CS_WECOM_URL?.trim(); - return fromEnv || CUSTOMER_SERVICE_WECOM_URL; -} - -export function getCustomerServicePhone(): string { - return cachedPhone; -} - -/** 从系统设置拉取客服电话(失败则保持默认常量) */ -export async function loadCustomerServicePhone(): Promise { - try { - const cfg = await fetchClientConfig(); - const phone = cfg.customerServicePhone?.trim(); - if (phone) cachedPhone = phone; - } catch { - /* keep fallback */ - } - return cachedPhone; -} - -/** - * 打开企业微信客服会话(须在微信内;需用户点击手势)。 - * @returns true 已跳转;false 非微信环境已提示 - */ -export function openWecomCustomerService(): boolean { - if (!isWechatEnv()) { - window.alert('请在微信中打开以联系在线客服'); - return false; - } - window.location.href = getCustomerServiceWecomUrl(); - return true; -} - -/** @deprecated 请用 getCustomerServicePhone(),保留兼容旧引用 */ -export { CUSTOMER_SERVICE_PHONE }; diff --git a/apps/h5-user/src/lib/navigation.ts b/apps/h5-user/src/lib/navigation.ts deleted file mode 100644 index d98af2c..0000000 --- a/apps/h5-user/src/lib/navigation.ts +++ /dev/null @@ -1,62 +0,0 @@ -export type CheckoutContext = { - productId?: string | null; - qty?: string | number | null; - addressId?: string | null; - cross?: boolean | string | null; - select?: boolean | string | null; -}; - -export function readCheckoutContext(params: URLSearchParams): CheckoutContext { - return { - productId: params.get('productId'), - qty: params.get('qty'), - addressId: params.get('addressId'), - cross: params.get('cross'), - select: params.get('select'), - }; -} - -export function appendCheckoutContext(qs: URLSearchParams, ctx: CheckoutContext) { - if (ctx.productId) qs.set('productId', ctx.productId); - if (ctx.qty != null && ctx.qty !== '') qs.set('qty', String(ctx.qty)); - if (ctx.addressId) qs.set('addressId', ctx.addressId); - if (ctx.cross === true || ctx.cross === '1') qs.set('cross', '1'); - if (ctx.select === true || ctx.select === '1') qs.set('select', '1'); -} - -export function buildOrderConfirmUrl(search: CheckoutContext) { - const qs = new URLSearchParams(); - if (search.productId) qs.set('productId', search.productId); - if (search.qty != null && search.qty !== '') qs.set('qty', String(search.qty)); - if (search.addressId) qs.set('addressId', search.addressId); - if (search.cross === true || search.cross === '1') qs.set('cross', '1'); - const query = qs.toString(); - return query ? `/order/confirm?${query}` : '/order/confirm'; -} - -export function buildAddressListUrl(ctx: CheckoutContext = {}) { - const qs = new URLSearchParams(); - appendCheckoutContext(qs, ctx); - const query = qs.toString(); - return query ? `/addresses?${query}` : '/addresses'; -} - -export function buildAddressEditUrl(id: string | 'new', ctx: CheckoutContext = {}) { - const path = id === 'new' ? '/addresses/new' : `/addresses/${id}/edit`; - const qs = new URLSearchParams(); - appendCheckoutContext(qs, ctx); - const query = qs.toString(); - return query ? `${path}?${query}` : path; -} - -export function buildProductDetailUrl(productId?: string | null) { - return productId ? `/product/${productId}` : '/'; -} - -export function buildOrderAddressSelectUrl(orderId: string) { - return `/addresses?orderId=${orderId}&select=1`; -} - -export function hasCheckoutContext(ctx: CheckoutContext) { - return Boolean(ctx.productId || ctx.select === true || ctx.select === '1'); -} diff --git a/apps/h5-user/src/lib/order-images.ts b/apps/h5-user/src/lib/order-images.ts deleted file mode 100644 index 822f4cb..0000000 --- a/apps/h5-user/src/lib/order-images.ts +++ /dev/null @@ -1,3 +0,0 @@ -/** Stitch 确认订单页商品缩略图 */ -export const STITCH_ORDER_PRODUCT_IMAGE = - 'https://lh3.googleusercontent.com/aida-public/AB6AXuAfqy5X1jKiMBB-L5amwR3xfLYbFBc_qsPbB9mdQZxWlV3rrOARPFVhLRDlW7r8Ig03O6c_ZJKLcVEsgYCblwKg8FZ4-EWwcc5bMNc3UsmBycu3bZ5E6S_aH9UBv0_nEP0sMD8rJsC_rMYBiGDMvRbd52taX-Ir_sfRiVvQu7ImFV-YvU54iXE2x51naVuR8qxwmK7YKitPClg0Pysga859a2-yiJ_ID0QR5xM2o84QbMwNyOEoDDTKSDNqG6J9jfeTsiIYb5vdVmc'; diff --git a/apps/h5-user/src/lib/pay-wechat.ts b/apps/h5-user/src/lib/pay-wechat.ts deleted file mode 100644 index 85a5bba..0000000 --- a/apps/h5-user/src/lib/pay-wechat.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { ClientRuntimeConfig, WechatLoginResult } from '@dukang/shared-types'; -import { isWxAuthorizeEnabled } from '@dukang/shared-types'; -import { toAppPath } from '@dukang/weixin-sdk'; -import { isWechatEnv, weixinSdk } from './weixin'; -import { request, saveSession, type UserProfile } from './api'; - -const WECHAT_AUTH_REQUIRED = 'WECHAT_AUTH_REQUIRED'; - -export function isWechatAuthRequiredError(err: unknown): boolean { - return err instanceof Error && err.message === WECHAT_AUTH_REQUIRED; -} - -export async function fetchClientConfig(): Promise { - return request('USER_H5', '/common/client-config'); -} - -export async function fetchUserProfile(): Promise { - return request('USER_H5', '/auth/me'); -} - -/** 真实微信支付且未绑定微信时需要授权 */ -export function needsWechatAuthForPay( - config: ClientRuntimeConfig, - profile: UserProfile | null, -): boolean { - if (!isWxAuthorizeEnabled(config)) return false; - return !config.mockPay && config.wechatPayEnabled && isWechatEnv() && !profile?.hasWechat; -} - -export function saveWechatLoginResult(result: WechatLoginResult): boolean { - if (!result.accessToken) return false; - saveSession({ - accessToken: result.accessToken, - refreshToken: result.refreshToken ?? '', - deviceKey: result.deviceKey, - phoneVerified: !!result.phoneVerified, - user: result.user as never, - }); - return true; -} - -export async function authorizeWechatForPay(): Promise { - const config = await fetchClientConfig(); - if (!isWxAuthorizeEnabled(config)) return; - if (!isWechatEnv()) { - throw new Error('请在微信内打开以完成授权'); - } - return weixinSdk.login(); -} - -export function buildLoginReturnUrl(pathname: string, search: string) { - return `${toAppPath('/login')}?return=${encodeURIComponent(`${pathname}${search}`)}`; -} diff --git a/apps/h5-user/src/lib/phone.ts b/apps/h5-user/src/lib/phone.ts deleted file mode 100644 index 0edbaf7..0000000 --- a/apps/h5-user/src/lib/phone.ts +++ /dev/null @@ -1,19 +0,0 @@ -const MOBILE_PHONE_RE = /^1[3-9]\d{9}$/; - -export function normalizePhoneInput(value: string): string { - return value.replace(/\D/g, '').slice(0, 11); -} - -export function validateMobilePhone(phone: string): { ok: boolean; message?: string } { - const trimmed = phone.trim(); - if (!trimmed) { - return { ok: false, message: '请输入手机号码' }; - } - if (trimmed.length !== 11) { - return { ok: false, message: '手机号码须为 11 位' }; - } - if (!MOBILE_PHONE_RE.test(trimmed)) { - return { ok: false, message: '请输入正确的手机号码' }; - } - return { ok: true }; -} diff --git a/apps/h5-user/src/lib/product-images.ts b/apps/h5-user/src/lib/product-images.ts deleted file mode 100644 index 734cb91..0000000 --- a/apps/h5-user/src/lib/product-images.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** 商品无图时的占位图 */ -export const PRODUCT_IMAGE_FALLBACK = '/images/1.png'; - -export type ProductImageSource = { - mainImageUrl?: string | null; - carouselUrls?: string[] | null; - detailImageUrls?: string[] | null; -}; - -function uniqueUrls(urls: Array) { - const seen = new Set(); - const result: string[] = []; - for (const url of urls) { - if (!url || seen.has(url)) continue; - seen.add(url); - result.push(url); - } - return result; -} - -/** 首页/列表轮播图:优先 CAROUSEL,否则封面 */ -export function getProductImages(source?: ProductImageSource | null): string[] { - const carousel = uniqueUrls(source?.carouselUrls ?? []); - if (carousel.length > 0) return carousel; - - const main = source?.mainImageUrl; - if (main) return [main]; - - return [PRODUCT_IMAGE_FALLBACK]; -} - -/** 单张主图:封面优先 */ -export function getProductMainImage(source?: ProductImageSource | null): string { - return source?.mainImageUrl ?? source?.carouselUrls?.[0] ?? PRODUCT_IMAGE_FALLBACK; -} - -/** 详情页顶部轮播 */ -export function getProductCarouselImages(source?: ProductImageSource | null): string[] { - const carousel = uniqueUrls(source?.carouselUrls ?? []); - if (carousel.length > 0) return carousel; - return getProductImages(source); -} - -/** 详情页图文长图 */ -export function getProductDetailImages(source?: ProductImageSource | null): string[] { - return uniqueUrls(source?.detailImageUrls ?? []); -} diff --git a/apps/h5-user/src/lib/promo.ts b/apps/h5-user/src/lib/promo.ts deleted file mode 100644 index bbcdfd2..0000000 --- a/apps/h5-user/src/lib/promo.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { apiBase } from './api'; - -export const PROMO_STORAGE_KEY = 'dukang_promo_code'; -export const PROMO_PID_STORAGE_KEY = 'dukang_promo_pid'; - -function readPromoFromSearch(search: string): { code: string | null; pid: string | null } { - const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search); - const code = params.get('promo')?.trim(); - const pid = params.get('pid')?.trim(); - return { - code: code ? code.toUpperCase() : null, - pid: pid || null, - }; -} - -/** 解析 URL 中的 ?promo= / ?pid= 并写入 sessionStorage */ -export function capturePromoFromUrl(): string | null { - if (typeof window === 'undefined') return null; - let parsed = readPromoFromSearch(window.location.search); - if (!parsed.code && !parsed.pid && window.location.hash.includes('?')) { - const hashQuery = window.location.hash.slice(window.location.hash.indexOf('?')); - parsed = readPromoFromSearch(hashQuery); - } - if (parsed.code) { - sessionStorage.setItem(PROMO_STORAGE_KEY, parsed.code); - } - if (parsed.pid) { - sessionStorage.setItem(PROMO_PID_STORAGE_KEY, parsed.pid); - } - return parsed.code ?? sessionStorage.getItem(PROMO_STORAGE_KEY); -} - -export function getStoredPromoCode(): string | null { - if (typeof window === 'undefined') return null; - return sessionStorage.getItem(PROMO_STORAGE_KEY); -} - -export function getStoredPromoPid(): string | null { - if (typeof window === 'undefined') return null; - return sessionStorage.getItem(PROMO_PID_STORAGE_KEY); -} - -/** 调用 /promo/touch 完成扫码归因(OptionalJwt:未登录也累加 scan_count) */ -export async function touchPromoIfNeeded(): Promise { - const promoCode = getStoredPromoCode(); - const qrcodeId = getStoredPromoPid(); - if (!promoCode && !qrcodeId) return; - - const headers: Record = { - 'Content-Type': 'application/json', - 'X-Client-App': 'USER_H5', - }; - const token = localStorage.getItem('accessToken'); - if (token) headers.Authorization = `Bearer ${token}`; - - try { - const res = await fetch(`${apiBase}/promo/touch`, { - method: 'POST', - headers, - body: JSON.stringify({ - ...(promoCode ? { promoCode } : {}), - ...(qrcodeId ? { qrcodeId } : {}), - }), - }); - const json = await res.json(); - if (json.code !== 0) return; - } catch { - /* 静默失败,不阻断用户流程 */ - } -} diff --git a/apps/h5-user/src/lib/region-data.ts b/apps/h5-user/src/lib/region-data.ts deleted file mode 100644 index 7e3959c..0000000 --- a/apps/h5-user/src/lib/region-data.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { regionData } from 'element-china-area-data'; - -export type RegionTree = Record>; - -/** 由国家标准省市区数据构建三级树 */ -function buildRegionTree(): RegionTree { - const tree: RegionTree = {}; - for (const province of regionData) { - const cities: Record = {}; - for (const city of province.children ?? []) { - cities[city.label] = (city.children ?? []).map((district) => district.label); - } - tree[province.label] = cities; - } - return tree; -} - -export const REGION_TREE: RegionTree = buildRegionTree(); - -export const PROVINCES = Object.keys(REGION_TREE); - -/** 三级选择「全市」选项(省/市/区列表首项) */ -export const REGION_ALL = '全市'; - -export function getCities(province: string): string[] { - if (province === REGION_ALL) return []; - return Object.keys(REGION_TREE[province] ?? {}); -} - -export function getDistricts(province: string, city: string): string[] { - if (province === REGION_ALL || city === REGION_ALL) return []; - return REGION_TREE[province]?.[city] ?? []; -} - -/** 省份列表(含全市) */ -export function getProvincesForPicker(): string[] { - return [...PROVINCES]; -} - -/** 城市列表(含全市) */ -export function getCitiesForPicker(province: string): string[] { - if (province === REGION_ALL) return [REGION_ALL]; - return [...getCities(province)]; -} - -/** 区县列表(含全市) */ -export function getDistrictsForPicker(province: string, city: string): string[] { - if (province === REGION_ALL || city === REGION_ALL) return [REGION_ALL]; - return [REGION_ALL, ...getDistricts(province, city)]; -} - -export function formatRegion(province: string, city: string, district: string): string { - if (!province) return ''; - if (province === REGION_ALL) return REGION_ALL; - if (city === REGION_ALL) return `${province} ${REGION_ALL}`; - if (district === REGION_ALL) return `${province} ${city} ${REGION_ALL}`; - if (!city || !district) return ''; - return `${province} ${city} ${district}`; -} - -/** 仅展示省、市两级(门店列表等场景) */ -export function formatRegionCity(province: string, city: string): string { - if (!province) return ''; - if (province === REGION_ALL) return REGION_ALL; - if (city === REGION_ALL) return `${province} ${REGION_ALL}`; - if (!city) return province; - return `${province} ${city}`; -} - -/** 门店筛选:固定为市级,不按区县过滤 */ -export function toCityLevelRegion(selection: RegionSelection): RegionSelection { - const normalized = normalizeRegionSelection(selection); - return { - province: normalized.province, - city: normalized.city, - district: REGION_ALL, - }; -} - -export type RegionSelection = { - province: string; - city: string; - district: string; -}; - -export const DEFAULT_REGION: RegionSelection = { - province: '河南省', - city: '郑州市', - district: '金水区', -}; - -export const FALLBACK_CITY_REGION: RegionSelection = { - province: '河南省', - city: '郑州市', - district: REGION_ALL, -}; - -export function regionFromGeo(province: string, city: string, district?: string): RegionSelection { - const cityName = city.endsWith('市') ? city : `${city}市`; - const provinceInTree = PROVINCES.includes(province) ? province : DEFAULT_REGION.province; - const cities = getCities(provinceInTree); - const matchedCity = cities.includes(cityName) - ? cityName - : cities.find((c) => c.replace(/市$/, '') === city.replace(/市$/, '')) ?? cityName; - const districts = getDistricts(provinceInTree, matchedCity); - const districtName = - district && districts.includes(district) - ? district - : REGION_ALL; - return normalizeRegionSelection({ - province: provinceInTree, - city: cities.includes(matchedCity) ? matchedCity : matchedCity, - district: districtName, - }); -} - -/** 校验已选地区是否仍存在于数据源中 */ -export function normalizeRegionSelection(selection: RegionSelection): RegionSelection { - if (selection.province === REGION_ALL) { - return { province: REGION_ALL, city: REGION_ALL, district: REGION_ALL }; - } - - const province = PROVINCES.includes(selection.province) - ? selection.province - : DEFAULT_REGION.province; - - if (selection.city === REGION_ALL) { - return { province, city: REGION_ALL, district: REGION_ALL }; - } - - const cities = getCities(province); - const city = cities.includes(selection.city) ? selection.city : (cities[0] ?? DEFAULT_REGION.city); - - if (selection.district === REGION_ALL) { - return { province, city, district: REGION_ALL }; - } - - const districts = getDistricts(province, city); - const district = districts.includes(selection.district) - ? selection.district - : (districts[0] ?? DEFAULT_REGION.district); - - return { province, city, district }; -} diff --git a/apps/h5-user/src/lib/store-images.ts b/apps/h5-user/src/lib/store-images.ts deleted file mode 100644 index e0c3b22..0000000 --- a/apps/h5-user/src/lib/store-images.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** Stitch user/22 门店详情页 — 图集与地图占位 */ -export const STITCH_STORE_GALLERY = [ - 'https://lh3.googleusercontent.com/aida-public/AB6AXuDqN0DeYRsWNcXyfSRec8k2fhjJsqdji3-7zrtegkiEs5lwt3Sx4l79Uzmfys2pnl_gUY_m3Dpy5cAM8HW7JcR8qPtfO2G8YNcZ3x0DGSN1DUPJPq4emVhmIuwmaLEQ944UT9hjpNQsjdqieKV8R-X-2YvSOrsEa74kyfI5UNgRQaGdinhLw6co29ji3F9BRgfgWCQ1KqjotRBC4r9lzWBdeue-xryXvN8jEp_7hjwrBNOOZoIDPnKkpQQwLLpaa7Di6kfEfwzamCg', - 'https://lh3.googleusercontent.com/aida-public/AB6AXuAW3oxOc6XpywVJmpwYxIPBlP32ftPIOUB8JbYcqOAcLg1gbzKIgbDgBaPVUyH0gjdoWa7Hi0u1-NBYBwc5Jd3YpqufVWIou_ySFB2oLXA6T0u7DgUWKhtxbMnqMue-oasf8GlEy_e7-Rh41ZxVFkc30tQVAYz-Psm3CNgfRFqXoHDXCZAZz5ggOFOB2dScURBVN9qp_Ribo4DuE4LARgf19R8eKZR8mbDQdHesLaVl0icifdcQEb75QM6-VqCR3ch9BNKzLJ5hKMM', - 'https://lh3.googleusercontent.com/aida-public/AB6AXuDuv4URDejJ5j26kuBPG2fqmOmI90qQomZki-aHr3MmdF47Pq5HM7tiH68E77rrF0XjeaZjkQ0e39j5gY1-N_981-eguGZn8VIRZI0n6t-f8QVIhAyjL8kg-5ZD2yRsfgw5mnOYYPMyNUI54efLiU4M6mni6nJvTAXMvX0oBMXtTItj5U66d9BIvie7VfHoVYMelEW9ppZsSRzA7ZoIu6aRp_72OwAIcTFuiI2zccaAmfTk7dChjjHIHZ85B8dDc2G8Tym34SuJAZc', -] as const; - -export const STITCH_STORE_MAP = - 'https://lh3.googleusercontent.com/aida-public/AB6AXuByauC4oncButUsGa_t2ntIVz-iPk9zVUnYA6_P_URyFYzrWALFa2TKfdpEyrGs61N_sEjRYksO_HeKCZGJQXfRhXqf1iXrk8JPIfzDwb33bDacTr2J0HM-cSnNjcM1c5l6r_yuzsE0zuLBZpuAWVPwkOJUkdfk6xxNpABh-OQ0B6736YmxFQM-WJ5h0eLHpRB7RuyTFr5c_TwTysKyY6QVDZ-oJrx9Vc3FQE7Pc64o3bzP6qW3GEqdqm4WVIAMKiNKqUcXvkN8E48'; - -export function getStoreGalleryImages(coverUrl?: string | null, media?: Array<{ url: string }>) { - const fromMedia = (media || []).map((m) => m.url).filter(Boolean); - if (fromMedia.length > 0) return fromMedia; - if (coverUrl) return [coverUrl, ...STITCH_STORE_GALLERY.slice(1)]; - return [...STITCH_STORE_GALLERY]; -} diff --git a/apps/h5-user/src/lib/upload.ts b/apps/h5-user/src/lib/upload.ts deleted file mode 100644 index ba9f276..0000000 --- a/apps/h5-user/src/lib/upload.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { apiBase } from './api'; -import { - compressImageFileIfNeeded, - DEFAULT_OSS_MAX_UPLOAD_BYTES, - formatOssMaxSizeMb, -} from '@dukang/shared-ui/compressImage'; - -export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE'; - -export type UploadFileResult = { - url: string; - ossKey: string; - bucket: string; - mock: boolean; -}; - -/** 经 API 服务端转存 OSS */ -export async function uploadFileToOss( - file: File, - options: { bizType: string; mediaType?: OssMediaType }, -): Promise { - const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE'); - let prepared = file; - if (mediaType === 'IMAGE') { - prepared = await compressImageFileIfNeeded(file); - if (prepared.size > DEFAULT_OSS_MAX_UPLOAD_BYTES) { - throw new Error(`图片压缩后仍超过 ${formatOssMaxSizeMb()}MB,请换一张较小的图片`); - } - } - const formData = new FormData(); - formData.append('file', prepared); - formData.append('bizType', options.bizType); - formData.append('mediaType', mediaType); - - const headers: Record = { - 'X-Client-App': 'USER_H5', - }; - const token = localStorage.getItem('accessToken'); - if (token) headers.Authorization = `Bearer ${token}`; - - const res = await fetch(`${apiBase}/common/resources/upload`, { - method: 'POST', - headers, - body: formData, - }); - const json = await res.json(); - if (json.code !== 0) throw new Error(json.message || '上传失败'); - return json.data as UploadFileResult; -} diff --git a/apps/h5-user/src/lib/use-sms-code.ts b/apps/h5-user/src/lib/use-sms-code.ts deleted file mode 100644 index d9c8600..0000000 --- a/apps/h5-user/src/lib/use-sms-code.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import type { SmsScene } from '@dukang/shared-types'; -import { request } from './api'; -import { fetchClientConfig } from './pay-wechat'; -import { validateMobilePhone } from './phone'; - -export function useSmsCode() { - const [codeCooldown, setCodeCooldown] = useState(0); - const [sending, setSending] = useState(false); - const [sentHint, setSentHint] = useState(''); - const [error, setError] = useState(''); - const [mockSms, setMockSms] = useState(true); - const timerRef = useRef | null>(null); - - useEffect(() => { - fetchClientConfig() - .then((cfg) => setMockSms(cfg.mockSms)) - .catch(() => {}); - return () => { - if (timerRef.current) clearInterval(timerRef.current); - }; - }, []); - - const startCooldown = useCallback(() => { - setCodeCooldown(60); - if (timerRef.current) clearInterval(timerRef.current); - timerRef.current = setInterval(() => { - setCodeCooldown((c) => { - if (c <= 1) { - if (timerRef.current) clearInterval(timerRef.current); - return 0; - } - return c - 1; - }); - }, 1000); - }, []); - - const sendCode = useCallback( - async (phone: string, scene: SmsScene) => { - const phoneCheck = validateMobilePhone(phone); - if (!phoneCheck.ok) { - setError(phoneCheck.message ?? '请输入正确的手机号码'); - return false; - } - setSending(true); - setError(''); - setSentHint(''); - try { - await request('USER_H5', '/auth/sms/send', { - method: 'POST', - body: JSON.stringify({ phone, scene }), - }); - setSentHint(mockSms ? '验证码已发送(开发模式)' : '验证码已发送,请注意查收'); - startCooldown(); - return true; - } catch (e) { - setError(e instanceof Error ? e.message : '发送失败'); - return false; - } finally { - setSending(false); - } - }, - [mockSms, startCooldown], - ); - - const clearMessages = useCallback(() => { - setError(''); - setSentHint(''); - }, []); - - return { - sendCode, - sending, - codeCooldown, - sentHint, - error, - setError, - clearMessages, - mockSms, - }; -} diff --git a/apps/h5-user/src/lib/usePageView.ts b/apps/h5-user/src/lib/usePageView.ts deleted file mode 100644 index d3e9fd7..0000000 --- a/apps/h5-user/src/lib/usePageView.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { trackPageView } from './analytics'; - -export function usePageView(eventName: string, params?: Record) { - const fired = useRef(false); - useEffect(() => { - if (fired.current) return; - fired.current = true; - trackPageView(eventName, params); - }, [eventName, params]); -} diff --git a/apps/h5-user/src/lib/wechat-auth.ts b/apps/h5-user/src/lib/wechat-auth.ts deleted file mode 100644 index ba5fb7b..0000000 --- a/apps/h5-user/src/lib/wechat-auth.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { WechatLoginResult } from '@dukang/shared-types'; -import { isWxAuthorizeEnabled } from '@dukang/shared-types'; -import { isWechatEnv, weixinSdk } from './weixin'; -import { - authorizeWechatForPay, - fetchClientConfig, - fetchUserProfile, - needsWechatAuthForPay, - saveWechatLoginResult, -} from './pay-wechat'; - -export type WechatAuthEnsureResult = - | { ok: true } - | { ok: false; redirecting: true } - | { ok: false; needBindPhone: true; wxSessionKey: string }; - -export async function checkNeedsWechatAuth(): Promise { - const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]); - return needsWechatAuthForPay(config, profile); -} - -/** 真实微信支付前确保已绑定微信;OAuth 跳转时返回 redirecting */ -export async function ensureWechatAuthForPay(): Promise { - if (!isWechatEnv()) return { ok: true }; - if (!(await checkNeedsWechatAuth())) return { ok: true }; - - const result = await authorizeWechatForPay(); - if (!result) return { ok: false, redirecting: true }; - - if (result.needBindPhone && result.wxSessionKey) { - return { ok: false, needBindPhone: true, wxSessionKey: result.wxSessionKey }; - } - - if (saveWechatLoginResult(result)) { - return { ok: true }; - } - return { ok: false, redirecting: true }; -} - -export async function handleWechatAuthCallback(): Promise { - if (!isWechatEnv()) return null; - const config = await fetchClientConfig(); - if (!isWxAuthorizeEnabled(config)) return null; - return weixinSdk.handleOAuthCallback(); -} - -export async function loginWithWechatSdk(): Promise { - const config = await fetchClientConfig(); - if (!isWxAuthorizeEnabled(config)) return; - if (!isWechatEnv()) { - throw new Error('请在微信内打开以使用微信一键授权'); - } - return weixinSdk.login(); -} - -export function applyWechatLoginResult(result: WechatLoginResult): boolean { - return saveWechatLoginResult(result); -} diff --git a/apps/h5-user/src/lib/wechat-location.ts b/apps/h5-user/src/lib/wechat-location.ts deleted file mode 100644 index d47f26e..0000000 --- a/apps/h5-user/src/lib/wechat-location.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { getWechatLocationDetailed } from '@dukang/weixin-sdk'; -import { apiBase } from './api'; -import { weixinSdk } from './weixin'; -import { regionFromGeo, type RegionSelection } from './region-data'; - -export const GPS_CITY_STORAGE_KEY = 'dukang_gps_city'; -export const CITY_STORAGE_KEY = 'dukang_selected_city'; -export const FALLBACK_CITY_CODE = '410100'; - -export type ResolvedUserCity = { - province: string; - city: string; - district: string; - cityCode?: string; - cityName?: string; - openCity: boolean; - region: RegionSelection; - displayCity: string; -}; - -type GpsCityCache = ResolvedUserCity & { timestamp: number }; - -function readCache(): GpsCityCache | null { - try { - const raw = sessionStorage.getItem(GPS_CITY_STORAGE_KEY); - if (!raw) return null; - const parsed = JSON.parse(raw) as GpsCityCache; - if (Date.now() - parsed.timestamp > 30 * 60 * 1000) return null; - return parsed; - } catch { - return null; - } -} - -function writeCache(data: ResolvedUserCity) { - sessionStorage.setItem( - GPS_CITY_STORAGE_KEY, - JSON.stringify({ ...data, timestamp: Date.now() } satisfies GpsCityCache), - ); -} - -async function reportLocationToServer(payload: { - latitude?: number; - longitude?: number; - sdk: 'jssdk' | 'geolocation'; - status: 'success' | 'fail'; - errMsg?: string; -}) { - const token = localStorage.getItem('accessToken'); - const headers: Record = { - 'Content-Type': 'application/json', - 'X-Client-App': 'USER_H5', - }; - if (token) headers.Authorization = `Bearer ${token}`; - - const res = await fetch(`${apiBase}/common/wechat/location`, { - method: 'POST', - headers, - body: JSON.stringify(payload), - }); - const json = await res.json(); - if (json.code !== 0) { - throw new Error(json.message || '定位上报失败'); - } - return json.data as { - province?: string; - city?: string; - district?: string; - cityCode?: string; - cityName?: string; - openCity?: boolean; - }; -} - -function toResolved(data: { - province?: string; - city?: string; - district?: string; - cityCode?: string; - cityName?: string; - openCity?: boolean; -}): ResolvedUserCity | null { - if (!data.province || !data.city) return null; - const region = regionFromGeo(data.province, data.city, data.district); - const displayCity = data.cityName ?? (data.city.endsWith('市') ? data.city : `${data.city}市`); - return { - province: data.province, - city: data.city, - district: data.district ?? '', - cityCode: data.cityCode, - cityName: data.cityName, - openCity: !!data.openCity, - region, - displayCity, - }; -} - -/** 获取并解析用户当前城市(微信 JSSDK 优先),失败返回 null */ -export async function resolveUserCity(force = false): Promise { - if (!force) { - const cached = readCache(); - if (cached) return cached; - } - - const outcome = await getWechatLocationDetailed({ - apiBase, - clientApp: 'USER_H5', - getAccessToken: () => localStorage.getItem('accessToken'), - }); - - if (!outcome.location) { - await reportLocationToServer({ - sdk: outcome.sdk, - status: 'fail', - errMsg: outcome.errMsg, - }).catch(() => {}); - return null; - } - - try { - const data = await reportLocationToServer({ - latitude: outcome.location.latitude, - longitude: outcome.location.longitude, - sdk: outcome.sdk, - status: 'success', - }); - const resolved = toResolved(data); - if (resolved) { - writeCache(resolved); - if (resolved.openCity && resolved.cityCode) { - localStorage.setItem(CITY_STORAGE_KEY, resolved.cityCode); - } - } - return resolved; - } catch { - return null; - } -} - -export function syncCityCodeFromGps(resolved: ResolvedUserCity) { - if (resolved.openCity && resolved.cityCode) { - localStorage.setItem(CITY_STORAGE_KEY, resolved.cityCode); - } -} diff --git a/apps/h5-user/src/lib/wechat-share.ts b/apps/h5-user/src/lib/wechat-share.ts deleted file mode 100644 index 8ff447d..0000000 --- a/apps/h5-user/src/lib/wechat-share.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { WechatShareData } from '@dukang/weixin-sdk'; -import { getWechatShareLink, toAppPath } from '@dukang/weixin-sdk'; -import { isWechatEnv, weixinSdk } from './weixin'; - -export const DEFAULT_SHARE_TITLE = '你吃饭,杜康买单'; -export const DEFAULT_SHARE_DESC = '杜康好客 · 买酒享权益,全城门店可用'; -export const WECHAT_SHARE_HINT = '请点击右上角 ··· 分享给好友'; - -export function getDefaultShareImageUrl(): string { - if (typeof window === 'undefined') return toAppPath('/logo.png'); - return new URL(toAppPath('/logo.png'), window.location.origin).href; -} - -export function buildDefaultShareData( - overrides?: Partial, -): WechatShareData { - return { - title: overrides?.title ?? DEFAULT_SHARE_TITLE, - desc: overrides?.desc ?? DEFAULT_SHARE_DESC, - link: overrides?.link ?? getWechatShareLink(), - imgUrl: overrides?.imgUrl ?? getDefaultShareImageUrl(), - }; -} - -export async function applyDefaultWechatShare( - overrides?: Partial, -): Promise { - if (!isWechatEnv()) return; - await weixinSdk.setShare(buildDefaultShareData(overrides)); -} - -export function handleShareButtonClick(onHint: (message: string) => void): void { - const showHint = (message: string) => { - onHint(message); - if (message) { - window.setTimeout(() => onHint(''), 2500); - } - }; - - if (!isWechatEnv()) { - showHint('请在微信内打开后分享'); - return; - } - void applyDefaultWechatShare() - .then(() => showHint(WECHAT_SHARE_HINT)) - .catch(() => showHint('分享配置失败,请刷新页面后重试')); -} diff --git a/apps/h5-user/src/lib/weixin.ts b/apps/h5-user/src/lib/weixin.ts deleted file mode 100644 index f957657..0000000 --- a/apps/h5-user/src/lib/weixin.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { createWeixinSdk, isWechatEnv } from '@dukang/weixin-sdk'; - -const CLIENT_APP = 'USER_H5'; - -export const weixinSdk = createWeixinSdk({ - apiBase: '/api/v1', - clientApp: CLIENT_APP, - getAccessToken: () => localStorage.getItem('accessToken'), -}); - -export { isWechatEnv }; diff --git a/apps/h5-user/src/main.tsx b/apps/h5-user/src/main.tsx deleted file mode 100644 index 53d311f..0000000 --- a/apps/h5-user/src/main.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import { BrowserRouter } from 'react-router-dom'; -import { getRouterBasename } from '@dukang/weixin-sdk'; -import { installClientErrorReporting } from '@dukang/client-logging'; -import App from './App'; -import { initUserAnalytics } from './lib/analytics'; -import { apiBase } from './lib/api'; -import './styles.css'; -import './styles/legal.css'; - -installClientErrorReporting({ apiBase, clientApp: 'USER_H5' }); -initUserAnalytics(); - -ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - , -); diff --git a/apps/h5-user/src/pages/AddressEditPage.tsx b/apps/h5-user/src/pages/AddressEditPage.tsx deleted file mode 100644 index 62e3ce3..0000000 --- a/apps/h5-user/src/pages/AddressEditPage.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import { useEffect, useState } from 'react'; -import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; -import SubPageHeader from '../components/SubPageHeader'; -import RegionPicker from '../components/RegionPicker'; -import { request } from '../lib/api'; -import { buildAddressListUrl, readCheckoutContext } from '../lib/navigation'; -import { DEFAULT_REGION, formatRegion } from '../lib/region-data'; -import { normalizePhoneInput, validateMobilePhone } from '../lib/phone'; -import { usePageView } from '../lib/usePageView'; - -type AddressForm = { - receiverName: string; - phone: string; - province: string; - city: string; - district: string; - detail: string; - isDefault: boolean; -}; - -export default function AddressEditPage() { - const { id } = useParams(); - const [params] = useSearchParams(); - const isEdit = Boolean(id); - usePageView('address_edit', { mode: isEdit ? 'edit' : 'create' }); - const navigate = useNavigate(); - const [pickerOpen, setPickerOpen] = useState(false); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(''); - const [form, setForm] = useState({ - receiverName: '', - phone: '', - province: DEFAULT_REGION.province, - city: params.get('city') || DEFAULT_REGION.city, - district: DEFAULT_REGION.district, - detail: '', - isDefault: true, - }); - - const checkoutCtx = readCheckoutContext(params); - - function goBackToList() { - navigate(buildAddressListUrl(checkoutCtx)); - } - - useEffect(() => { - if (!id) return; - request>>('USER_H5', '/user/addresses').then((list) => { - const found = list.find((a) => String(a.id) === id); - if (found) { - setForm({ - receiverName: String(found.receiverName), - phone: String(found.phone), - province: String(found.province), - city: String(found.city), - district: String(found.district), - detail: String(found.detail), - isDefault: found.isDefault === 1, - }); - } - }); - }, [id]); - - const regionText = formatRegion(form.province, form.city, form.district); - - function validateForm(): string | null { - if (!form.receiverName.trim()) return '请输入收货人姓名'; - const phoneCheck = validateMobilePhone(form.phone); - if (!phoneCheck.ok) return phoneCheck.message ?? '请输入正确的手机号码'; - if (!form.province || !form.city || !form.district) return '请选择所在地区'; - if (!form.detail.trim()) return '请输入详细地址'; - return null; - } - - async function save() { - const validationError = validateForm(); - if (validationError) { - setError(validationError); - return; - } - - setSaving(true); - setError(''); - try { - if (isEdit && id) { - await request('USER_H5', `/user/addresses/${id}`, { - method: 'PUT', - body: JSON.stringify(form), - }); - } else { - await request('USER_H5', '/user/addresses', { - method: 'POST', - body: JSON.stringify(form), - }); - } - navigate(buildAddressListUrl(checkoutCtx)); - } catch (e) { - setError(e instanceof Error ? e.message : '保存失败'); - } finally { - setSaving(false); - } - } - - return ( -
- - -
-
-
- -
- { - setForm({ ...form, receiverName: e.target.value }); - setError(''); - }} - /> - person -
-
- -
- -
- +86 - { - setForm({ ...form, phone: normalizePhoneInput(e.target.value) }); - setError(''); - }} - /> - smartphone -
-
- - - -
- -
-