ifixkart-storefront/lib/compareSpecs.ts

428 lines
15 KiB
TypeScript

import { ProductResponse } from '@/services/api/catalogService';
import { getCatalogPriceInfo, getImageUrl, parseMoney } from '@/lib/utils';
export type SpecValue = {
text: string;
chips?: string[];
tone?: 'default' | 'muted' | 'success' | 'danger' | 'best';
};
export type SpecRow = {
label: string;
values: SpecValue[];
};
export type ProductSpecItem = { label: string; value: string };
const COLOR_HINT =
/black|white|grey|gray|red|blue|green|purple|gold|silver|starlight|midnight|titanium|sierra|storm|pink|orange|yellow|graphite|ultramarine|natural|desert|deep/i;
function titleCase(value: string): string {
return value
.replace(/\s+/g, ' ')
.trim()
.replace(/\b([a-z])/g, (letter) => letter.toUpperCase());
}
export function formatComparePrice(amount: number | string): string {
const n = parseMoney(amount);
if (!n) return '—';
return new Intl.NumberFormat('en-IN', {
style: 'currency',
currency: 'INR',
maximumFractionDigits: 0,
}).format(n);
}
export function humanizeOption(raw: string, product?: ProductResponse): string {
let s = String(raw || '').trim();
if (!s) return '';
const slug = String(product?.slug || '').toLowerCase();
let lower = s.toLowerCase();
if (slug && lower.includes(slug)) {
lower = lower.split(slug).join(' ');
}
lower = lower
.replace(/apple[-_]?mobile[-_]?(camera|case|caseless|cover|phone)?/g, ' ')
.replace(/\b(apple|samsung|google|nothing|oneplus|iphone|pixel|product)\b/g, ' ')
.replace(/[-_]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const words = lower.split(' ').filter(Boolean);
if (!words.length) return titleCase(s.replace(/[-_]+/g, ' '));
if (words.length > 3) {
const colorish = words.filter((word) => COLOR_HINT.test(word));
return titleCase((colorish.length ? colorish : words).slice(-2).join(' '));
}
return titleCase(words.join(' '));
}
function uniqueLabels(values: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
values.forEach((value) => {
const key = value.toLowerCase().trim();
if (!key || key === '—' || seen.has(key)) return;
seen.add(key);
out.push(value);
});
return out;
}
export function productImage(product: ProductResponse): string {
const extra = product as ProductResponse & { thumbnail_url?: string };
const candidates = [
product.images?.[0]?.image_url,
product.images?.[1]?.image_url,
extra.thumbnail_url,
...(product.variants || []).flatMap((variant) =>
(variant.images || []).map((image) => image.image_url)
),
];
for (const candidate of candidates) {
const url = getImageUrl(candidate);
if (url) return url;
}
return '';
}
export function stockInfo(product: ProductResponse): SpecValue {
const stocks = (product.variants || [])
.map((variant) => variant.available_stock)
.filter((n): n is number => typeof n === 'number');
if (!stocks.length) return { text: 'Check availability', tone: 'muted' };
const total = stocks.reduce((sum, n) => sum + n, 0);
if (total <= 0) return { text: 'Out of stock', tone: 'danger' };
if (total <= 5) return { text: `Only ${total} left`, tone: 'danger' };
return { text: 'In stock', tone: 'success' };
}
export function colorChips(product: ProductResponse): string[] {
const raw: string[] = [];
if (Array.isArray(product.colors)) raw.push(...product.colors);
if (product.color) raw.push(product.color);
(product.variants || []).forEach((variant) => {
(variant.attributes || []).forEach((attr) => {
const key = `${attr.attribute_code || ''} ${attr.attribute_name || ''}`.toLowerCase();
if (key.includes('color') || key.includes('colour')) {
raw.push(attr.attribute_value);
}
});
});
return uniqueLabels(
raw.map((value) => humanizeOption(value, product)).filter((value) => value.length > 1)
).slice(0, 3);
}
function specValue(specMap: Record<string, ProductSpecItem[]>, productId: string, matcher: RegExp): string {
const match = (specMap[productId] || []).find((item) => matcher.test(item.label));
return match?.value?.trim() || '';
}
function attributeValues(product: ProductResponse, matcher: RegExp): string[] {
return uniqueLabels(
(product.variants || [])
.flatMap((variant) => variant.attributes || [])
.filter((attr) => matcher.test(`${attr.attribute_code || ''} ${attr.attribute_name || ''}`))
.map((attr) => humanizeOption(attr.attribute_value, product))
.filter(Boolean)
).slice(0, 3);
}
function rowFromSources(
products: ProductResponse[],
specMap: Record<string, ProductSpecItem[]>,
matcher: RegExp
): SpecRow['values'] {
return products.map((product) => {
const fromSpec = specValue(specMap, product.product_id, matcher);
const fromAttrs = attributeValues(product, matcher);
const chips = uniqueLabels(
[...fromAttrs, ...(fromSpec ? fromSpec.split(/[,|/]+/).map((part) => humanizeOption(part, product)) : [])]
.filter(Boolean)
).slice(0, 3);
if (!chips.length) return { text: '—' };
return { text: chips.join(', '), chips };
});
}
export function valuesDiffer(values: SpecValue[]): boolean {
const unique = new Set(values.map((value) => value.text.trim().toLowerCase()));
return unique.size > 1;
}
export type CompareKind =
| 'audio'
| 'speaker'
| 'case'
| 'camera'
| 'charger'
| 'phone'
| 'laptop'
| 'watch'
| 'protector'
| 'general';
function productHaystack(product: ProductResponse): string {
const extra = product as ProductResponse & { category_name?: string };
return [product.name, product.slug, product.full_path, extra.category_name]
.filter(Boolean)
.join(' ')
.toLowerCase();
}
export function detectProductKind(product: ProductResponse): CompareKind {
const hay = productHaystack(product);
if (/headphone|earbud|earphone|airpod|headset/.test(hay)) return 'audio';
if (/speaker|soundbar/.test(hay)) return 'speaker';
if (/(^|[\s-])(case|cover|bumper)([\s-]|$)/.test(hay) || hay.includes('cases')) return 'case';
if (/camera|lens/.test(hay)) return 'camera';
if (/charger|charging|cable|adapter|power.?bank|usb/.test(hay)) return 'charger';
if (/glass|protector|tempered|screen guard/.test(hay)) return 'protector';
if (/watch|band|strap/.test(hay)) return 'watch';
if (/laptop|macbook|notebook/.test(hay)) return 'laptop';
if (/\b(iphone|pixel|galaxy|smartphone|mobile phone)\b/.test(hay) && !/camera|case|cover/.test(hay)) {
return 'phone';
}
return 'general';
}
export function dominantCompareKind(products: ProductResponse[]): CompareKind {
const counts = new Map<CompareKind, number>();
products.forEach((product) => {
const kind = detectProductKind(product);
counts.set(kind, (counts.get(kind) || 0) + 1);
});
return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || 'general';
}
export function compareKindLabel(kind: CompareKind): string {
const labels: Record<CompareKind, string> = {
audio: 'headphones',
speaker: 'speakers',
case: 'phone cases',
camera: 'cameras',
charger: 'chargers & cables',
phone: 'smartphones',
laptop: 'laptops',
watch: 'smartwatches',
protector: 'screen protectors',
general: 'accessories',
};
return labels[kind];
}
export function sameTypeCompareList(list: ProductResponse[]): ProductResponse[] {
if (list.length <= 1) return list;
const kind = detectProductKind(list[0]);
return list.filter((product) => detectProductKind(product) === kind);
}
export function isSameCompareKind(
list: ProductResponse[],
product: ProductResponse,
ignoreId?: string
): boolean {
const peers = ignoreId
? list.filter((item) => item.product_id !== ignoreId && item.slug !== ignoreId)
: list;
if (!peers.length) return true;
return detectProductKind(peers[0]) === detectProductKind(product);
}
export function lockedCompareKind(
list: ProductResponse[],
replaceProductId?: string
): CompareKind | null {
const peers = replaceProductId
? list.filter(
(item) => item.product_id !== replaceProductId && item.slug !== replaceProductId
)
: list;
if (!peers.length) return null;
return detectProductKind(peers[0]);
}
function compatibleDevice(product: ProductResponse, specMap: Record<string, ProductSpecItem[]>): string {
const named = String(product.name || '').match(/\bfor\s+(.+)$/i);
if (named?.[1]) return named[1].replace(/\s+/g, ' ').trim();
const fromSpec = specValue(specMap, product.product_id, /compatible|fits|model$/i);
if (fromSpec && fromSpec.length > 3 && /[a-z]/i.test(fromSpec)) return fromSpec;
return '';
}
function connectivity(product: ProductResponse, specMap: Record<string, ProductSpecItem[]>): string {
const fromSpec = specValue(specMap, product.product_id, /bluetooth|wireless|wired|connectivity/i);
if (fromSpec) return fromSpec;
const hay = productHaystack(product);
if (/bluetooth|wireless|airpod/.test(hay)) return 'Wireless';
if (/wired|aux/.test(hay)) return 'Wired';
return '';
}
function productTypeLabel(product: ProductResponse): string {
const labels: Record<CompareKind, string> = {
audio: 'Headphones',
speaker: 'Speaker',
case: 'Phone case',
camera: 'Camera',
charger: 'Charger / cable',
phone: 'Smartphone',
laptop: 'Laptop',
watch: 'Smartwatch',
protector: 'Screen protector',
general: 'Accessory',
};
return labels[detectProductKind(product)];
}
function cellFromMatch(
products: ProductResponse[],
specMap: Record<string, ProductSpecItem[]>,
matcher: RegExp,
infer?: (product: ProductResponse) => string
): SpecRow['values'] {
return products.map((product) => {
const inferred = infer?.(product)?.trim() || '';
const sourced = rowFromSources([product], specMap, matcher)[0];
if (sourced.text && sourced.text !== '—') return sourced;
return { text: inferred || '—' };
});
}
function brandCells(products: ProductResponse[], specMap: Record<string, ProductSpecItem[]>): SpecRow['values'] {
return products.map((product) => ({
text: product.brand_name?.trim() || specValue(specMap, product.product_id, /brand|manufacturer/i) || '—',
}));
}
function colourCells(products: ProductResponse[]): SpecRow['values'] {
return products.map((product) => {
const chips = colorChips(product);
return chips.length ? { text: chips.join(', ') } : { text: '—' };
});
}
const TYPE_FIELDS: Record<
CompareKind,
Array<{
label: string;
match?: RegExp;
infer?: (product: ProductResponse, specMap: Record<string, ProductSpecItem[]>) => string;
colours?: boolean;
brand?: boolean;
}>
> = {
audio: [
{ label: 'Brand', brand: true },
{ label: 'Type', infer: (product) => productTypeLabel(product) },
{ label: 'Compatible with', infer: (product, specMap) => compatibleDevice(product, specMap) },
{ label: 'Connectivity', infer: (product, specMap) => connectivity(product, specMap), match: /bluetooth|wireless|wired|connectivity/i },
{ label: 'Colour', colours: true },
],
speaker: [
{ label: 'Brand', brand: true },
{ label: 'Type', infer: () => 'Speaker' },
{ label: 'Compatible with', infer: (product, specMap) => compatibleDevice(product, specMap) },
{ label: 'Connectivity', infer: (product, specMap) => connectivity(product, specMap) },
{ label: 'Colour', colours: true },
],
case: [
{ label: 'Brand', brand: true },
{ label: 'Compatible with', infer: (product, specMap) => compatibleDevice(product, specMap) },
{ label: 'Colour', colours: true },
{ label: 'Material', match: /material|finish|case type/i },
],
camera: [
{ label: 'Brand', brand: true },
{ label: 'Compatible with', infer: (product, specMap) => compatibleDevice(product, specMap) },
{ label: 'Colour', colours: true },
{ label: 'Type', infer: () => 'Mobile camera' },
],
charger: [
{ label: 'Brand', brand: true },
{ label: 'Compatible with', infer: (product, specMap) => compatibleDevice(product, specMap) },
{ label: 'Output', match: /watt|output|power|amp/i },
{ label: 'Connector', match: /connector|port|usb|type.?c|lightning/i },
{ label: 'Colour', colours: true },
],
phone: [
{ label: 'Brand', brand: true },
{ label: 'Storage', match: /storage|memory|rom|capacity/i },
{ label: 'Display', match: /display|screen/i },
{ label: 'Battery', match: /battery/i },
{ label: 'Colour', colours: true },
],
laptop: [
{ label: 'Brand', brand: true },
{ label: 'Processor', match: /processor|cpu|chip/i },
{ label: 'RAM', match: /\bram\b|memory/i },
{ label: 'Storage', match: /storage|ssd|hdd/i },
{ label: 'Display', match: /display|screen/i },
],
watch: [
{ label: 'Brand', brand: true },
{ label: 'Compatible with', infer: (product, specMap) => compatibleDevice(product, specMap) },
{ label: 'Size', match: /size|mm|case size/i },
{ label: 'Colour', colours: true },
],
protector: [
{ label: 'Brand', brand: true },
{ label: 'Compatible with', infer: (product, specMap) => compatibleDevice(product, specMap) },
{ label: 'Material', match: /material|glass|tempered/i },
],
general: [
{ label: 'Brand', brand: true },
{ label: 'Type', infer: (product) => productTypeLabel(product) },
{ label: 'Compatible with', infer: (product, specMap) => compatibleDevice(product, specMap) },
{ label: 'Colour', colours: true },
],
};
export function buildTypeCompareRows(
products: ProductResponse[],
specMap: Record<string, ProductSpecItem[]> = {}
): SpecRow[] {
if (!products.length) return [];
const kind = dominantCompareKind(products);
return TYPE_FIELDS[kind]
.map((field) => {
let values: SpecRow['values'];
if (field.brand) values = brandCells(products, specMap);
else if (field.colours) values = colourCells(products);
else if (field.match) {
values = cellFromMatch(products, specMap, field.match, (product) =>
field.infer ? field.infer(product, specMap) : ''
);
} else {
values = products.map((product) => ({
text: field.infer?.(product, specMap) || '—',
}));
}
return { label: field.label, values };
})
.filter((row) => row.values.some((value) => value.text && value.text !== '—'));
}
/** @deprecated use buildTypeCompareRows */
export function buildCompareRows(
products: ProductResponse[],
specMap: Record<string, ProductSpecItem[]> = {}
): SpecRow[] {
return buildTypeCompareRows(products, specMap);
}
export function cheapestProductId(products: ProductResponse[]): string | null {
let bestId: string | null = null;
let bestPrice = Number.POSITIVE_INFINITY;
products.forEach((product) => {
const price = getCatalogPriceInfo(product).price;
if (price > 0 && price < bestPrice) {
bestPrice = price;
bestId = product.product_id;
}
});
const matches = products.filter((product) => getCatalogPriceInfo(product).price === bestPrice);
return matches.length === 1 ? bestId : null;
}