x
This commit is contained in:
1 parent
913cbef765
commit
173c583787
22 files changed
+2847
-364
No files matched your search
@@ -33,9 +33,11 @@ function resolveOptionValue(val: string, options: any[] | null | undefined, lang
|
||||
|
||||
interface Props {
|
||||
product: Product
|
||||
showBrandMeta?: boolean
|
||||
showMobileStickyBar?: boolean
|
||||
}
|
||||
|
||||
export default function AddToCartSection({ product }: Props) {
|
||||
export default function AddToCartSection({ product, showBrandMeta = true, showMobileStickyBar = true }: Props) {
|
||||
const [selectedOptions, setSelectedOptions] = useState<string[]>(() => {
|
||||
if (product.variants && product.variants.length > 0) {
|
||||
const first = product.variants[0];
|
||||
@@ -195,37 +197,41 @@ export default function AddToCartSection({ product }: Props) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 border-t border-line-primary/20 pt-6 text-sm text-muted-text">
|
||||
<div>
|
||||
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-muted-text/70">Brand</span>
|
||||
<span className="font-semibold text-text-main">{product.brand.name}</span>
|
||||
{showBrandMeta && (
|
||||
<div className="grid grid-cols-2 gap-4 border-t border-line-primary/20 pt-6 text-sm text-muted-text">
|
||||
<div>
|
||||
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-muted-text/70">Brand</span>
|
||||
<span className="font-semibold text-text-main">{product.brand.name}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-muted-text/70">Category</span>
|
||||
<Link href={`/category/${product.category.id}`} className="font-semibold text-text-main transition hover:text-brand-primary">
|
||||
{categoryName}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-muted-text/70">Category</span>
|
||||
<Link href={`/category/${product.category.id}`} className="font-semibold text-text-main transition hover:text-brand-primary">
|
||||
{categoryName}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="fixed bottom-0 left-0 right-0 z-40 border-t border-line-primary/20 bg-surface-card/95 p-4 shadow-[0_-10px_30px_rgba(0,0,0,0.08)] backdrop-blur md:hidden">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-text/70">
|
||||
{currentVariantName || 'Total Price'}
|
||||
</span>
|
||||
<span className="text-xl font-bold text-brand-primary">{formatPrice(currentVariant?.price ?? 0)}</span>
|
||||
{showMobileStickyBar && (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-40 border-t border-line-primary/20 bg-surface-card/95 p-4 shadow-[0_-10px_30px_rgba(0,0,0,0.08)] backdrop-blur md:hidden">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-text/70">
|
||||
{currentVariantName || 'Total Price'}
|
||||
</span>
|
||||
<span className="text-xl font-bold text-brand-primary">{formatPrice(currentVariant?.price ?? 0)}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
disabled={!currentVariant}
|
||||
className={`ui-form-inline-button flex-1 px-5 py-3.5 ${!currentVariant ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{currentVariant ? 'Add to Cart' : 'Unavailable'}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
disabled={!currentVariant}
|
||||
className={`ui-form-inline-button flex-1 px-5 py-3.5 ${!currentVariant ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{currentVariant ? 'Add to Cart' : 'Unavailable'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import Link from './LocalizedLink'
|
||||
import { Heart, Search, ShoppingBag, Star, User } from 'lucide-react'
|
||||
import { formatPrice, getCategoryImage, type Product } from '@homaroma/types'
|
||||
import { useCartStore } from '../store/cartStore'
|
||||
import { resolveCategoryName, resolveLocalized } from '../lib/localization'
|
||||
import { useLang } from '../hooks/useLang'
|
||||
|
||||
function resolveOptionValue(val: string, options: Product['options'], lang: string): string {
|
||||
if (!val || !options || !Array.isArray(options)) return val
|
||||
|
||||
for (const option of options) {
|
||||
if (!Array.isArray(option.values)) continue
|
||||
for (const item of option.values) {
|
||||
const base = typeof item === 'string' ? item : item.th || item.en || Object.values(item)[0] || ''
|
||||
if (base === val) {
|
||||
if (typeof item === 'string') return item
|
||||
return item[lang] ?? item[lang.slice(0, 2)] ?? item.th ?? item.en ?? Object.values(item)[0] ?? base
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
export default function ProductStickyPurchaseBar({
|
||||
product,
|
||||
triggerId,
|
||||
}: {
|
||||
product: Product
|
||||
triggerId: string
|
||||
}) {
|
||||
const lang = useLang()
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const { addToCart, toggleCart } = useCartStore()
|
||||
const productName = resolveLocalized(product.name, lang) || product.brand.name
|
||||
const categoryName = resolveCategoryName(product.category, lang)
|
||||
const variant = product.variants?.[0]
|
||||
const imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName)
|
||||
|
||||
const variantName = useMemo(() => {
|
||||
if (!variant) return ''
|
||||
return [variant.option1, variant.option2, variant.option3]
|
||||
.filter(Boolean)
|
||||
.map((option) => resolveOptionValue(option, product.options, lang))
|
||||
.join(' / ')
|
||||
}, [lang, product.options, variant])
|
||||
|
||||
const handleAddToCart = () => {
|
||||
if (!variant) return
|
||||
|
||||
addToCart({
|
||||
productId: product.id,
|
||||
variantId: variant.id,
|
||||
quantity: 1,
|
||||
price: variant.price || 0,
|
||||
productName,
|
||||
variantName: variantName || undefined,
|
||||
productImage: imageUrl,
|
||||
})
|
||||
toggleCart(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const updateVisibility = () => {
|
||||
const trigger = document.getElementById(triggerId)
|
||||
if (!trigger) {
|
||||
setIsVisible(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsVisible(trigger.getBoundingClientRect().top <= 0)
|
||||
}
|
||||
|
||||
updateVisibility()
|
||||
window.addEventListener('scroll', updateVisibility, { passive: true })
|
||||
window.addEventListener('resize', updateVisibility)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', updateVisibility)
|
||||
window.removeEventListener('resize', updateVisibility)
|
||||
}
|
||||
}, [triggerId])
|
||||
|
||||
return (
|
||||
<div className={`fixed left-0 right-0 top-0 z-50 hidden border-b border-line-primary/15 bg-surface-page/98 px-6 py-3 backdrop-blur transition duration-300 md:block ${
|
||||
isVisible ? 'translate-y-0 opacity-100' : 'pointer-events-none -translate-y-full opacity-0'
|
||||
}`}>
|
||||
<div className="relative mx-auto flex max-w-7xl items-start justify-between gap-6">
|
||||
<div className="absolute left-1/2 top-0 hidden -translate-x-1/2 md:block">
|
||||
<Link href="/" className="text-3xl font-bold uppercase tracking-[0.22em] text-text-main">
|
||||
HomAroma
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 items-center gap-5 pt-9 md:max-w-[48%]">
|
||||
<div className="relative h-12 w-12 shrink-0 overflow-hidden bg-surface-card">
|
||||
<Image src={imageUrl} alt={productName} fill className="object-cover" sizes="56px" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-base font-semibold uppercase tracking-[0.18em] text-text-main">{productName}</p>
|
||||
<p className="mt-1 truncate text-sm text-muted-text">
|
||||
{variantName || categoryName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end gap-6">
|
||||
<div className="hidden items-center gap-5 text-text-main md:flex">
|
||||
<button type="button" aria-label="Search" className="transition hover:text-brand-primary">
|
||||
<Search className="h-5 w-5" strokeWidth={1.6} />
|
||||
</button>
|
||||
<Link href="/profile" aria-label="Account" className="transition hover:text-brand-primary">
|
||||
<User className="h-5 w-5" strokeWidth={1.6} />
|
||||
</Link>
|
||||
<Link href="/wishlist" aria-label="Wishlist" className="transition hover:text-brand-primary">
|
||||
<Star className="h-5 w-5" strokeWidth={1.6} />
|
||||
</Link>
|
||||
<Link href="/wishlist" aria-label="Favorites" className="transition hover:text-brand-primary">
|
||||
<Heart className="h-5 w-5" strokeWidth={1.6} />
|
||||
</Link>
|
||||
<button type="button" aria-label="Cart" onClick={() => toggleCart(true)} className="transition hover:text-brand-primary">
|
||||
<ShoppingBag className="h-5 w-5" strokeWidth={1.6} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-8">
|
||||
<p className="hidden text-base font-semibold uppercase tracking-[0.16em] text-text-main sm:block">{formatPrice(variant?.price ?? 0)}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddToCart}
|
||||
disabled={!variant}
|
||||
className="bg-text-main px-8 py-4 text-xs font-bold uppercase tracking-widest text-surface-page transition hover:bg-brand-primary disabled:cursor-not-allowed disabled:opacity-50 md:min-w-32"
|
||||
>
|
||||
{variant ? 'Add to Cart' : 'Unavailable'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user