428 lines
15 KiB
TypeScript
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;
|
|
}
|