x
This commit is contained in:
1 parent
f258e3e176
commit
3631f59bba
7 files changed
+523
-56
No files matched your search
@@ -127,6 +127,7 @@ export default function ProductEditPage() {
|
||||
const [options, setOptions] = useState<{name: Record<string, string>, values: Record<string, string>[]}[]>([])
|
||||
const [variants, setVariants] = useState<Partial<Variant>[]>([])
|
||||
const [images, setImages] = useState<EditableProductImage[]>([])
|
||||
const [titleGraphicUrl, setTitleGraphicUrl] = useState<string>('')
|
||||
const [availableAttributes, setAvailableAttributes] = useState<AttributeDefinition[]>([])
|
||||
const [attributes, setAttributes] = useState<EditableAttribute[]>([])
|
||||
const [selectedDefinitionId, setSelectedDefinitionId] = useState<number>(0)
|
||||
@@ -211,6 +212,7 @@ export default function ProductEditPage() {
|
||||
setOptions(parseOptions(prodData.options))
|
||||
setVariants(prodData.variants || [])
|
||||
setImages(prodData.images || [])
|
||||
setTitleGraphicUrl(prodData.titleGraphicUrl || '')
|
||||
setAttributes((prodData.attributes || []).map(parseEditableAttribute))
|
||||
await fetchAttributeDefinitions(prodData.categoryId)
|
||||
} else {
|
||||
@@ -219,6 +221,7 @@ export default function ProductEditPage() {
|
||||
setName({ th: '', en: '' })
|
||||
setDescription({ th: '', en: '' })
|
||||
setDetails({ th: '', en: '' })
|
||||
setTitleGraphicUrl('')
|
||||
setOptions([])
|
||||
setVariants([])
|
||||
setImages([])
|
||||
@@ -384,6 +387,7 @@ export default function ProductEditPage() {
|
||||
body: JSON.stringify({
|
||||
categoryId,
|
||||
name,
|
||||
titleGraphicUrl: titleGraphicUrl.trim() || null,
|
||||
description,
|
||||
details,
|
||||
options,
|
||||
@@ -395,7 +399,8 @@ export default function ProductEditPage() {
|
||||
price: Number(v.price) || 0,
|
||||
stock: Number(v.stock) || 0,
|
||||
sku: v.sku || '',
|
||||
barcode: v.barcode || ''
|
||||
barcode: v.barcode || '',
|
||||
imageUrl: v.imageUrl || null,
|
||||
})),
|
||||
images: images.filter(img => img.url).map((img, idx) => ({
|
||||
...(img.id ? { id: img.id } : {}),
|
||||
@@ -550,6 +555,57 @@ export default function ProductEditPage() {
|
||||
</select>
|
||||
</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. "NORIFUMI Oil Cup GP Series" 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">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Short Description ({activeLang.toUpperCase()})</label>
|
||||
<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">Swatch (1:1)</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">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">
|
||||
{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">
|
||||
<input
|
||||
type="number"
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Product" ADD COLUMN "titleGraphicUrl" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ProductVariant" ADD COLUMN "imageUrl" TEXT;
|
||||
@@ -81,6 +81,7 @@ model Product {
|
||||
categoryId Int
|
||||
category Category @relation(fields: [categoryId], references: [id])
|
||||
name String?
|
||||
titleGraphicUrl String?
|
||||
description String?
|
||||
details String?
|
||||
brandId Int
|
||||
@@ -134,6 +135,7 @@ model ProductVariant {
|
||||
option1 String @default("") // Maps to Product.options[0]
|
||||
option2 String @default("") // Maps to Product.options[1]
|
||||
option3 String @default("") // Maps to Product.options[2]
|
||||
imageUrl String?
|
||||
|
||||
productId Int
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
|
||||
@@ -118,6 +118,7 @@ async function createSingleProductTx(tx: any, payload: any, validateVariants: an
|
||||
categoryId,
|
||||
brandId,
|
||||
name: pName,
|
||||
titleGraphicUrl: payload.titleGraphicUrl || null,
|
||||
description: pDesc,
|
||||
details: pDetails,
|
||||
options,
|
||||
@@ -149,6 +150,7 @@ async function createSingleProductTx(tx: any, payload: any, validateVariants: an
|
||||
option2: v.option2 || "",
|
||||
option3: v.option3 || "",
|
||||
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;
|
||||
}
|
||||
if (body.options !== undefined) updateData.options = body.options;
|
||||
if (body.titleGraphicUrl !== undefined) updateData.titleGraphicUrl = body.titleGraphicUrl || null;
|
||||
|
||||
const prod = await tx.product.update({
|
||||
where: { id },
|
||||
@@ -711,6 +714,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
option2: v.option2 || "",
|
||||
option3: v.option3 || "",
|
||||
price: v.price || 0,
|
||||
imageUrl: v.imageUrl !== undefined ? (v.imageUrl || null) : undefined,
|
||||
bomItems: v.bomItems ? {
|
||||
deleteMany: {},
|
||||
create: v.bomItems.map((b: any) => ({
|
||||
@@ -732,6 +736,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
option2: v.option2 || "",
|
||||
option3: v.option3 || "",
|
||||
price: v.price || 0,
|
||||
imageUrl: v.imageUrl || null,
|
||||
bomItems: v.bomItems && v.bomItems.length > 0 ? {
|
||||
create: v.bomItems.map((b: any) => ({
|
||||
rawMaterialId: b.rawMaterialId,
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import Image from 'next/image'
|
||||
import Link from './LocalizedLink'
|
||||
import { getCategoryImage, formatPrice, type Product } from '@homaroma/types'
|
||||
import { useCartStore } from '../store/cartStore'
|
||||
import { resolveCategoryName, resolveLocalized } from '../lib/localization'
|
||||
import { useLang } from '../hooks/useLang'
|
||||
import {
|
||||
ShoppingCart,
|
||||
Star,
|
||||
Heart,
|
||||
BarChart2,
|
||||
Minus,
|
||||
Plus,
|
||||
Truck,
|
||||
ShieldCheck,
|
||||
RotateCcw,
|
||||
MessageCircle
|
||||
} from 'lucide-react'
|
||||
|
||||
function getBaseString(val: any): string {
|
||||
if (!val) return '';
|
||||
@@ -37,6 +50,174 @@ interface Props {
|
||||
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) {
|
||||
const [selectedOptions, setSelectedOptions] = useState<string[]>(() => {
|
||||
if (product.variants && product.variants.length > 0) {
|
||||
@@ -45,9 +226,11 @@ export default function AddToCartSection({ product, showBrandMeta = true, showMo
|
||||
}
|
||||
return ['', '', ''];
|
||||
});
|
||||
const [quantity, setQuantity] = useState(1)
|
||||
|
||||
const { addToCart, toggleCart } = useCartStore()
|
||||
const lang = useLang()
|
||||
const t = UI_TRANSLATIONS[lang] || UI_TRANSLATIONS[lang.slice(0, 2)] || UI_TRANSLATIONS['th']
|
||||
|
||||
const currentVariant = useMemo(() => {
|
||||
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 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) => {
|
||||
if (!product.variants) return false;
|
||||
return product.variants.some(v => {
|
||||
@@ -100,11 +292,11 @@ export default function AddToCartSection({ product, showBrandMeta = true, showMo
|
||||
addToCart({
|
||||
productId: product.id,
|
||||
variantId: currentVariant.id,
|
||||
quantity: 1,
|
||||
quantity,
|
||||
price: currentVariant.price || 0,
|
||||
productName,
|
||||
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)
|
||||
}
|
||||
@@ -118,54 +310,154 @@ export default function AddToCartSection({ product, showBrandMeta = true, showMo
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-6">
|
||||
{/* Title Area */}
|
||||
<div className="space-y-2">
|
||||
{categoryName && (
|
||||
<p className="ui-eyebrow">{categoryName}</p>
|
||||
{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 && (
|
||||
<p className="text-[11px] font-bold uppercase tracking-widest text-red-500">{categoryName}</p>
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
<h1 className="text-4xl font-semibold leading-tight text-text-main md:text-6xl">
|
||||
|
||||
{/* Localized Product Title - Compact and clean */}
|
||||
<h1 className="text-xl md:text-2xl font-bold leading-snug text-text-main tracking-tight">
|
||||
{productName}
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{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-2">
|
||||
<span className="text-3xl md:text-4xl font-black text-red-500 tracking-tight">
|
||||
{formatPrice(currentVariant?.price ?? product.price ?? 0)}
|
||||
</span>
|
||||
{currentVariantName && (
|
||||
<span className="text-xs text-zinc-400 font-medium">/ {currentVariantName}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="text-4xl font-bold text-brand-primary">
|
||||
{formatPrice(currentVariant?.price ?? 0)}
|
||||
</span>
|
||||
{currentVariantName && <span className="text-sm text-muted-text">/ {currentVariantName}</span>}
|
||||
<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 && (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-5">
|
||||
{product.options.map((option, optIndex) => {
|
||||
const optNameBase = getBaseString(option.name);
|
||||
const optNameDisplay = resolveLocalized(option.name, lang);
|
||||
const hasAnySwatch = option.values.some(v => Boolean(getOptionImage(optIndex, getBaseString(v))));
|
||||
|
||||
return (
|
||||
<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">
|
||||
{option.values.map(val => {
|
||||
const baseVal = getBaseString(val);
|
||||
const displayVal = resolveLocalized(val, lang);
|
||||
const isSelected = selectedOptions[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 (
|
||||
<button
|
||||
key={baseVal}
|
||||
disabled={!isAvailable}
|
||||
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
|
||||
? '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
|
||||
? 'border-line-primary/20 bg-surface-card text-muted-text hover:border-brand-primary hover:text-brand-primary'
|
||||
: 'border-line-primary/10 bg-surface-card/50 text-muted-text/30 cursor-not-allowed'
|
||||
? '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/40 text-muted-text/30 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{displayVal}
|
||||
@@ -179,55 +471,101 @@ export default function AddToCartSection({ product, showBrandMeta = true, showMo
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="hidden gap-4 md:flex">
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
disabled={!currentVariant}
|
||||
className={`ui-form-inline-button flex-1 ${!currentVariant ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{currentVariant ? 'Add to Cart' : 'Unavailable'}
|
||||
</button>
|
||||
{categoryName && (
|
||||
<Link
|
||||
href={`/category/${product.category.id}`}
|
||||
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"
|
||||
{/* 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
|
||||
onClick={handleAddToCart}
|
||||
disabled={!currentVariant}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
{categoryName}
|
||||
</Link>
|
||||
)}
|
||||
<ShoppingCart className="w-4 h-4" />
|
||||
<span>{currentVariant ? t.addToCart : t.outOfStock}</span>
|
||||
</button>
|
||||
|
||||
{/* Wishlist 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-red-500 hover:border-red-500/40 transition-colors shrink-0"
|
||||
title={t.wishlist}
|
||||
>
|
||||
<Heart className="w-4 h-4" />
|
||||
</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>
|
||||
|
||||
{/* Trust Badges 4 Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 pt-6 border-t border-line-primary/20 text-xs">
|
||||
{t.badges.map((badge, idx) => {
|
||||
const Icon = idx === 0 ? Truck : idx === 1 ? ShieldCheck : idx === 2 ? RotateCcw : MessageCircle;
|
||||
return (
|
||||
<div key={badge.title} className="flex items-center gap-2.5 p-2 rounded-xl bg-surface-card/40 border border-line-primary/10">
|
||||
<Icon className="w-4 h-4 text-zinc-300 shrink-0" />
|
||||
<div className="leading-tight">
|
||||
<span className="font-bold text-text-main block">{badge.title}</span>
|
||||
<span className="text-[10px] text-zinc-500">{badge.desc}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{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>
|
||||
|
||||
{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 flex-col">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-text/70">
|
||||
{currentVariantName || 'Total Price'}
|
||||
{currentVariantName || t.totalPrice}
|
||||
</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>
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,6 +44,28 @@ export default function ProductStickyPurchaseBar({
|
||||
const variant = product.variants?.[0]
|
||||
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(() => {
|
||||
if (!variant) return ''
|
||||
return [variant.option1, variant.option2, variant.option3]
|
||||
@@ -161,7 +183,9 @@ export default function ProductStickyPurchaseBar({
|
||||
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'}
|
||||
{variant
|
||||
? (addToCartLabels[lang] || addToCartLabels[lang.slice(0, 2)] || addToCartLabels.th)
|
||||
: (unavailableLabels[lang] || unavailableLabels[lang.slice(0, 2)] || unavailableLabels.th)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -78,6 +78,7 @@ export interface Variant {
|
||||
option1: string
|
||||
option2: string
|
||||
option3: string
|
||||
imageUrl?: string | null
|
||||
bomItems?: BomItem[]
|
||||
}
|
||||
|
||||
@@ -187,6 +188,7 @@ export interface ProductAttribute {
|
||||
export interface Product {
|
||||
id: number
|
||||
name: TranslatableString
|
||||
titleGraphicUrl?: string | null
|
||||
description: TranslatableString | null
|
||||
details: TranslatableString | null
|
||||
price: number | null
|
||||
|
||||
Reference in new issue
Block a user