x
This commit is contained in:
1 parent
07e9413a6b
commit
46d504e483
4 files changed
+112
-38
No files matched your search
@@ -13,6 +13,24 @@ function resolveStr(v: TranslatableString | null | undefined, lang: string): str
|
|||||||
return v[lang] ?? v[lang.slice(0, 2)] ?? v['th'] ?? v['en'] ?? Object.values(v)[0] ?? ''
|
return v[lang] ?? v[lang.slice(0, 2)] ?? v['th'] ?? v['en'] ?? Object.values(v)[0] ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveOptionValue(val: string, options: any[] | null | undefined, lang: string): string {
|
||||||
|
if (!val) return '';
|
||||||
|
if (!options || !Array.isArray(options)) return val;
|
||||||
|
|
||||||
|
for (const option of options) {
|
||||||
|
if (!option.values || !Array.isArray(option.values)) continue;
|
||||||
|
for (const v of option.values) {
|
||||||
|
const baseStr = typeof v === 'string' ? v : (v['th'] || v['en'] || Object.values(v)[0] || '');
|
||||||
|
if (baseStr === val) {
|
||||||
|
if (typeof v === 'string') return v;
|
||||||
|
return v[lang] ?? v[lang.slice(0, 2)] ?? v['th'] ?? v['en'] ?? Object.values(v)[0] ?? baseStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
interface ProductCardProps {
|
interface ProductCardProps {
|
||||||
product: Product;
|
product: Product;
|
||||||
enableQuickShop?: boolean;
|
enableQuickShop?: boolean;
|
||||||
@@ -100,7 +118,7 @@ export default function ProductCard({ product, enableQuickShop = false, category
|
|||||||
: 'border-pop-black/10 bg-transparent text-pop-black/70 hover:border-pop-black/30 hover:bg-pop-black/5'
|
: 'border-pop-black/10 bg-transparent text-pop-black/70 hover:border-pop-black/30 hover:bg-pop-black/5'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{[variant.option1, variant.option2, variant.option3].filter(Boolean).join(' / ')}
|
{[variant.option1, variant.option2, variant.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / ')}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -131,7 +149,7 @@ export default function ProductCard({ product, enableQuickShop = false, category
|
|||||||
quantity: 1,
|
quantity: 1,
|
||||||
price: selectedVariant.price || 0,
|
price: selectedVariant.price || 0,
|
||||||
productName: displayName,
|
productName: displayName,
|
||||||
variantName: [selectedVariant.option1, selectedVariant.option2, selectedVariant.option3].filter(Boolean).join(' / '),
|
variantName: [selectedVariant.option1, selectedVariant.option2, selectedVariant.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / '),
|
||||||
productImage: product.images?.[0]?.url || getCategoryImage(displayCategoryName)
|
productImage: product.images?.[0]?.url || getCategoryImage(displayCategoryName)
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -16,10 +16,28 @@ import DOMPurify from 'dompurify'
|
|||||||
function resolveTranslatable(value: TranslatableString | null | undefined, lang: string): string {
|
function resolveTranslatable(value: TranslatableString | null | undefined, lang: string): string {
|
||||||
if (!value) return ''
|
if (!value) return ''
|
||||||
if (typeof value === 'string') return value
|
if (typeof value === 'string') return value
|
||||||
// Record<string, string>: ลอง lang เต็ม → ลอง prefix 2 ตัว → fallback 'th'/'en'
|
// Record<string, string>: ลอง lang เต็ม → ลอง prefix 2 double-char → fallback 'th'/'en'
|
||||||
return value[lang] ?? value[lang.slice(0, 2)] ?? value['th'] ?? value['en'] ?? Object.values(value)[0] ?? ''
|
return value[lang] ?? value[lang.slice(0, 2)] ?? value['th'] ?? value['en'] ?? Object.values(value)[0] ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveOptionValue(val: string, options: any[] | null | undefined, lang: string): string {
|
||||||
|
if (!val) return '';
|
||||||
|
if (!options || !Array.isArray(options)) return val;
|
||||||
|
|
||||||
|
for (const option of options) {
|
||||||
|
if (!option.values || !Array.isArray(option.values)) continue;
|
||||||
|
for (const v of option.values) {
|
||||||
|
const baseStr = typeof v === 'string' ? v : (v['th'] || v['en'] || Object.values(v)[0] || '');
|
||||||
|
if (baseStr === val) {
|
||||||
|
if (typeof v === 'string') return v;
|
||||||
|
return v[lang] ?? v[lang.slice(0, 2)] ?? v['th'] ?? v['en'] ?? Object.values(v)[0] ?? baseStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProductDetailPage() {
|
export default function ProductDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
|
|
||||||
@@ -134,7 +152,7 @@ export default function ProductDetailPage() {
|
|||||||
quantity: 1,
|
quantity: 1,
|
||||||
price: currentVariant.price || product.price || 0,
|
price: currentVariant.price || product.price || 0,
|
||||||
productName: displayName,
|
productName: displayName,
|
||||||
variantName: [currentVariant.option1, currentVariant.option2, currentVariant.option3].filter(Boolean).join(' / '),
|
variantName: [currentVariant.option1, currentVariant.option2, currentVariant.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / '),
|
||||||
productImage: product.images?.[0]?.url || getCategoryImage(typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.en || '')
|
productImage: product.images?.[0]?.url || getCategoryImage(typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.en || '')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -204,7 +222,7 @@ export default function ProductDetailPage() {
|
|||||||
{formatPrice(currentVariant?.price)}
|
{formatPrice(currentVariant?.price)}
|
||||||
</span>
|
</span>
|
||||||
{currentVariant && (
|
{currentVariant && (
|
||||||
<span className="text-spa-stone/60 text-sm font-semibold">/ {[currentVariant.option1, currentVariant.option2, currentVariant.option3].filter(Boolean).join(' / ')}</span>
|
<span className="text-spa-stone/60 text-sm font-semibold">/ {[currentVariant.option1, currentVariant.option2, currentVariant.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / ')}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -224,7 +242,7 @@ export default function ProductDetailPage() {
|
|||||||
: 'bg-spa-light text-spa-stone border-spa-stone/20 hover:border-spa-forest hover:text-spa-forest shadow-sm'
|
: 'bg-spa-light text-spa-stone border-spa-stone/20 hover:border-spa-forest hover:text-spa-forest shadow-sm'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{[v.option1, v.option2, v.option3].filter(Boolean).join(' / ')}
|
{[v.option1, v.option2, v.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / ')}
|
||||||
<span className={`block text-[10px] mt-0.5 ${selectedVariant === i ? 'text-spa-light' : 'text-spa-stone/60'}`}>
|
<span className={`block text-[10px] mt-0.5 ${selectedVariant === i ? 'text-spa-light' : 'text-spa-stone/60'}`}>
|
||||||
{formatPrice(v.price)}
|
{formatPrice(v.price)}
|
||||||
</span>
|
</span>
|
||||||
@@ -337,7 +355,7 @@ export default function ProductDetailPage() {
|
|||||||
: 'bg-spa-light border-spa-stone/20 text-spa-stone'
|
: 'bg-spa-light border-spa-stone/20 text-spa-stone'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{[v.option1, v.option2, v.option3].filter(Boolean).join(' / ')}
|
{[v.option1, v.option2, v.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / ')}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -346,7 +364,7 @@ export default function ProductDetailPage() {
|
|||||||
<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] uppercase tracking-widest text-spa-stone/60 font-semibold">
|
<span className="text-[10px] uppercase tracking-widest text-spa-stone/60 font-semibold">
|
||||||
{product.variants && product.variants.length > 1 ? [currentVariant?.option1, currentVariant?.option2, currentVariant?.option3].filter(Boolean).join(' / ') : t('cart.totalPrice')}
|
{product.variants && product.variants.length > 1 ? [currentVariant?.option1, currentVariant?.option2, currentVariant?.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / ') : t('cart.totalPrice')}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xl font-bold text-rose-500">
|
<span className="text-xl font-bold text-rose-500">
|
||||||
{formatPrice(currentVariant?.price)}
|
{formatPrice(currentVariant?.price)}
|
||||||
|
|||||||
@@ -7,6 +7,30 @@ 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'
|
||||||
|
|
||||||
|
function getBaseString(val: any): string {
|
||||||
|
if (!val) return '';
|
||||||
|
if (typeof val === 'string') return val;
|
||||||
|
return val['th'] || val['en'] || Object.values(val)[0] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOptionValue(val: string, options: any[] | null | undefined, lang: string): string {
|
||||||
|
if (!val) return '';
|
||||||
|
if (!options || !Array.isArray(options)) return val;
|
||||||
|
|
||||||
|
for (const option of options) {
|
||||||
|
if (!option.values || !Array.isArray(option.values)) continue;
|
||||||
|
for (const v of option.values) {
|
||||||
|
const baseStr = typeof v === 'string' ? v : (v['th'] || v['en'] || Object.values(v)[0] || '');
|
||||||
|
if (baseStr === val) {
|
||||||
|
if (typeof v === 'string') return v;
|
||||||
|
return v[lang] ?? v[lang.slice(0, 2)] ?? v['th'] ?? v['en'] ?? Object.values(v)[0] ?? baseStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
product: Product
|
product: Product
|
||||||
}
|
}
|
||||||
@@ -66,7 +90,10 @@ export default function AddToCartSection({ product }: Props) {
|
|||||||
const handleAddToCart = () => {
|
const handleAddToCart = () => {
|
||||||
if (!currentVariant) return
|
if (!currentVariant) return
|
||||||
|
|
||||||
const variantName = [currentVariant.option1, currentVariant.option2, currentVariant.option3].filter(Boolean).join(' / ');
|
const variantName = [currentVariant.option1, currentVariant.option2, currentVariant.option3]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(opt => resolveOptionValue(opt, product.options, lang))
|
||||||
|
.join(' / ');
|
||||||
|
|
||||||
addToCart({
|
addToCart({
|
||||||
productId: product.id,
|
productId: product.id,
|
||||||
@@ -80,7 +107,12 @@ export default function AddToCartSection({ product }: Props) {
|
|||||||
toggleCart(true)
|
toggleCart(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentVariantName = currentVariant ? [currentVariant.option1, currentVariant.option2, currentVariant.option3].filter(Boolean).join(' / ') : null;
|
const currentVariantName = currentVariant
|
||||||
|
? [currentVariant.option1, currentVariant.option2, currentVariant.option3]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(opt => resolveOptionValue(opt, product.options, lang))
|
||||||
|
.join(' / ')
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -108,34 +140,40 @@ export default function AddToCartSection({ product }: Props) {
|
|||||||
|
|
||||||
{product.options && product.options.length > 0 && (
|
{product.options && product.options.length > 0 && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{product.options.map((option, optIndex) => (
|
{product.options.map((option, optIndex) => {
|
||||||
<div key={option.name} className="space-y-3">
|
const optNameBase = getBaseString(option.name);
|
||||||
<p className="text-xs font-bold uppercase tracking-widest text-muted-text">Select {option.name}</p>
|
const optNameDisplay = resolveLocalized(option.name, lang);
|
||||||
<div className="flex flex-wrap gap-3">
|
return (
|
||||||
{option.values.map(val => {
|
<div key={optNameBase} className="space-y-3">
|
||||||
const isSelected = selectedOptions[optIndex] === val;
|
<p className="text-xs font-bold uppercase tracking-widest text-muted-text">Select {optNameDisplay}</p>
|
||||||
const isAvailable = isOptionAvailable(optIndex, val);
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{option.values.map(val => {
|
||||||
return (
|
const baseVal = getBaseString(val);
|
||||||
<button
|
const displayVal = resolveLocalized(val, lang);
|
||||||
key={val}
|
const isSelected = selectedOptions[optIndex] === baseVal;
|
||||||
disabled={!isAvailable}
|
const isAvailable = isOptionAvailable(optIndex, baseVal);
|
||||||
onClick={() => handleOptionChange(optIndex, val)}
|
|
||||||
className={`rounded-xl border px-5 py-3 text-sm font-bold uppercase tracking-widest transition ${
|
return (
|
||||||
isSelected
|
<button
|
||||||
? 'border-brand-primary bg-brand-primary text-surface-card shadow-card'
|
key={baseVal}
|
||||||
: isAvailable
|
disabled={!isAvailable}
|
||||||
? 'border-line-primary/20 bg-surface-card text-muted-text hover:border-brand-primary hover:text-brand-primary'
|
onClick={() => handleOptionChange(optIndex, baseVal)}
|
||||||
: 'border-line-primary/10 bg-surface-card/50 text-muted-text/30 cursor-not-allowed'
|
className={`rounded-xl border px-5 py-3 text-sm font-bold uppercase tracking-widest transition ${
|
||||||
}`}
|
isSelected
|
||||||
>
|
? 'border-brand-primary bg-brand-primary text-surface-card shadow-card'
|
||||||
{val}
|
: isAvailable
|
||||||
</button>
|
? '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'
|
||||||
})}
|
}`}
|
||||||
|
>
|
||||||
|
{displayVal}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export interface Product {
|
|||||||
categoryId: number
|
categoryId: number
|
||||||
category: Category
|
category: Category
|
||||||
brand: Brand
|
brand: Brand
|
||||||
options?: Array<{ name: string; values: string[] }> | null
|
options?: Array<{ name: TranslatableString; values: TranslatableString[] }> | null
|
||||||
variants: Variant[]
|
variants: Variant[]
|
||||||
images: ProductImage[]
|
images: ProductImage[]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user