370 lines
11 KiB
TypeScript
370 lines
11 KiB
TypeScript
import { parseMetadata, resolveIsActive } from '@/lib/layoutConfig';
|
||
import { detectProductKind, type CompareKind } from '@/lib/compareSpecs';
|
||
|
||
export type PdpFact = { label: string; value: string };
|
||
export type PdpTrustBadge = { title: string; icon?: string };
|
||
|
||
const FACT_PRIORITY = [
|
||
'ram',
|
||
'storage',
|
||
'memory',
|
||
'color',
|
||
'colour',
|
||
'condition',
|
||
'size',
|
||
'warranty',
|
||
'warranty_months',
|
||
];
|
||
|
||
function normalizeKey(value?: string | null): string {
|
||
return String(value || '')
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/[\s-]+/g, '_');
|
||
}
|
||
|
||
const MISSING_SPEC_VALUES = new Set([
|
||
'n/a',
|
||
'na',
|
||
'n.a',
|
||
'n.a.',
|
||
'-',
|
||
'--',
|
||
'—',
|
||
'–',
|
||
'none',
|
||
'null',
|
||
'undefined',
|
||
'not available',
|
||
'not applicable',
|
||
'tbd',
|
||
'unknown',
|
||
]);
|
||
|
||
export function isMissingSpecValue(value?: string | null): boolean {
|
||
const trimmed = String(value ?? '').trim();
|
||
if (!trimmed) return true;
|
||
const compact = trimmed.toLowerCase().replace(/[_/]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||
return MISSING_SPEC_VALUES.has(compact) || MISSING_SPEC_VALUES.has(compact.replace(/\s/g, ''));
|
||
}
|
||
|
||
function prettyValue(label: string, value: string): string {
|
||
const trimmed = value.trim();
|
||
if (isMissingSpecValue(trimmed)) return '';
|
||
if (normalizeKey(label) === 'color' || normalizeKey(label) === 'colour') {
|
||
return trimmed.replace(/\b\w/g, (c) => c.toUpperCase());
|
||
}
|
||
return trimmed;
|
||
}
|
||
|
||
function factLabel(code: string, name?: string): string {
|
||
const key = normalizeKey(name || code);
|
||
const labels: Record<string, string> = {
|
||
quality: 'Quality',
|
||
qul: 'Quality',
|
||
qual: 'Quality',
|
||
memory: 'Memory',
|
||
ram: 'RAM',
|
||
storage: 'Storage',
|
||
color: 'Color',
|
||
colour: 'Color',
|
||
clr: 'Color',
|
||
condition: 'Condition',
|
||
size: 'Size',
|
||
warranty: 'Warranty',
|
||
warranty_months: 'Warranty',
|
||
compatible_with: 'Compatible with',
|
||
type: 'Type',
|
||
connector: 'Connector',
|
||
output: 'Output',
|
||
material: 'Material',
|
||
processor: 'Processor',
|
||
display: 'Display',
|
||
connectivity: 'Connectivity',
|
||
series: 'Series',
|
||
};
|
||
if (labels[key]) return labels[key];
|
||
if (name && name.trim()) return name.trim();
|
||
return code.replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||
}
|
||
|
||
export function extractPdpFacts(
|
||
product: {
|
||
variants?: any[];
|
||
specifications?: { label?: string; value?: string }[];
|
||
},
|
||
selectedVariant?: any | null,
|
||
extraSpecs: { label: string; value: string }[] = []
|
||
): PdpFact[] {
|
||
const variant = selectedVariant || product.variants?.[0] || null;
|
||
const facts: PdpFact[] = [];
|
||
const seen = new Set<string>();
|
||
|
||
const push = (label: string, value: string) => {
|
||
const pretty = prettyValue(label, value);
|
||
const key = normalizeKey(label);
|
||
if (!pretty || seen.has(key)) return;
|
||
seen.add(key);
|
||
facts.push({ label, value: pretty });
|
||
};
|
||
|
||
(variant?.attributes || []).forEach((attr: any) => {
|
||
const key = normalizeKey(attr.attribute_code || attr.attribute_name);
|
||
if (!FACT_PRIORITY.includes(key)) return;
|
||
push(factLabel(attr.attribute_code || '', attr.attribute_name), String(attr.attribute_value || ''));
|
||
});
|
||
|
||
extraSpecs.forEach((spec) => {
|
||
const key = normalizeKey(spec.label);
|
||
if (!['series', 'model', 'model_reference', 'condition'].includes(key)) return;
|
||
push(spec.label, spec.value);
|
||
});
|
||
|
||
(product.specifications || []).forEach((spec) => {
|
||
const key = normalizeKey(spec.label);
|
||
if (!['series', 'model', 'model_reference', 'condition'].includes(key)) return;
|
||
push(String(spec.label || ''), String(spec.value || ''));
|
||
});
|
||
|
||
return facts.slice(0, 4);
|
||
}
|
||
|
||
const QUICK_VIEW_KIND_KEYS: Record<CompareKind | 'phone', string[]> = {
|
||
phone: ['ram', 'storage', 'color', 'condition', 'series'],
|
||
laptop: ['processor', 'ram', 'storage', 'display'],
|
||
watch: ['size', 'color', 'compatible_with', 'condition'],
|
||
case: ['compatible_with', 'color', 'material', 'condition'],
|
||
camera: ['compatible_with', 'type', 'color', 'condition'],
|
||
charger: ['connector', 'output', 'compatible_with', 'color'],
|
||
protector: ['compatible_with', 'material', 'type'],
|
||
audio: ['compatible_with', 'connectivity', 'color'],
|
||
speaker: ['compatible_with', 'connectivity', 'color'],
|
||
general: ['compatible_with', 'type', 'color', 'condition', 'size'],
|
||
};
|
||
|
||
const QUICK_VIEW_TYPE_LABEL: Record<CompareKind, string> = {
|
||
audio: 'Headphones',
|
||
speaker: 'Speaker',
|
||
case: 'Phone case',
|
||
camera: 'Mobile camera',
|
||
charger: 'Charger / cable',
|
||
phone: 'Smartphone',
|
||
laptop: 'Laptop',
|
||
watch: 'Smartwatch',
|
||
protector: 'Screen protector',
|
||
general: 'Accessory',
|
||
};
|
||
|
||
function resolveQuickViewKind(product: { device_type?: string | null; name?: string; slug?: string; full_path?: string }): CompareKind {
|
||
const deviceType = normalizeKey(product.device_type);
|
||
if (['mobile', 'phone', 'smartphone', 'feature_phone', 'tablet', 'ipad'].includes(deviceType)) {
|
||
return 'phone';
|
||
}
|
||
if (['laptop', 'notebook', 'ultrabook'].includes(deviceType)) return 'laptop';
|
||
if (['watch', 'smartwatch', 'wearable'].includes(deviceType)) return 'watch';
|
||
return detectProductKind(product as any);
|
||
}
|
||
|
||
export function extractQuickViewSpecs(
|
||
product: {
|
||
name?: string;
|
||
device_type?: string | null;
|
||
variants?: any[];
|
||
specifications?: { label?: string; value?: string }[];
|
||
compatible_devices?: { name?: string }[];
|
||
},
|
||
selectedVariant?: any | null,
|
||
extraSpecs: { label?: string; value?: string }[] = []
|
||
): PdpFact[] {
|
||
const kind = resolveQuickViewKind(product);
|
||
const wanted = QUICK_VIEW_KIND_KEYS[kind] || QUICK_VIEW_KIND_KEYS.general;
|
||
const bag = new Map<string, { label: string; values: string[] }>();
|
||
|
||
const add = (rawLabel: string, rawValue: string) => {
|
||
const label = factLabel(rawLabel, rawLabel);
|
||
const pretty = prettyValue(label, rawValue);
|
||
const key = normalizeKey(label);
|
||
if (!pretty || key === 'brand' || key === 'manufacturer') return;
|
||
if (!bag.has(key)) bag.set(key, { label, values: [] });
|
||
const entry = bag.get(key)!;
|
||
if (!entry.values.some((value) => value.toLowerCase() === pretty.toLowerCase())) {
|
||
entry.values.push(pretty);
|
||
}
|
||
};
|
||
|
||
const variant = selectedVariant || product.variants?.[0] || null;
|
||
(variant?.attributes || []).forEach((attr: any) => {
|
||
add(attr.attribute_name || attr.attribute_code || '', String(attr.attribute_value || ''));
|
||
});
|
||
|
||
(product.variants || []).forEach((item: any) => {
|
||
(item?.attributes || []).forEach((attr: any) => {
|
||
add(attr.attribute_name || attr.attribute_code || '', String(attr.attribute_value || ''));
|
||
});
|
||
});
|
||
|
||
[...extraSpecs, ...(product.specifications || [])].forEach((spec) => {
|
||
add(String(spec.label || ''), String(spec.value || ''));
|
||
});
|
||
|
||
const deviceNames = (product.compatible_devices || [])
|
||
.map((device) => String(device?.name || '').trim())
|
||
.filter((name) => name && !isMissingSpecValue(name) && name.length > 1);
|
||
if (deviceNames.length) {
|
||
add('Compatible with', deviceNames.slice(0, 2).join(', '));
|
||
}
|
||
|
||
const forMatch = String(product.name || '').match(/\bfor\s+(.+)$/i);
|
||
if (forMatch?.[1]) add('Compatible with', forMatch[1].replace(/\s+/g, ' ').trim());
|
||
|
||
if (kind !== 'phone' && kind !== 'laptop' && kind !== 'general') {
|
||
add('Type', QUICK_VIEW_TYPE_LABEL[kind]);
|
||
}
|
||
|
||
const facts: PdpFact[] = [];
|
||
const used = new Set<string>();
|
||
wanted.forEach((key) => {
|
||
const entry = bag.get(key);
|
||
if (!entry || used.has(key)) return;
|
||
used.add(key);
|
||
facts.push({
|
||
label: entry.label,
|
||
value: entry.values.slice(0, 3).join(', '),
|
||
});
|
||
});
|
||
|
||
return facts.slice(0, 4);
|
||
}
|
||
|
||
const TRUST_DEVICE_TYPES = new Set([
|
||
'mobile',
|
||
'phone',
|
||
'smartphone',
|
||
'feature_phone',
|
||
'laptop',
|
||
'notebook',
|
||
'ultrabook',
|
||
'tablet',
|
||
'ipad',
|
||
'watch',
|
||
'smartwatch',
|
||
'wearable',
|
||
'desktop',
|
||
'pc',
|
||
'computer',
|
||
'tv',
|
||
'television',
|
||
'console',
|
||
'gaming',
|
||
]);
|
||
|
||
const TRUST_CATEGORY_KEYS = new Set([
|
||
'phones',
|
||
'phone',
|
||
'mobiles',
|
||
'mobile',
|
||
'smartphones',
|
||
'laptops',
|
||
'laptop',
|
||
'notebooks',
|
||
'tablets',
|
||
'tablet',
|
||
'computers',
|
||
'wearables',
|
||
'smart_watches',
|
||
'smartwatches',
|
||
'spare_parts',
|
||
'mobile_display',
|
||
'mobile_battery',
|
||
'mother_board',
|
||
'motherboard',
|
||
'mobile_back_panel',
|
||
'mobile_camera',
|
||
]);
|
||
|
||
const ACCESSORY_CATEGORY_KEYS = new Set([
|
||
'accessories',
|
||
'accessory',
|
||
'back_covers',
|
||
'back_cover',
|
||
'mobile_cases',
|
||
'mobile_case',
|
||
'cases',
|
||
'screen_guards',
|
||
'screen_guard',
|
||
'camera_lens_guards',
|
||
'usb_c_cables',
|
||
'charging_adapters',
|
||
'mobile_charger',
|
||
'charging',
|
||
'cables',
|
||
'earbuds',
|
||
'headphones',
|
||
'audio',
|
||
'bluetooth_speaker',
|
||
]);
|
||
|
||
function categoryChainKeys(
|
||
product: { category_id?: string; full_path?: string; category?: { slug?: string; name?: string } },
|
||
categories: { category_id: string; parent_category_id?: string | null; slug?: string; name?: string }[] = []
|
||
): string[] {
|
||
const keys: string[] = [];
|
||
const byId = new Map(categories.map((cat) => [cat.category_id, cat]));
|
||
let current = product.category_id ? byId.get(product.category_id) : undefined;
|
||
while (current) {
|
||
keys.push(normalizeKey(current.slug), normalizeKey(current.name));
|
||
current = current.parent_category_id ? byId.get(current.parent_category_id) : undefined;
|
||
}
|
||
const firstPath = String(product.full_path || '')
|
||
.split('/')
|
||
.map((part) => part.trim())
|
||
.filter(Boolean)[0];
|
||
if (firstPath) keys.push(normalizeKey(firstPath));
|
||
if (product.category?.slug) keys.push(normalizeKey(product.category.slug));
|
||
if (product.category?.name) keys.push(normalizeKey(product.category.name));
|
||
return keys.filter(Boolean);
|
||
}
|
||
|
||
export function productShowsTrustBadges(
|
||
product: {
|
||
device_type?: string | null;
|
||
category_id?: string;
|
||
full_path?: string;
|
||
category?: { slug?: string; name?: string };
|
||
},
|
||
categories: { category_id: string; parent_category_id?: string | null; slug?: string; name?: string }[] = []
|
||
): boolean {
|
||
return true;
|
||
}
|
||
|
||
const DEFAULT_HOMEPAGE_TRUST_BADGES: PdpTrustBadge[] = [
|
||
{ title: '6-Month Spare Parts Warranty', icon: 'ShieldCheck' },
|
||
{ title: 'Skilled Tech Diagnostics', icon: 'Wrench' },
|
||
{ title: 'Fast Doorstep Repairs', icon: 'Clock' },
|
||
];
|
||
|
||
export function parseTrustBadgesFromLayout(layoutData: unknown): PdpTrustBadge[] {
|
||
const rows = Array.isArray(layoutData) ? layoutData : [];
|
||
const section = rows.find((item: any) => {
|
||
const region = String(item?.region || item?.metadata?.section_key || item?.type || '').toLowerCase();
|
||
return region === 'trust_badges' || region === 'badges' || region === 'sec_trust';
|
||
});
|
||
|
||
if (section && resolveIsActive(section) === false) return [];
|
||
|
||
if (section) {
|
||
const meta = parseMetadata(section);
|
||
const features = Array.isArray(meta.features) ? meta.features : [];
|
||
const parsed = features
|
||
.map((item: any) => ({
|
||
title: String(item?.title || '').trim(),
|
||
icon: String(item?.icon || '').trim() || undefined,
|
||
}))
|
||
.filter((item: PdpTrustBadge) => item.title)
|
||
.slice(0, 3);
|
||
|
||
if (parsed.length > 0) return parsed;
|
||
}
|
||
|
||
return DEFAULT_HOMEPAGE_TRUST_BADGES;
|
||
}
|