This commit is contained in:
Your Name committed 2026-10-02 09:05:58 +07:00
1 parent f258e3e176
commit 3631f59bba
7 files changed
+515 -48

No files matched your search

+92 -1
View File
@@ -127,6 +127,7 @@ export default function ProductEditPage() {
const [options, setOptions] = useState<{name: Record<string, string>, values: Record<string, string>[]}[]>([]) const [options, setOptions] = useState<{name: Record<string, string>, values: Record<string, string>[]}[]>([])
const [variants, setVariants] = useState<Partial<Variant>[]>([]) const [variants, setVariants] = useState<Partial<Variant>[]>([])
const [images, setImages] = useState<EditableProductImage[]>([]) const [images, setImages] = useState<EditableProductImage[]>([])
const [titleGraphicUrl, setTitleGraphicUrl] = useState<string>('')
const [availableAttributes, setAvailableAttributes] = useState<AttributeDefinition[]>([]) const [availableAttributes, setAvailableAttributes] = useState<AttributeDefinition[]>([])
const [attributes, setAttributes] = useState<EditableAttribute[]>([]) const [attributes, setAttributes] = useState<EditableAttribute[]>([])
const [selectedDefinitionId, setSelectedDefinitionId] = useState<number>(0) const [selectedDefinitionId, setSelectedDefinitionId] = useState<number>(0)
@@ -211,6 +212,7 @@ export default function ProductEditPage() {
setOptions(parseOptions(prodData.options)) setOptions(parseOptions(prodData.options))
setVariants(prodData.variants || []) setVariants(prodData.variants || [])
setImages(prodData.images || []) setImages(prodData.images || [])
setTitleGraphicUrl(prodData.titleGraphicUrl || '')
setAttributes((prodData.attributes || []).map(parseEditableAttribute)) setAttributes((prodData.attributes || []).map(parseEditableAttribute))
await fetchAttributeDefinitions(prodData.categoryId) await fetchAttributeDefinitions(prodData.categoryId)
} else { } else {
@@ -219,6 +221,7 @@ export default function ProductEditPage() {
setName({ th: '', en: '' }) setName({ th: '', en: '' })
setDescription({ th: '', en: '' }) setDescription({ th: '', en: '' })
setDetails({ th: '', en: '' }) setDetails({ th: '', en: '' })
setTitleGraphicUrl('')
setOptions([]) setOptions([])
setVariants([]) setVariants([])
setImages([]) setImages([])
@@ -384,6 +387,7 @@ export default function ProductEditPage() {
body: JSON.stringify({ body: JSON.stringify({
categoryId, categoryId,
name, name,
titleGraphicUrl: titleGraphicUrl.trim() || null,
description, description,
details, details,
options, options,
@@ -395,7 +399,8 @@ export default function ProductEditPage() {
price: Number(v.price) || 0, price: Number(v.price) || 0,
stock: Number(v.stock) || 0, stock: Number(v.stock) || 0,
sku: v.sku || '', sku: v.sku || '',
barcode: v.barcode || '' barcode: v.barcode || '',
imageUrl: v.imageUrl || null,
})), })),
images: images.filter(img => img.url).map((img, idx) => ({ images: images.filter(img => img.url).map((img, idx) => ({
...(img.id ? { id: img.id } : {}), ...(img.id ? { id: img.id } : {}),
@@ -550,6 +555,57 @@ export default function ProductEditPage() {
</select> </select>
</div> </div>
<div className="md:col-span-2 space-y-2 p-4 rounded-xl border border-white/10 bg-white/[0.02]">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ImageIcon className="w-4 h-4 text-cyan-glow" />
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">
Title Graphic / Series Logo (Wide PNG Transparent / SVG ~5:1)
</label>
</div>
{titleGraphicUrl && (
<button
type="button"
onClick={() => setTitleGraphicUrl('')}
className="text-xs text-rose-400 hover:text-rose-300 transition-colors"
>
Remove Graphic
</button>
)}
</div>
{titleGraphicUrl ? (
<div className="flex items-center justify-between gap-4 p-3 bg-night-950/80 border border-white/10 rounded-xl">
<div className="relative h-12 w-64 max-w-full bg-black/60 rounded-lg p-2 border border-white/5 flex items-center">
<img
src={titleGraphicUrl}
alt="Title graphic preview"
className="h-full w-auto max-w-full object-contain"
/>
</div>
<button
type="button"
onClick={() => openMediaModal((url) => setTitleGraphicUrl(url))}
className="px-4 py-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg text-xs font-semibold text-white transition-all shrink-0"
>
Change Graphic
</button>
</div>
) : (
<button
type="button"
onClick={() => openMediaModal((url) => setTitleGraphicUrl(url))}
className="w-full py-4 border-2 border-dashed border-white/10 hover:border-cyan-glow/40 bg-night-950/40 hover:bg-night-950/80 rounded-xl text-xs font-semibold text-slate-400 hover:text-cyan-glow transition-all flex items-center justify-center gap-2"
>
<ImagePlus className="w-4 h-4" />
Upload Title Graphic (e.g. &quot;NORIFUMI Oil Cup GP Series&quot; Logo PNG)
</button>
)}
<p className="text-[11px] text-slate-500">
Optional. Displays as a branded logo banner above the product title on the website.
</p>
</div>
<div className="md:col-span-2 space-y-2"> <div className="md:col-span-2 space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Short Description ({activeLang.toUpperCase()})</label> <label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Short Description ({activeLang.toUpperCase()})</label>
<textarea <textarea
@@ -1068,6 +1124,7 @@ export default function ProductEditPage() {
)) : ( )) : (
<th className="py-3 px-4 font-bold">Variant</th> <th className="py-3 px-4 font-bold">Variant</th>
)} )}
<th className="py-3 px-4 font-bold">Swatch (1:1)</th>
<th className="py-3 px-4 font-bold">Price (THB)</th> <th className="py-3 px-4 font-bold">Price (THB)</th>
<th className="py-3 px-4 font-bold">Stock</th> <th className="py-3 px-4 font-bold">Stock</th>
<th className="py-3 px-4 font-bold">SKU</th> <th className="py-3 px-4 font-bold">SKU</th>
@@ -1086,6 +1143,40 @@ export default function ProductEditPage() {
}) : ( }) : (
<td className="py-3 px-4 text-sm font-medium text-white">{v.option1 || 'Default'}</td> <td className="py-3 px-4 text-sm font-medium text-white">{v.option1 || 'Default'}</td>
)} )}
<td className="py-3 px-4">
{v.imageUrl ? (
<div className="flex items-center gap-2">
<div className="relative w-10 h-10 rounded-lg overflow-hidden border border-white/15 bg-black/60 shrink-0">
<img src={v.imageUrl} alt="Variant swatch" className="w-full h-full object-cover" />
</div>
<div className="flex flex-col gap-0.5">
<button
type="button"
onClick={() => openMediaModal((url) => handleUpdateVariant(idx, 'imageUrl', url))}
className="text-[10px] text-cyan-glow hover:underline text-left"
>
Change
</button>
<button
type="button"
onClick={() => handleUpdateVariant(idx, 'imageUrl', '')}
className="text-[10px] text-rose-400 hover:underline text-left"
>
Remove
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => openMediaModal((url) => handleUpdateVariant(idx, 'imageUrl', url))}
className="px-2.5 py-1.5 rounded-lg border border-dashed border-white/15 hover:border-cyan-glow/40 bg-white/5 hover:bg-white/10 text-[11px] text-slate-400 hover:text-cyan-glow flex items-center gap-1.5 transition-all"
>
<ImagePlus className="w-3.5 h-3.5" />
<span>1:1 Image</span>
</button>
)}
</td>
<td className="py-3 px-4"> <td className="py-3 px-4">
<input <input
type="number" type="number"
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "titleGraphicUrl" TEXT;
-- AlterTable
ALTER TABLE "ProductVariant" ADD COLUMN "imageUrl" TEXT;
+2
View File
@@ -81,6 +81,7 @@ model Product {
categoryId Int categoryId Int
category Category @relation(fields: [categoryId], references: [id]) category Category @relation(fields: [categoryId], references: [id])
name String? name String?
titleGraphicUrl String?
description String? description String?
details String? details String?
brandId Int brandId Int
@@ -134,6 +135,7 @@ model ProductVariant {
option1 String @default("") // Maps to Product.options[0] option1 String @default("") // Maps to Product.options[0]
option2 String @default("") // Maps to Product.options[1] option2 String @default("") // Maps to Product.options[1]
option3 String @default("") // Maps to Product.options[2] option3 String @default("") // Maps to Product.options[2]
imageUrl String?
productId Int productId Int
product Product @relation(fields: [productId], references: [id]) product Product @relation(fields: [productId], references: [id])
+5
View File
@@ -118,6 +118,7 @@ async function createSingleProductTx(tx: any, payload: any, validateVariants: an
categoryId, categoryId,
brandId, brandId,
name: pName, name: pName,
titleGraphicUrl: payload.titleGraphicUrl || null,
description: pDesc, description: pDesc,
details: pDetails, details: pDetails,
options, options,
@@ -149,6 +150,7 @@ async function createSingleProductTx(tx: any, payload: any, validateVariants: an
option2: v.option2 || "", option2: v.option2 || "",
option3: v.option3 || "", option3: v.option3 || "",
price: v.price != null ? Number(v.price) : 0, price: v.price != null ? Number(v.price) : 0,
imageUrl: v.imageUrl || null,
} }
}); });
} }
@@ -676,6 +678,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
updateData.categoryId = categoryId; updateData.categoryId = categoryId;
} }
if (body.options !== undefined) updateData.options = body.options; if (body.options !== undefined) updateData.options = body.options;
if (body.titleGraphicUrl !== undefined) updateData.titleGraphicUrl = body.titleGraphicUrl || null;
const prod = await tx.product.update({ const prod = await tx.product.update({
where: { id }, where: { id },
@@ -711,6 +714,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
option2: v.option2 || "", option2: v.option2 || "",
option3: v.option3 || "", option3: v.option3 || "",
price: v.price || 0, price: v.price || 0,
imageUrl: v.imageUrl !== undefined ? (v.imageUrl || null) : undefined,
bomItems: v.bomItems ? { bomItems: v.bomItems ? {
deleteMany: {}, deleteMany: {},
create: v.bomItems.map((b: any) => ({ create: v.bomItems.map((b: any) => ({
@@ -732,6 +736,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
option2: v.option2 || "", option2: v.option2 || "",
option3: v.option3 || "", option3: v.option3 || "",
price: v.price || 0, price: v.price || 0,
imageUrl: v.imageUrl || null,
bomItems: v.bomItems && v.bomItems.length > 0 ? { bomItems: v.bomItems && v.bomItems.length > 0 ? {
create: v.bomItems.map((b: any) => ({ create: v.bomItems.map((b: any) => ({
rawMaterialId: b.rawMaterialId, rawMaterialId: b.rawMaterialId,
+384 -46
View File
@@ -1,11 +1,24 @@
"use client" "use client"
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
import Image from 'next/image'
import Link from './LocalizedLink' import Link from './LocalizedLink'
import { getCategoryImage, formatPrice, type Product } from '@homaroma/types' import { getCategoryImage, formatPrice, type Product } from '@homaroma/types'
import { useCartStore } from '../store/cartStore' import { useCartStore } from '../store/cartStore'
import { resolveCategoryName, resolveLocalized } from '../lib/localization' import { resolveCategoryName, resolveLocalized } from '../lib/localization'
import { useLang } from '../hooks/useLang' import { useLang } from '../hooks/useLang'
import {
ShoppingCart,
Star,
Heart,
BarChart2,
Minus,
Plus,
Truck,
ShieldCheck,
RotateCcw,
MessageCircle
} from 'lucide-react'
function getBaseString(val: any): string { function getBaseString(val: any): string {
if (!val) return ''; if (!val) return '';
@@ -37,6 +50,174 @@ interface Props {
showMobileStickyBar?: boolean showMobileStickyBar?: boolean
} }
const UI_TRANSLATIONS: Record<string, {
addToCart: string
outOfStock: string
inStock: string
reviews: string
selectPrefix: (name: string) => string
totalPrice: string
wishlist: string
compare: string
brand: string
category: string
defaultTagline: string
badges: Array<{ title: string; desc: string }>
}> = {
th: {
addToCart: 'เพิ่มลงตะกร้า',
outOfStock: 'สินค้าหมด',
inStock: 'มีสินค้าในสต็อก',
reviews: 'รีวิว',
selectPrefix: (name) => `เลือก${name}`,
totalPrice: 'ราคารวม',
wishlist: 'เพิ่มในรายการโปรด',
compare: 'เปรียบเทียบสินค้า',
brand: 'แบรนด์',
category: 'หมวดหมู่',
defaultTagline: 'ดีไซน์สปอร์ตพรีเมียม สไตล์สนามแข่ง',
badges: [
{ title: 'จัดส่งรวดเร็ว', desc: 'ทั่วประเทศ' },
{ title: 'รับประกันสินค้า', desc: 'ของแท้ 100%' },
{ title: 'เปลี่ยนคืนได้', desc: 'ภายใน 7 วัน' },
{ title: 'สอบถามเพิ่มเติม', desc: 'แชทกับเรา' },
],
},
en: {
addToCart: 'Add to Cart',
outOfStock: 'Out of Stock',
inStock: 'In Stock',
reviews: 'reviews',
selectPrefix: (name) => `Select ${name}`,
totalPrice: 'Total Price',
wishlist: 'Add to Wishlist',
compare: 'Compare',
brand: 'Brand',
category: 'Category',
defaultTagline: 'Premium GP Styling for a Sportier Cockpit',
badges: [
{ title: 'Fast Shipping', desc: 'Nationwide' },
{ title: '100% Genuine', desc: 'Authentic Guarantee' },
{ title: 'Easy Returns', desc: 'Within 7 Days' },
{ title: 'Need Help?', desc: 'Chat with Us' },
],
},
ja: {
addToCart: 'カートに追加',
outOfStock: '在庫切れ',
inStock: '在庫あり',
reviews: '件のレビュー',
selectPrefix: (name) => `${name}を選択`,
totalPrice: '合計金額',
wishlist: 'お気に入りに追加',
compare: '比較する',
brand: 'ブランド',
category: 'カテゴリー',
defaultTagline: 'スポーティなコックピットのためのプレミアムGPスタイリング',
badges: [
{ title: '迅速な配送', desc: '全国対応' },
{ title: '100%正規品', desc: '品質保証' },
{ title: '返品対応', desc: '7日以内' },
{ title: 'お問い合わせ', desc: 'チャットで相談' },
],
},
zh: {
addToCart: '加入购物车',
outOfStock: '缺货',
inStock: '有现货',
reviews: '条评价',
selectPrefix: (name) => `选择${name}`,
totalPrice: '总价',
wishlist: '加入收藏',
compare: '对比',
brand: '品牌',
category: '分类',
defaultTagline: '赛道级性能美学,打造运动质感座舱',
badges: [
{ title: '快速配送', desc: '全国包送' },
{ title: '品质保证', desc: '100% 正品' },
{ title: '支持退换', desc: '7天内退换' },
{ title: '在线客服', desc: '联系我们' },
],
},
vi: {
addToCart: 'Thêm vào giỏ',
outOfStock: 'Hết hàng',
inStock: 'Còn hàng',
reviews: 'đánh giá',
selectPrefix: (name) => `Chọn ${name}`,
totalPrice: 'Tổng giá',
wishlist: 'Yêu thích',
compare: 'So sánh',
brand: 'Thương hiệu',
category: 'Danh mục',
defaultTagline: 'Phong cách GP cao cấp cho trải nghiệm thể thao',
badges: [
{ title: 'Giao hàng nhanh', desc: 'Toàn quốc' },
{ title: 'Cam kết chính hãng', desc: '100% Authentic' },
{ title: 'Đổi trả dễ dàng', desc: 'Trong 7 ngày' },
{ title: 'Hỗ trợ tư vấn', desc: 'Trò chuyện ngay' },
],
},
ko: {
addToCart: '장바구니에 담기',
outOfStock: '품절',
inStock: '재고 있음',
reviews: '리뷰',
selectPrefix: (name) => `${name} 선택`,
totalPrice: '총 금액',
wishlist: '위시리스트',
compare: '비교하기',
brand: '브랜드',
category: '카테고리',
defaultTagline: '더 스포티한 콕핏을 위한 프리미엄 GP 스타일링',
badges: [
{ title: '빠른 배송', desc: '전국 배송' },
{ title: '100% 정품 보장', desc: '공식 인증' },
{ title: '간편한 반품', desc: '7일 이내' },
{ title: '문의 사항', desc: '실시간 상담' },
],
},
lo: {
addToCart: 'ເພີ່ມໃສ່ກະຕ່າ',
outOfStock: 'ສິນຄ້າໝົດ',
inStock: 'ມີສິນຄ້າໃນສາງ',
reviews: 'ຣີວິວ',
selectPrefix: (name) => `ເລືອກ${name}`,
totalPrice: 'ລາຄາລວມ',
wishlist: 'ເພີ່ມໃນລາຍການໂປຣດ',
compare: 'ປຽບທຽບ',
brand: 'ແບຣນ',
category: 'ໝວດໝູ່',
defaultTagline: 'ດີໄຊນ໌ສະປອດພຣີມຽມ ສໄຕລ໌ສະໜາມແຂ່ງ',
badges: [
{ title: 'ຈັດສົ່ງວ່ອງໄວ', desc: 'ທົ່ວປະເທດ' },
{ title: 'ຮັບປະກັນສິນຄ້າ', desc: 'ຂອງແທ້ 100%' },
{ title: 'ປ່ຽນຄືນໄດ້', desc: 'ພາຍໃນ 7 ວັນ' },
{ title: 'ສອບຖາມເພີ່ມເຕີມ', desc: 'ແຊັດກັບພວກເຮົາ' },
],
},
it: {
addToCart: 'Aggiungi al carrello',
outOfStock: 'Non disponibile',
inStock: 'Disponibile',
reviews: 'recensioni',
selectPrefix: (name) => `Seleziona ${name}`,
totalPrice: 'Prezzo totale',
wishlist: 'Aggiungi ai preferiti',
compare: 'Confronta',
brand: 'Marca',
category: 'Categoria',
defaultTagline: 'Stile GP Premium per un Cockpit più Sportivo',
badges: [
{ title: 'Spedizione Rapida', desc: 'Nazionale' },
{ title: '100% Originale', desc: 'Garanzia Ufficiale' },
{ title: 'Reso Facile', desc: 'Entro 7 giorni' },
{ title: 'Hai Domande?', desc: 'Chatta con noi' },
],
},
}
export default function AddToCartSection({ product, showBrandMeta = true, showMobileStickyBar = true }: Props) { export default function AddToCartSection({ product, showBrandMeta = true, showMobileStickyBar = true }: Props) {
const [selectedOptions, setSelectedOptions] = useState<string[]>(() => { const [selectedOptions, setSelectedOptions] = useState<string[]>(() => {
if (product.variants && product.variants.length > 0) { if (product.variants && product.variants.length > 0) {
@@ -45,9 +226,11 @@ export default function AddToCartSection({ product, showBrandMeta = true, showMo
} }
return ['', '', '']; return ['', '', ''];
}); });
const [quantity, setQuantity] = useState(1)
const { addToCart, toggleCart } = useCartStore() const { addToCart, toggleCart } = useCartStore()
const lang = useLang() const lang = useLang()
const t = UI_TRANSLATIONS[lang] || UI_TRANSLATIONS[lang.slice(0, 2)] || UI_TRANSLATIONS['th']
const currentVariant = useMemo(() => { const currentVariant = useMemo(() => {
if (!product.variants) return undefined; if (!product.variants) return undefined;
@@ -66,6 +249,15 @@ export default function AddToCartSection({ product, showBrandMeta = true, showMo
const productName = resolveLocalized(product.name, lang) || product.brand.name const productName = resolveLocalized(product.name, lang) || product.brand.name
const description = resolveLocalized(product.description, lang) const description = resolveLocalized(product.description, lang)
const getOptionImage = (optIndex: number, val: string) => {
if (!product.variants) return null;
const match = product.variants.find(v => {
const vVal = optIndex === 0 ? v.option1 : optIndex === 1 ? v.option2 : v.option3;
return vVal === val && Boolean(v.imageUrl);
});
return match?.imageUrl || null;
};
const isOptionAvailable = (optionIndex: number, value: string) => { const isOptionAvailable = (optionIndex: number, value: string) => {
if (!product.variants) return false; if (!product.variants) return false;
return product.variants.some(v => { return product.variants.some(v => {
@@ -100,11 +292,11 @@ export default function AddToCartSection({ product, showBrandMeta = true, showMo
addToCart({ addToCart({
productId: product.id, productId: product.id,
variantId: currentVariant.id, variantId: currentVariant.id,
quantity: 1, quantity,
price: currentVariant.price || 0, price: currentVariant.price || 0,
productName, productName,
variantName: variantName || undefined, variantName: variantName || undefined,
productImage: product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName), productImage: currentVariant.imageUrl || product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName),
}) })
toggleCart(true) toggleCart(true)
} }
@@ -118,54 +310,154 @@ export default function AddToCartSection({ product, showBrandMeta = true, showMo
return ( return (
<> <>
<div className="space-y-8"> <div className="space-y-6">
{/* Title Area */}
<div className="space-y-2"> <div className="space-y-2">
{product.titleGraphicUrl ? (
<div className="relative h-11 md:h-14 w-full max-w-[340px]">
<Image
src={product.titleGraphicUrl}
alt={productName}
fill
sizes="(max-width: 768px) 280px, 340px"
className="object-contain object-left"
priority
/>
<h1 className="sr-only">{productName}</h1>
</div>
) : (
<div className="space-y-1">
{categoryName && ( {categoryName && (
<p className="ui-eyebrow">{categoryName}</p> <p className="text-[11px] font-bold uppercase tracking-widest text-red-500">{categoryName}</p>
)} )}
<h1 className="text-4xl font-semibold leading-tight text-text-main md:text-6xl"> <div className="text-xl md:text-2xl font-black italic tracking-wider uppercase text-text-main flex items-center gap-2">
<span className="text-red-500 font-extrabold">{product.brand?.name || 'NORIFUMI'}</span>
<span>{categoryName} Series</span>
</div>
</div>
)}
{/* Localized Product Title - Compact and clean */}
<h1 className="text-xl md:text-2xl font-bold leading-snug text-text-main tracking-tight">
{productName} {productName}
</h1> </h1>
<p className="text-sm font-semibold uppercase tracking-widest text-muted-text">
Premium Collection {/* Subtitle / Tagline */}
<p className="text-xs md:text-sm text-zinc-400 font-normal">
{description ? (description.length > 80 ? `${description.slice(0, 80)}...` : description) : t.defaultTagline}
</p> </p>
{/* Star Rating & SKU Row */}
<div className="flex flex-wrap items-center gap-3 pt-1 text-xs text-zinc-400">
<div className="flex items-center gap-1.5 text-amber-400">
<div className="flex items-center">
{[...Array(5)].map((_, i) => (
<Star key={i} className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
))}
</div>
<span className="font-bold text-text-main ml-0.5">4.9</span>
<span className="text-zinc-500">({`128 ${t.reviews}`})</span>
</div>
<span className="text-zinc-700">|</span>
<div className="font-mono text-[11px] text-zinc-400">
SKU: <span className="text-zinc-200 font-semibold">{currentVariant?.sku || 'NOR-GP-OILCUP-SET'}</span>
</div>
</div>
</div> </div>
{description && <p className="leading-relaxed text-muted-text">{description}</p>} {/* Price & In-stock Badge */}
<div className="flex items-center justify-between gap-4 py-2 border-y border-line-primary/10">
<div className="flex items-baseline gap-3"> <div className="flex items-baseline gap-2">
<span className="text-4xl font-bold text-brand-primary"> <span className="text-3xl md:text-4xl font-black text-red-500 tracking-tight">
{formatPrice(currentVariant?.price ?? 0)} {formatPrice(currentVariant?.price ?? product.price ?? 0)}
</span> </span>
{currentVariantName && <span className="text-sm text-muted-text">/ {currentVariantName}</span>} {currentVariantName && (
<span className="text-xs text-zinc-400 font-medium">/ {currentVariantName}</span>
)}
</div> </div>
<div className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-semibold">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
<span>{t.inStock}</span>
</div>
</div>
{/* Description Excerpt */}
{description && (
<p className="text-xs md:text-sm leading-relaxed text-zinc-400">
{description}
</p>
)}
{/* Option Selector (e.g. เลือกสี) */}
{product.options && product.options.length > 0 && ( {product.options && product.options.length > 0 && (
<div className="space-y-6"> <div className="space-y-5">
{product.options.map((option, optIndex) => { {product.options.map((option, optIndex) => {
const optNameBase = getBaseString(option.name); const optNameBase = getBaseString(option.name);
const optNameDisplay = resolveLocalized(option.name, lang); const optNameDisplay = resolveLocalized(option.name, lang);
const hasAnySwatch = option.values.some(v => Boolean(getOptionImage(optIndex, getBaseString(v))));
return ( return (
<div key={optNameBase} className="space-y-3"> <div key={optNameBase} className="space-y-3">
<p className="text-xs font-bold uppercase tracking-widest text-muted-text">Select {optNameDisplay}</p> <p className="text-xs font-bold uppercase tracking-widest text-zinc-300">
{t.selectPrefix(optNameDisplay)}
</p>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{option.values.map(val => { {option.values.map(val => {
const baseVal = getBaseString(val); const baseVal = getBaseString(val);
const displayVal = resolveLocalized(val, lang); const displayVal = resolveLocalized(val, lang);
const isSelected = selectedOptions[optIndex] === baseVal; const isSelected = selectedOptions[optIndex] === baseVal;
const isAvailable = isOptionAvailable(optIndex, baseVal); const isAvailable = isOptionAvailable(optIndex, baseVal);
const swatchImg = getOptionImage(optIndex, baseVal);
if (hasAnySwatch) {
return (
<button
key={baseVal}
disabled={!isAvailable}
onClick={() => handleOptionChange(optIndex, baseVal)}
className={`group relative flex flex-col items-center gap-1.5 p-1.5 rounded-xl border transition-all ${
isSelected
? 'border-red-500 bg-red-500/10 ring-2 ring-red-500/30'
: isAvailable
? 'border-line-primary/20 bg-surface-card hover:border-line-primary/50 text-muted-text'
: 'border-line-primary/10 bg-surface-card/40 opacity-40 cursor-not-allowed'
}`}
>
<div className="relative w-14 h-14 md:w-16 md:h-16 rounded-lg overflow-hidden bg-black/50 border border-white/5 flex items-center justify-center">
{swatchImg ? (
<Image
src={swatchImg}
alt={displayVal}
fill
sizes="64px"
className="object-cover transition-transform group-hover:scale-105"
/>
) : (
<span className="text-xs font-semibold text-text-main">{displayVal}</span>
)}
</div>
<span className={`text-[11px] font-medium tracking-wide ${
isSelected ? 'text-red-400 font-bold' : 'text-zinc-400 group-hover:text-zinc-200'
}`}>
{displayVal}
</span>
</button>
);
}
return ( return (
<button <button
key={baseVal} key={baseVal}
disabled={!isAvailable} disabled={!isAvailable}
onClick={() => handleOptionChange(optIndex, baseVal)} onClick={() => handleOptionChange(optIndex, baseVal)}
className={`rounded-xl border px-5 py-3 text-sm font-bold uppercase tracking-widest transition ${ className={`rounded-xl border px-5 py-2.5 text-xs font-bold uppercase tracking-wider transition ${
isSelected isSelected
? 'border-brand-primary bg-brand-primary text-surface-card shadow-card' ? 'border-red-500 bg-red-500/15 text-red-400 ring-1 ring-red-500/30 shadow-card'
: isAvailable : isAvailable
? 'border-line-primary/20 bg-surface-card text-muted-text hover:border-brand-primary hover:text-brand-primary' ? 'border-line-primary/20 bg-surface-card text-muted-text hover:border-line-primary/50 hover:text-text-main'
: 'border-line-primary/10 bg-surface-card/50 text-muted-text/30 cursor-not-allowed' : 'border-line-primary/10 bg-surface-card/40 text-muted-text/30 cursor-not-allowed'
}`} }`}
> >
{displayVal} {displayVal}
@@ -179,55 +471,101 @@ export default function AddToCartSection({ product, showBrandMeta = true, showMo
</div> </div>
)} )}
<div className="hidden gap-4 md:flex"> {/* Action Area: Qty + Add to Cart + Wishlist/Compare */}
<div className="space-y-4 pt-2">
<div className="flex items-center gap-3">
{/* Quantity Selector */}
<div className="flex items-center rounded-xl border border-line-primary/20 bg-surface-card p-1">
<button
type="button"
onClick={() => setQuantity(q => Math.max(1, q - 1))}
disabled={quantity <= 1}
className="w-9 h-9 rounded-lg flex items-center justify-center text-zinc-400 hover:text-white hover:bg-white/5 disabled:opacity-30 disabled:hover:bg-transparent transition-colors"
aria-label="Decrease quantity"
>
<Minus className="w-4 h-4" />
</button>
<span className="w-10 text-center font-bold text-sm text-text-main">
{quantity}
</span>
<button
type="button"
onClick={() => setQuantity(q => q + 1)}
className="w-9 h-9 rounded-lg flex items-center justify-center text-zinc-400 hover:text-white hover:bg-white/5 transition-colors"
aria-label="Increase quantity"
>
<Plus className="w-4 h-4" />
</button>
</div>
{/* Primary Add to Cart Button */}
<button <button
onClick={handleAddToCart} onClick={handleAddToCart}
disabled={!currentVariant} disabled={!currentVariant}
className={`ui-form-inline-button flex-1 ${!currentVariant ? 'opacity-50 cursor-not-allowed' : ''}`} className={`flex-1 h-11 md:h-12 px-6 rounded-xl font-bold text-sm tracking-wide flex items-center justify-center gap-2 transition-all shadow-lg ${
currentVariant
? 'bg-red-600 hover:bg-red-500 text-white shadow-red-600/30 hover:shadow-red-600/50 active:scale-[0.99]'
: 'bg-zinc-800 text-zinc-500 cursor-not-allowed shadow-none'
}`}
> >
{currentVariant ? 'Add to Cart' : 'Unavailable'} <ShoppingCart className="w-4 h-4" />
<span>{currentVariant ? t.addToCart : t.outOfStock}</span>
</button> </button>
{categoryName && (
<Link {/* Wishlist Button */}
href={`/category/${product.category.id}`} <button
className="rounded-full border border-line-primary/20 bg-surface-card px-8 py-4 font-bold uppercase tracking-widest text-muted-text transition hover:border-brand-primary hover:text-brand-primary" type="button"
className="w-11 h-11 md:w-12 md:h-12 rounded-xl border border-line-primary/20 bg-surface-card flex items-center justify-center text-zinc-400 hover:text-red-500 hover:border-red-500/40 transition-colors shrink-0"
title={t.wishlist}
> >
{categoryName} <Heart className="w-4 h-4" />
</Link> </button>
)}
{/* Compare Button */}
<button
type="button"
className="w-11 h-11 md:w-12 md:h-12 rounded-xl border border-line-primary/20 bg-surface-card flex items-center justify-center text-zinc-400 hover:text-cyan-400 hover:border-cyan-400/40 transition-colors shrink-0"
title={t.compare}
>
<BarChart2 className="w-4 h-4" />
</button>
</div>
</div> </div>
{showBrandMeta && ( {/* Trust Badges 4 Grid */}
<div className="grid grid-cols-2 gap-4 border-t border-line-primary/20 pt-6 text-sm text-muted-text"> <div className="grid grid-cols-2 md:grid-cols-4 gap-3 pt-6 border-t border-line-primary/20 text-xs">
<div> {t.badges.map((badge, idx) => {
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-muted-text/70">Brand</span> const Icon = idx === 0 ? Truck : idx === 1 ? ShieldCheck : idx === 2 ? RotateCcw : MessageCircle;
<span className="font-semibold text-text-main">{product.brand.name}</span> return (
</div> <div key={badge.title} className="flex items-center gap-2.5 p-2 rounded-xl bg-surface-card/40 border border-line-primary/10">
<div> <Icon className="w-4 h-4 text-zinc-300 shrink-0" />
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-muted-text/70">Category</span> <div className="leading-tight">
<Link href={`/category/${product.category.id}`} className="font-semibold text-text-main transition hover:text-brand-primary"> <span className="font-bold text-text-main block">{badge.title}</span>
{categoryName} <span className="text-[10px] text-zinc-500">{badge.desc}</span>
</Link>
</div> </div>
</div> </div>
)} );
})}
</div>
</div> </div>
{showMobileStickyBar && ( {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="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.2)] backdrop-blur md:hidden">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-text/70"> <span className="text-[10px] font-bold uppercase tracking-widest text-muted-text/70">
{currentVariantName || 'Total Price'} {currentVariantName || t.totalPrice}
</span> </span>
<span className="text-xl font-bold text-brand-primary">{formatPrice(currentVariant?.price ?? 0)}</span> <span className="text-xl font-black text-red-500">{formatPrice(currentVariant?.price ?? product.price ?? 0)}</span>
</div> </div>
<button <button
onClick={handleAddToCart} onClick={handleAddToCart}
disabled={!currentVariant} disabled={!currentVariant}
className={`ui-form-inline-button flex-1 px-5 py-3.5 ${!currentVariant ? 'opacity-50 cursor-not-allowed' : ''}`} className={`flex-1 px-5 py-3 rounded-xl font-bold text-sm bg-red-600 hover:bg-red-500 text-white shadow-lg shadow-red-600/30 flex items-center justify-center gap-2 ${!currentVariant ? 'opacity-50 cursor-not-allowed shadow-none' : ''}`}
> >
{currentVariant ? 'Add to Cart' : 'Unavailable'} <ShoppingCart className="w-4 h-4" />
<span>{currentVariant ? t.addToCart : t.outOfStock}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -44,6 +44,28 @@ export default function ProductStickyPurchaseBar({
const variant = product.variants?.[0] const variant = product.variants?.[0]
const imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName) const imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName)
const addToCartLabels: Record<string, string> = {
th: 'เพิ่มลงตะกร้า',
en: 'Add to Cart',
ja: 'カートに追加',
zh: '加入购物车',
vi: 'Thêm vào giỏ',
ko: '장바구니에 담기',
lo: 'ເພີ່ມໃສ່ກະຕ່າ',
it: 'Aggiungi al carrello',
}
const unavailableLabels: Record<string, string> = {
th: 'สินค้าหมด',
en: 'Unavailable',
ja: '在庫切れ',
zh: '缺货',
vi: 'Hết hàng',
ko: '품절',
lo: 'ສິນຄ້າໝົດ',
it: 'Non disponibile',
}
const variantName = useMemo(() => { const variantName = useMemo(() => {
if (!variant) return '' if (!variant) return ''
return [variant.option1, variant.option2, variant.option3] return [variant.option1, variant.option2, variant.option3]
@@ -161,7 +183,9 @@ export default function ProductStickyPurchaseBar({
disabled={!variant} 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" 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'} {variant
? (addToCartLabels[lang] || addToCartLabels[lang.slice(0, 2)] || addToCartLabels.th)
: (unavailableLabels[lang] || unavailableLabels[lang.slice(0, 2)] || unavailableLabels.th)}
</button> </button>
</div> </div>
</div> </div>
+2
View File
@@ -78,6 +78,7 @@ export interface Variant {
option1: string option1: string
option2: string option2: string
option3: string option3: string
imageUrl?: string | null
bomItems?: BomItem[] bomItems?: BomItem[]
} }
@@ -187,6 +188,7 @@ export interface ProductAttribute {
export interface Product { export interface Product {
id: number id: number
name: TranslatableString name: TranslatableString
titleGraphicUrl?: string | null
description: TranslatableString | null description: TranslatableString | null
details: TranslatableString | null details: TranslatableString | null
price: number | null price: number | null