426 lines
14 KiB
TypeScript
426 lines
14 KiB
TypeScript
import React from 'react';
|
|
import { notFound } from 'next/navigation';
|
|
|
|
import { AnnouncementBar } from '@/layout/AnnouncementBar';
|
|
import { Header } from '@/layout/Header';
|
|
import { Navbar } from '@/layout/Navbar';
|
|
import { Footer } from '@/layout/Footer';
|
|
import { FloatingButtons } from '@/components/FloatingButtons';
|
|
import { StickyHeaderSpacer } from '@/components/StickyHeaderSpacer';
|
|
|
|
import { catalogServerService } from '@/services/api/catalogServerService';
|
|
|
|
|
|
import { ProductInteractiveGrid } from '@/components/pdp/ProductInteractiveGrid';
|
|
import { ProductTabsWrapper } from '@/components/pdp/ProductTabsWrapper';
|
|
import { QuickComparisonTable } from '@/components/pdp/QuickComparisonTable';
|
|
import { YouMayAlsoLike } from '@/components/pdp/YouMayAlsoLike';
|
|
|
|
interface ProductDetailPageProps {
|
|
params: Promise<{ slug: string }>;
|
|
}
|
|
|
|
const HIDDEN_SPEC_KEYS = [
|
|
'cost_price',
|
|
'costprice',
|
|
'parent_media_key',
|
|
'parent_category',
|
|
'parent_id',
|
|
'media_key',
|
|
'sku',
|
|
'barcode',
|
|
'compare_price',
|
|
];
|
|
|
|
function normalizeSpecKey(code: string): string {
|
|
return String(code || '').trim().toLowerCase().replace(/[\s-]+/g, '_');
|
|
}
|
|
|
|
function humanizeSpecLabel(code: string): string {
|
|
const key = normalizeSpecKey(code);
|
|
const labels: Record<string, string> = {
|
|
quality: 'Quality',
|
|
qul: 'Quality',
|
|
qual: 'Quality',
|
|
ram: 'RAM',
|
|
storage: 'Storage',
|
|
color: 'Color',
|
|
colour: 'Color',
|
|
clr: 'Color',
|
|
size: 'Size',
|
|
condition: 'Condition',
|
|
in_box_contents: 'In the box',
|
|
warranty_months: 'Warranty',
|
|
country_of_origin: 'Country of origin',
|
|
device_model: 'Device model',
|
|
device_series: 'Device series',
|
|
};
|
|
if (labels[key]) return labels[key];
|
|
return code
|
|
.replace(/[_-]+/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
}
|
|
|
|
function looksLikeId(value?: string | null): boolean {
|
|
if (!value) return true;
|
|
if (value.length > 18 && /[0-9]/.test(value)) return true;
|
|
if (/^[0-9A-Z]{16,}$/i.test(value.replace(/[_-]/g, ''))) return true;
|
|
return false;
|
|
}
|
|
|
|
function extractDeviceModel(product: any, specifications: any[] = []): string {
|
|
if (product.device_model_name && !looksLikeId(product.device_model_name)) {
|
|
return product.device_model_name;
|
|
}
|
|
if (typeof product.device_model === 'object' && product.device_model?.name) {
|
|
return product.device_model.name;
|
|
}
|
|
if (typeof product.device_model === 'string' && product.device_model && !looksLikeId(product.device_model)) {
|
|
return product.device_model;
|
|
}
|
|
|
|
if (Array.isArray(specifications)) {
|
|
const modelRef = specifications.find((s: any) =>
|
|
['model reference', 'device model', 'model', 'device_model', 'model_reference'].includes(
|
|
String(s.label || s.code || s.name || '').toLowerCase()
|
|
)
|
|
);
|
|
if (modelRef && modelRef.value && modelRef.value !== 'N/A' && !looksLikeId(modelRef.value)) {
|
|
return modelRef.value;
|
|
}
|
|
|
|
const seriesRef = specifications.find((s: any) =>
|
|
['series', 'device series', 'device_series'].includes(
|
|
String(s.label || s.code || s.name || '').toLowerCase()
|
|
)
|
|
);
|
|
if (seriesRef && seriesRef.value && seriesRef.value !== 'N/A' && !looksLikeId(seriesRef.value)) {
|
|
return seriesRef.value;
|
|
}
|
|
}
|
|
|
|
if (product.device_series_name && !looksLikeId(product.device_series_name)) {
|
|
return product.device_series_name;
|
|
}
|
|
if (typeof product.device_series === 'object' && product.device_series?.name) {
|
|
return product.device_series.name;
|
|
}
|
|
if (typeof product.device_series === 'string' && product.device_series && !looksLikeId(product.device_series)) {
|
|
return product.device_series;
|
|
}
|
|
|
|
const attrRows = variantAttributeRows(product);
|
|
const devAttr = attrRows.find((a) =>
|
|
['device_model', 'device_series', 'compatible_device', 'supported_device', 'model_reference', 'model'].includes(a.key)
|
|
);
|
|
if (devAttr && devAttr.value && !looksLikeId(devAttr.value)) {
|
|
return devAttr.value;
|
|
}
|
|
|
|
const title = String(product.name || product.title || '').trim();
|
|
if (title) {
|
|
const cleaned = title
|
|
.replace(/\s*(?:with\s+touch\s+screen|with\s+touch|display|screen|touch\s+glass|combo|battery|panel|housing|back\s+glass|charging\s+port|flex\s+cable|spare\s+parts|replacement)\b.*/i, '')
|
|
.replace(/^buy\s+/i, '')
|
|
.trim();
|
|
if (cleaned) return cleaned;
|
|
return title;
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function variantAttributeRows(product: any): { key: string; label: string; value: string }[] {
|
|
const collected = new Map<string, Set<string>>();
|
|
(product.variants || []).forEach((variant: any) => {
|
|
(variant.attributes || []).forEach((attr: any) => {
|
|
const code = String(attr.attribute_code || '');
|
|
const key = normalizeSpecKey(code);
|
|
if (!code || HIDDEN_SPEC_KEYS.some((h) => key === h || key.includes(h))) return;
|
|
if (key.startsWith('parent_') || key.includes('cost')) return;
|
|
const value = String(attr.attribute_value || '').trim();
|
|
if (!value) return;
|
|
if (!collected.has(key)) collected.set(key, new Set());
|
|
collected.get(key)!.add(value);
|
|
});
|
|
});
|
|
|
|
return Array.from(collected.entries()).map(([key, values]) => ({
|
|
key,
|
|
label: humanizeSpecLabel(key),
|
|
value: Array.from(values).join(', '),
|
|
}));
|
|
}
|
|
|
|
function groupSpecs(specifications: any[], product: any) {
|
|
const groups: { title: string; rows: { label: string; value: string }[] }[] = [];
|
|
const attrRows = variantAttributeRows(product);
|
|
|
|
const mapped = (specifications || []).map((spec: any) => ({
|
|
group: String(spec.group || spec.section || spec.category || '').trim(),
|
|
label: String(spec.label || spec.name || spec.key || ''),
|
|
value: String(spec.value ?? spec.val ?? ''),
|
|
})).filter((s) => s.label);
|
|
|
|
const brandValue = !looksLikeId(product.brand_name)
|
|
? product.brand_name
|
|
: !looksLikeId(product.brand_id)
|
|
? product.brand_id
|
|
: 'Gallery';
|
|
|
|
const used = new Set<string>();
|
|
const take = (keys: string[]) =>
|
|
attrRows
|
|
.filter((row) => keys.includes(row.key))
|
|
.map((row) => {
|
|
used.add(row.key);
|
|
return { label: row.label, value: row.value };
|
|
});
|
|
|
|
const general = [
|
|
{ label: 'Brand', value: brandValue },
|
|
{ label: 'Model', value: product.name },
|
|
...take(['device_series', 'device_model', 'condition', 'country_of_origin']),
|
|
];
|
|
const memory = take(['ram', 'storage', 'memory']);
|
|
const appearance = take(['color', 'colour']);
|
|
const pack = [
|
|
...(product.warranty_type ? [{ label: 'Warranty Type', value: product.warranty_type }] : []),
|
|
...(product.warranty_summary ? [{ label: 'Warranty Details', value: product.warranty_summary }] : []),
|
|
...take(['in_box_contents', 'warranty_months', 'warranty']),
|
|
];
|
|
const leftover = attrRows
|
|
.filter((row) => !used.has(row.key))
|
|
.map((row) => ({ label: row.label, value: row.value }));
|
|
|
|
groups.push({ title: 'GENERAL INFORMATION', rows: general });
|
|
if (memory.length) groups.push({ title: 'MEMORY & STORAGE', rows: memory });
|
|
if (appearance.length) groups.push({ title: 'APPEARANCE', rows: appearance });
|
|
if (pack.length) groups.push({ title: 'PACKAGE & WARRANTY', rows: pack });
|
|
if (leftover.length) groups.push({ title: 'ADDITIONAL DETAILS', rows: leftover });
|
|
|
|
if (mapped.some((s) => s.group)) {
|
|
const bucket: Record<string, { label: string; value: string }[]> = {};
|
|
mapped.forEach((s) => {
|
|
const key = (s.group || 'PRODUCT DETAILS').toUpperCase();
|
|
if (!bucket[key]) bucket[key] = [];
|
|
bucket[key].push({ label: s.label, value: s.value });
|
|
});
|
|
Object.entries(bucket).forEach(([title, rows]) => {
|
|
if (rows.length) groups.push({ title, rows });
|
|
});
|
|
return groups;
|
|
}
|
|
|
|
if (mapped.length) {
|
|
groups.push({
|
|
title: 'PRODUCT DETAILS',
|
|
rows: mapped.map((s) => ({ label: s.label, value: s.value })),
|
|
});
|
|
}
|
|
|
|
return groups;
|
|
}
|
|
|
|
export default async function ProductDetailPage({ params }: ProductDetailPageProps) {
|
|
const { slug } = await params;
|
|
|
|
if (!slug) {
|
|
return notFound();
|
|
}
|
|
|
|
let data: any = null;
|
|
try {
|
|
data = await catalogServerService.getProductDetail(slug);
|
|
} catch (err) {
|
|
console.error('Failed to load product details on server:', err);
|
|
return notFound();
|
|
}
|
|
|
|
if (!data || !data.product) {
|
|
return notFound();
|
|
}
|
|
|
|
const { product, reviews = [], related_products = [], specifications = [] } = data;
|
|
const related = related_products.slice(0, 5);
|
|
const comparisonProducts = [product, ...related_products.filter((p: any) => p.product_id !== product.product_id)].slice(0, 5);
|
|
const specGroups = groupSpecs(specifications, product);
|
|
|
|
const additionalInfoRows: { label: string; value: string }[] = [];
|
|
|
|
// Supported Brand (auto-fetched)
|
|
let brandVal = product.brand_name || product.brand?.name || '';
|
|
if (looksLikeId(brandVal)) {
|
|
brandVal = '';
|
|
}
|
|
if (!brandVal) {
|
|
const brandAttr = variantAttributeRows(product).find((a) => a.key === 'brand' || a.key === 'brand_name');
|
|
if (brandAttr && !looksLikeId(brandAttr.value)) {
|
|
brandVal = brandAttr.value;
|
|
}
|
|
}
|
|
if (brandVal) {
|
|
additionalInfoRows.push({ label: 'Supported Brand', value: brandVal });
|
|
}
|
|
|
|
// Supported Device (auto-fetched exact device model)
|
|
let deviceVal = extractDeviceModel(product, specifications);
|
|
if (deviceVal) {
|
|
additionalInfoRows.push({ label: 'Supported Device', value: deviceVal });
|
|
}
|
|
|
|
const wType = product.warranty_type || 'Brand Warranty';
|
|
additionalInfoRows.push({ label: 'Warranty Type', value: wType });
|
|
|
|
if (product.warranty_summary || product.warranty_details) {
|
|
additionalInfoRows.push({
|
|
label: 'Warranty Details',
|
|
value: product.warranty_summary || product.warranty_details,
|
|
});
|
|
}
|
|
|
|
const attrRowsForPack = variantAttributeRows(product);
|
|
attrRowsForPack.forEach((attr) => {
|
|
if (['in_box_contents', 'warranty_months', 'warranty'].includes(attr.key)) {
|
|
if (!additionalInfoRows.some((p) => p.label.toLowerCase() === attr.label.toLowerCase())) {
|
|
additionalInfoRows.push({ label: attr.label, value: attr.value });
|
|
}
|
|
}
|
|
});
|
|
|
|
const descriptionHtml = (
|
|
<div>
|
|
{product.description ? (
|
|
<div className="text-[13px] text-[#555] leading-relaxed px-5 py-4">
|
|
<div
|
|
className="product-description-html prose prose-sm max-w-none text-[13px] text-[#555] leading-relaxed"
|
|
dangerouslySetInnerHTML={{ __html: product.description }}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="text-[13px] text-gray-400 px-5 py-6">
|
|
No description available for this product.
|
|
</div>
|
|
)}
|
|
|
|
{additionalInfoRows.length > 0 && (
|
|
<div className="border-t border-[#e0e0e0]">
|
|
<table className="w-full border-collapse text-[13px]">
|
|
<tbody>
|
|
<tr>
|
|
<th
|
|
colSpan={2}
|
|
className="text-left text-primary font-bold uppercase tracking-wide text-[13px] bg-[#f6f7f9] px-5 py-3 border-b border-[#e0e0e0]"
|
|
>
|
|
ADDITIONAL INFORMATION
|
|
</th>
|
|
</tr>
|
|
{additionalInfoRows.map((row, idx) => {
|
|
const isLast = idx === additionalInfoRows.length - 1;
|
|
return (
|
|
<tr key={`pw-${row.label}`}>
|
|
<td
|
|
className={`w-[28%] align-top font-semibold text-[#444] px-5 py-3.5 ${
|
|
isLast ? '' : 'border-b border-[#e0e0e0]'
|
|
}`}
|
|
>
|
|
{row.label}
|
|
</td>
|
|
<td
|
|
className={`align-top text-[#555] px-5 py-3.5 ${
|
|
isLast ? '' : 'border-b border-[#e0e0e0]'
|
|
}`}
|
|
>
|
|
{row.value}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
const specsHtml = (
|
|
<table className="w-full border-collapse text-[13px]">
|
|
<tbody>
|
|
{specGroups.map((group, groupIdx) => (
|
|
<React.Fragment key={group.title}>
|
|
<tr>
|
|
<th
|
|
colSpan={2}
|
|
className={`text-left text-primary font-bold uppercase tracking-wide text-[13px] bg-[#f6f7f9] px-5 py-3 border-b border-[#e0e0e0] ${
|
|
groupIdx > 0 ? 'border-t border-[#e0e0e0]' : ''
|
|
}`}
|
|
>
|
|
{group.title}
|
|
</th>
|
|
</tr>
|
|
{group.rows.map((row, rowIdx) => {
|
|
const isLast =
|
|
groupIdx === specGroups.length - 1 && rowIdx === group.rows.length - 1;
|
|
return (
|
|
<tr key={`${group.title}-${row.label}`}>
|
|
<td
|
|
className={`w-[28%] align-top font-semibold text-[#444] px-5 py-3.5 ${
|
|
isLast ? '' : 'border-b border-[#e0e0e0]'
|
|
}`}
|
|
>
|
|
{row.label}
|
|
</td>
|
|
<td
|
|
className={`align-top text-[#555] px-5 py-3.5 ${
|
|
isLast ? '' : 'border-b border-[#e0e0e0]'
|
|
}`}
|
|
>
|
|
{row.value}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</React.Fragment>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
);
|
|
|
|
return (
|
|
<div className="min-h-screen bg-white text-[#1E293B] flex flex-col font-sans">
|
|
<AnnouncementBar />
|
|
<Header />
|
|
<StickyHeaderSpacer />
|
|
<Navbar />
|
|
|
|
<main className="flex-grow pb-24 text-gray-800 antialiased">
|
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-5">
|
|
<ProductInteractiveGrid
|
|
product={product}
|
|
initialReviewsCount={reviews.length}
|
|
relatedProducts={related}
|
|
/>
|
|
|
|
<div className="mt-10">
|
|
<ProductTabsWrapper
|
|
descriptionHtml={descriptionHtml}
|
|
specsHtml={specsHtml}
|
|
showSpecifications={product.show_specifications ?? true}
|
|
/>
|
|
</div>
|
|
|
|
{related.length > 0 && <YouMayAlsoLike products={related} />}
|
|
|
|
{comparisonProducts.length > 0 && (
|
|
<QuickComparisonTable products={comparisonProducts} />
|
|
)}
|
|
</div>
|
|
</main>
|
|
|
|
<Footer />
|
|
<FloatingButtons />
|
|
</div>
|
|
);
|
|
}
|