商品浏览页构建上传1.1.0版本
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
import { View, Text, Image, ScrollView } from '@tarojs/components'
|
||||
import Taro, { hideTabBar, showTabBar } from '@tarojs/taro'
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import './index.scss'
|
||||
import { PRODUCTS } from '../../utils/productConfig'
|
||||
import { ProductCategory } from '../../types'
|
||||
import { useThemeContext } from '../../context/ThemeContext'
|
||||
|
||||
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 ShopPage() {
|
||||
const { theme } = useThemeContext()
|
||||
const [winHeight, setWinHeight] = useState(667)
|
||||
const [showDetail, setShowDetail] = useState(false)
|
||||
const [detailVisible, setDetailVisible] = useState(false)
|
||||
const [activeProduct, setActiveProduct] = useState<ProductCategory | null>(null)
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const info = Taro.getSystemInfoSync()
|
||||
setWinHeight(info.windowHeight)
|
||||
} catch (e) {
|
||||
// fallback to safe default
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (showDetail && activeProduct) {
|
||||
hideTabBar({ animation: true }).catch(() => {})
|
||||
} else {
|
||||
showTabBar({ animation: true }).catch(() => {})
|
||||
}
|
||||
}, [showDetail, activeProduct])
|
||||
|
||||
const openDetail = useCallback((product: ProductCategory) => {
|
||||
setActiveProduct(product)
|
||||
setShowDetail(true)
|
||||
setProgress(0)
|
||||
// Slight delay so CSS transition picks up from display:block
|
||||
setTimeout(() => setDetailVisible(true), 50)
|
||||
}, [])
|
||||
|
||||
const closeDetail = useCallback(() => {
|
||||
setDetailVisible(false)
|
||||
setTimeout(() => {
|
||||
setShowDetail(false)
|
||||
setActiveProduct(null)
|
||||
setProgress(0)
|
||||
}, 350)
|
||||
}, [])
|
||||
|
||||
const goToProduct = useCallback(() => {
|
||||
if (!activeProduct) return
|
||||
Taro.navigateTo({ url: `/pages/product/index?id=${activeProduct.id}` })
|
||||
}, [activeProduct])
|
||||
|
||||
const handleScroll = useCallback((e: any) => {
|
||||
const st = 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(() => activeProduct?.tone || [28, 22, 18], [activeProduct])
|
||||
|
||||
return (
|
||||
<View className={`shop-page theme-${theme}`}>
|
||||
{/* ============================================================
|
||||
LIST LAYER — always in DOM so scroll position is preserved
|
||||
============================================================ */}
|
||||
<View className="shop-list">
|
||||
<View className="shop-header">
|
||||
<Text className="shop-header-title">智绘精选</Text>
|
||||
<Text className="shop-header-sub">激光微雕定制好物</Text>
|
||||
</View>
|
||||
|
||||
<View className="shop-grid">
|
||||
{PRODUCTS.map(product => (
|
||||
<View
|
||||
key={product.id}
|
||||
className="shop-card"
|
||||
onTap={() => openDetail(product)}
|
||||
>
|
||||
<View className="shop-card-img-wrap">
|
||||
<Image
|
||||
className="shop-card-img"
|
||||
src={product.images?.[0] || product.iconImg || ''}
|
||||
mode="aspectFill"
|
||||
lazyLoad
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
className="shop-card-info"
|
||||
style={{
|
||||
backgroundColor: `rgba(${product.tone?.[0] || 28}, ${product.tone?.[1] || 22}, ${product.tone?.[2] || 18}, 0.92)`
|
||||
}}
|
||||
>
|
||||
<Text className="shop-card-name">{product.name}</Text>
|
||||
<Text className="shop-card-price">¥{product.price}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* TabBar spacer so list isn't obscured */}
|
||||
<View style={{ height: '160px' }} />
|
||||
</View>
|
||||
|
||||
{/* ============================================================
|
||||
DETAIL LAYER — fixed overlay, single-page dual-container
|
||||
============================================================ */}
|
||||
{activeProduct && (
|
||||
<View
|
||||
className={`shop-detail-view ${detailVisible ? 'show' : ''}`}
|
||||
style={{ display: showDetail ? 'block' : 'none' }}
|
||||
>
|
||||
{/* 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)`
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
className="shop-hero-img"
|
||||
src={activeProduct.images?.[0] || activeProduct.iconImg || ''}
|
||||
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">{activeProduct.name}</Text>
|
||||
<Text className="shop-hero-subtitle">{activeProduct.subtitle || activeProduct.desc}</Text>
|
||||
<View className="shop-hero-price-row">
|
||||
<Text className="shop-hero-price">¥{activeProduct.price}</Text>
|
||||
{activeProduct.originalPrice && activeProduct.originalPrice > activeProduct.price && (
|
||||
<Text className="shop-hero-original">¥{activeProduct.originalPrice}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Scrollable content sits above the fixed hero */}
|
||||
<ScrollView
|
||||
key={activeProduct.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={{
|
||||
opacity: heroStyles.bandOpacity,
|
||||
backgroundColor: `rgba(${tone[0]}, ${tone[1]}, ${tone[2]}, 0.88)`,
|
||||
backdropFilter: 'blur(12px)',
|
||||
WebkitBackdropFilter: 'blur(12px)'
|
||||
}}
|
||||
>
|
||||
<Text className="shop-band-title">{activeProduct.name}</Text>
|
||||
<Text className="shop-band-subtitle">{activeProduct.subtitle || activeProduct.desc}</Text>
|
||||
<View className="shop-band-price-row">
|
||||
<Text className="shop-band-price">¥{activeProduct.price}</Text>
|
||||
{activeProduct.originalPrice && activeProduct.originalPrice > activeProduct.price && (
|
||||
<Text className="shop-band-original">¥{activeProduct.originalPrice}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Main copy blocks */}
|
||||
<View
|
||||
className="shop-detail-body"
|
||||
style={{
|
||||
opacity: heroStyles.contentOpacity,
|
||||
transform: `translateY(${heroStyles.contentOffset}px)`
|
||||
}}
|
||||
>
|
||||
{activeProduct.story && (
|
||||
<View className="shop-section">
|
||||
<Text className="shop-section-title">设计理念</Text>
|
||||
<Text className="shop-section-text">{activeProduct.story}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className="shop-divider" />
|
||||
|
||||
{activeProduct.scene && (
|
||||
<View className="shop-section">
|
||||
<Text className="shop-section-title">使用场景</Text>
|
||||
<Text className="shop-section-text">{activeProduct.scene}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{activeProduct.tags && activeProduct.tags.length > 0 && (
|
||||
<View className="shop-section">
|
||||
<View className="shop-tag-row">
|
||||
{activeProduct.tags.map(tag => (
|
||||
<View key={tag} className="shop-tag">
|
||||
<Text className="shop-tag-text">{tag}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className="shop-divider" />
|
||||
|
||||
{activeProduct.specs && activeProduct.specs.length > 0 && (
|
||||
<View className="shop-section">
|
||||
<Text className="shop-section-title">规格参数</Text>
|
||||
<View className="shop-spec-table">
|
||||
{activeProduct.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 className="shop-bottom-spacer" />
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Back button — glass morphism circle */}
|
||||
<View
|
||||
className="shop-back-btn"
|
||||
onTap={closeDetail}
|
||||
style={{ opacity: heroStyles.backBtnOpacity }}
|
||||
>
|
||||
<Text className="shop-back-arrow">←</Text>
|
||||
</View>
|
||||
|
||||
{/* Bottom CTA — fixed, glass, links to existing product page */}
|
||||
<View
|
||||
className="shop-cta-bar"
|
||||
style={{ backgroundColor: `rgba(${tone[0]}, ${tone[1]}, ${tone[2]}, 0.92)` }}
|
||||
>
|
||||
<View className="shop-cta-price-col">
|
||||
<Text className="shop-cta-price">¥{activeProduct.price}</Text>
|
||||
{activeProduct.originalPrice && activeProduct.originalPrice > activeProduct.price && (
|
||||
<Text className="shop-cta-original">¥{activeProduct.originalPrice}</Text>
|
||||
)}
|
||||
</View>
|
||||
<View className="shop-cta-btn" onTap={goToProduct}>
|
||||
<Text className="shop-cta-btn-text">立即定制</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user