Files
wechat_wc/src/pages/shop/detail/index.tsx
T
fenjoyoung 8fdf0b1b4f feat(r3-order-pay): 结算与订单支付流程、支付倒计时/商品图组件,接口地址改为构建期注入
- 新增 PaymentCountdown、ProductImage 组件
- 完善 checkout/orders/orderDetail 订单支付链路与地址、商品、设计数据
- request 接口地址改为构建期注入并保留真实网络错误,默认兜底线上地址
- 同步重新构建 dist 产物
2026-09-13 14:50:39 +08:00

313 lines
11 KiB
TypeScript

import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState, useEffect, useCallback, useMemo } from 'react'
import './index.scss'
import { PRODUCTS } from '../../../utils/productConfig'
import { fetchProduct } from '../../../utils/api/product'
import { ProductCategory } from '../../../types'
import { useSafeArea } from '../../../hooks/useSafeArea'
import { useStatusBar } from '../../../hooks/useStatusBar'
import { useThemeContext } from '../../../context/ThemeContext'
import ThemedPageMeta from '../../../components/ThemedPageMeta'
import ScrollTopMask from '../../../components/ScrollTopMask'
import BottomActionBar from '../../../components/BottomActionBar'
import ProductImage from '../../../components/ProductImage'
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max)
const lerp = (a: number, b: number, t: number) => a + (b - a) * t
const easeOut = (t: number) => 1 - Math.pow(1 - t, 3)
export default function ShopDetailPage() {
const { resolvedTheme } = useThemeContext()
const [winHeight, setWinHeight] = useState(667)
const [progress, setProgress] = useState(0)
const safe = useSafeArea()
// 顶部为沉浸式商品图,状态栏始终使用白字。
useStatusBar(resolvedTheme, { mode: 'light' })
const routerParams = Taro.getCurrentInstance().router
const routerParamsValues = routerParams ? routerParams.params : undefined
const productId = routerParamsValues ? routerParamsValues.id : ''
const [product, setProduct] = useState<ProductCategory | undefined>(PRODUCTS.find(p => p.id === productId))
useEffect(() => {
if (!productId) return
fetchProduct(productId).then(setProduct).catch(() => { /* 保留本地兜底 */ })
}, [productId])
useEffect(() => {
try {
const info = Taro.getSystemInfoSync()
setWinHeight(info.windowHeight)
} catch (e) {
// fallback to safe default
}
}, [])
const handleScroll = useCallback((e: any) => {
const st = (e.detail && e.detail.scrollTop) || 0
const total = winHeight * 1.2
const p = clamp(st / total, 0, 1)
setProgress(p)
}, [winHeight])
/* Scroll-progress → multi-stage hero & content animations */
const heroStyles = useMemo(() => {
const p = progress
// 1. Image crop insets (vh %)
let topCrop = 0, bottomCrop = 0
if (p <= 0.4) {
const t = easeOut(p / 0.4)
topCrop = lerp(0, 32, t)
bottomCrop = lerp(0, 38, t)
} else if (p <= 0.55) {
const t = (p - 0.4) / 0.15
topCrop = lerp(32, 34, t)
bottomCrop = lerp(38, 41, t)
} else {
topCrop = 34; bottomCrop = 41
}
// 2. Hero translateY
let translateY = 0
if (p <= 0.4) translateY = 0
else if (p <= 0.7) translateY = lerp(0, -75, (p - 0.4) / 0.3)
else translateY = -75
// 3. Hero bottom info opacity (always visible on open, fades after scroll)
let heroInfoOpacity = 1
if (p <= 0.35) heroInfoOpacity = 1
else if (p <= 0.48) heroInfoOpacity = clamp(1 - (p - 0.35) / 0.13, 0, 1)
else heroInfoOpacity = 0
// 4. Transition band opacity
let bandOpacity = 0
if (p <= 0.55) bandOpacity = 0
else if (p <= 0.7) bandOpacity = (p - 0.55) / 0.15
else bandOpacity = 1
// 5. Content reveal
let contentOpacity = 1, contentOffset = 0
if (p <= 0.05) { contentOpacity = 0; contentOffset = 60 }
else if (p <= 0.22) {
const t = (p - 0.05) / 0.17
contentOpacity = easeOut(t)
contentOffset = lerp(60, 0, easeOut(t))
}
// 6. Back button (fade out as hero scrolls)
let backBtnOpacity = 1
if (p <= 0.15) backBtnOpacity = clamp(1 - p / 0.15, 0, 1)
return {
topCrop,
bottomCrop,
translateY,
heroInfoOpacity,
bandOpacity,
contentOpacity,
contentOffset,
backBtnOpacity,
visibleHeight: 100 - topCrop - bottomCrop
}
}, [progress])
const tone = useMemo(() => (product ? product.tone : [28, 22, 18]), [product])
if (!product) {
return (
<View className="shop-detail-page theme-dark">
<ThemedPageMeta darkTop />
<ScrollTopMask title="商品详情" targetSelector=".shop-back-btn" showBack />
<Text style={{ color: '#fff', padding: '40rpx' }}>商品不存在</Text>
</View>
)
}
return (
<View className="shop-detail-page theme-dark">
<ThemedPageMeta darkTop />
<ScrollTopMask title="商品详情" targetSelector=".shop-back-btn" showBack />
{/* Fixed Hero */}
<View className="shop-hero-fixed">
<View
className="shop-hero-clip"
style={{
height: `${winHeight * heroStyles.visibleHeight / 100}px`,
overflow: 'hidden',
transform: `translateY(${winHeight * heroStyles.translateY / 100}px)`
}}
>
<ProductImage
className="shop-hero-img"
src={(product.images && product.images[0]) || product.iconImg || ''}
fallback={product.iconImg || product.icon}
mode="aspectFill"
style={{
width: '100%',
height: `${winHeight}px`,
position: 'absolute',
top: `${-winHeight * heroStyles.topCrop / 100}px`,
left: 0
}}
/>
</View>
{/* Bottom gradient overlay driven by product tone */}
<View
className="shop-hero-overlay"
style={{
background: `linear-gradient(to bottom, rgba(${tone[0]}, ${tone[1]}, ${tone[2]}, 0) 0%, rgba(${tone[0]}, ${tone[1]}, ${tone[2]}, 0.55) 40%, rgba(${tone[0]}, ${tone[1]}, ${tone[2]}, 0.95) 100%)`
}}
/>
<View
className="shop-hero-info"
style={{ opacity: heroStyles.heroInfoOpacity }}
>
<Text className="shop-hero-title">{product.name}</Text>
<Text className="shop-hero-subtitle">{product.subtitle || product.desc}</Text>
<View className="shop-hero-price-row">
<Text className="shop-hero-price">¥{product.price}</Text>
{product.originalPrice && product.originalPrice > product.price && (
<Text className="shop-hero-original">¥{product.originalPrice}</Text>
)}
</View>
</View>
</View>
{/* Scrollable content sits above the fixed hero */}
<ScrollView
key={product.id}
className="shop-scroll"
scrollY
scrollEventThrottle={16}
style={{ height: `${winHeight}px` }}
onScroll={handleScroll}
>
{/* Transparent spacer pushes content below hero */}
<View style={{ height: `${winHeight * 0.65}px` }} />
<View
className="shop-content"
style={{ backgroundColor: `rgb(${tone[0]}, ${tone[1]}, ${tone[2]})` }}
>
{/* Transition band — appears as hero scrolls away */}
<View
className="shop-band"
style={{
top: `${safe.statusBarHeight}px`,
opacity: heroStyles.bandOpacity,
backgroundColor: `rgba(${tone[0]}, ${tone[1]}, ${tone[2]}, 0.88)`,
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)'
}}
>
<Text className="shop-band-title">{product.name}</Text>
<Text className="shop-band-subtitle">{product.subtitle || product.desc}</Text>
<View className="shop-band-price-row">
<Text className="shop-band-price">¥{product.price}</Text>
{product.originalPrice && product.originalPrice > product.price && (
<Text className="shop-band-original">¥{product.originalPrice}</Text>
)}
</View>
</View>
{/* Main copy blocks */}
<View
className="shop-detail-body"
style={{
opacity: heroStyles.contentOpacity,
transform: `translateY(${heroStyles.contentOffset}px)`
}}
>
<View className="shop-content-card">
{product.story && (
<View className="shop-section">
<Text className="shop-section-title">设计理念</Text>
<Text className="shop-section-text">{product.story}</Text>
</View>
)}
{(product.story && product.scene) && (
<View className="shop-divider" />
)}
{product.scene && (
<View className="shop-section">
<Text className="shop-section-title">使用场景</Text>
<Text className="shop-section-text">{product.scene}</Text>
</View>
)}
{product.tags && product.tags.length > 0 && (
<View className="shop-section">
<View className="shop-tag-row">
{product.tags.map(tag => (
<View key={tag} className="shop-tag">
<Text className="shop-tag-text">{tag}</Text>
</View>
))}
</View>
</View>
)}
{((product.story || product.scene) && product.tags && product.tags.length > 0) && (
<View className="shop-divider" />
)}
{((product.story || product.scene || (product.tags && product.tags.length > 0)) && product.specs && product.specs.length > 0) && (
<View className="shop-divider" />
)}
{product.specs && product.specs.length > 0 && (
<View className="shop-section">
<Text className="shop-section-title">规格参数</Text>
<View className="shop-spec-table">
{product.specs.map(([k, v]) => (
<View key={k} className="shop-spec-row">
<Text className="shop-spec-key">{k}</Text>
<Text className="shop-spec-val">{v}</Text>
</View>
))}
</View>
</View>
)}
</View>
<View className="shop-bottom-spacer" />
</View>
</View>
</ScrollView>
{/* Back button — glass morphism circle */}
<View
className="shop-back-btn"
onTap={() => Taro.navigateBack()}
style={{ top: `${safe.menuButtonTop}px`, opacity: heroStyles.backBtnOpacity }}
>
<Text className="shop-back-arrow"></Text>
</View>
{/* Bottom CTA — 复用毛玻璃 BottomActionBar */}
<BottomActionBar>
<View className="price-summary">
<Text className="summary-label">起价</Text>
<Text className="summary-price">¥{product.price}</Text>
{product.originalPrice && product.originalPrice > product.price && (
<Text className="summary-original">¥{product.originalPrice}</Text>
)}
</View>
<View className="btn-secondary" onTap={() => Taro.navigateBack()}>
<Text>返回</Text>
</View>
<View className="btn-primary" onTap={() => Taro.navigateTo({ url: `/pages/product/index?id=${product.id}` })}>
<Text>立即定制</Text>
</View>
</BottomActionBar>
<View className="safe-bottom-placeholder" />
</View>
)
}