mirror of
https://github.com/sakaljurgis/best-choice.git
synced 2026-07-08 21:47:40 +00:00
split items section
This commit is contained in:
@@ -10,17 +10,15 @@ function AppLayout() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { data, isLoading, isError } = useProjectsQuery();
|
const { data, isLoading, isError } = useProjectsQuery();
|
||||||
|
|
||||||
const projects = data?.data ?? [];
|
|
||||||
|
|
||||||
const recentProjects = useMemo(
|
const recentProjects = useMemo(
|
||||||
() =>
|
() =>
|
||||||
[...projects]
|
[...(data?.data ?? [])]
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||||
)
|
)
|
||||||
.slice(0, 3),
|
.slice(0, 3),
|
||||||
[projects]
|
[data?.data]
|
||||||
);
|
);
|
||||||
|
|
||||||
const isInitialDesktop = typeof window !== 'undefined'
|
const isInitialDesktop = typeof window !== 'undefined'
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
interface ProjectItemsAddItemSectionProps {
|
||||||
|
quickUrl: string;
|
||||||
|
quickError: string | null;
|
||||||
|
onQuickUrlChange: (value: string) => void;
|
||||||
|
onAddUrl: () => void;
|
||||||
|
onAddManual: () => void;
|
||||||
|
isImportingFromUrl: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectItemsAddItemSection({
|
||||||
|
quickUrl,
|
||||||
|
quickError,
|
||||||
|
onQuickUrlChange,
|
||||||
|
onAddUrl,
|
||||||
|
onAddManual,
|
||||||
|
isImportingFromUrl
|
||||||
|
}: ProjectItemsAddItemSectionProps) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-2xl border border-dashed border-slate-300 bg-slate-50/60 p-6">
|
||||||
|
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500">Add Item</h3>
|
||||||
|
<p className="mt-2 text-sm text-slate-500">
|
||||||
|
Paste a product link to pre-fill details or switch to manual entry.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex flex-col gap-4 md:flex-row md:items-end md:gap-6">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-xs font-medium text-slate-600" htmlFor="quick-item-url">
|
||||||
|
Item URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="quick-item-url"
|
||||||
|
type="url"
|
||||||
|
value={quickUrl}
|
||||||
|
onChange={(event) => onQuickUrlChange(event.target.value)}
|
||||||
|
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
|
||||||
|
placeholder="https://example.com/product"
|
||||||
|
/>
|
||||||
|
{quickError ? <p className="mt-2 text-xs font-medium text-red-600">{quickError}</p> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAddUrl}
|
||||||
|
disabled={isImportingFromUrl}
|
||||||
|
className="inline-flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-400"
|
||||||
|
>
|
||||||
|
{isImportingFromUrl ? (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-4 w-4 animate-spin text-white"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Importing…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Add from URL'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAddManual}
|
||||||
|
className="rounded-md border border-blue-600 px-4 py-2 text-sm font-semibold text-blue-600 transition hover:bg-blue-50"
|
||||||
|
>
|
||||||
|
Add Manually
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { Fragment } from 'react';
|
||||||
|
import { ExternalLink, Pencil } from 'lucide-react';
|
||||||
|
import type { Item } from '@shared/models/item';
|
||||||
|
import { ItemPricesPanel } from './item-prices-panel';
|
||||||
|
import { formatAttributeValue, getPriceSummaryDisplay } from './project-items-utils';
|
||||||
|
|
||||||
|
interface ProjectItemsDesktopTableProps {
|
||||||
|
items: Item[];
|
||||||
|
visibleAttributes: string[];
|
||||||
|
expandedItemId: string | null;
|
||||||
|
onTogglePrices: (itemId: string) => void;
|
||||||
|
onEditItem: (item: Item) => void;
|
||||||
|
projectId: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectItemsDesktopTable({
|
||||||
|
items,
|
||||||
|
visibleAttributes,
|
||||||
|
expandedItemId,
|
||||||
|
onTogglePrices,
|
||||||
|
onEditItem,
|
||||||
|
projectId
|
||||||
|
}: ProjectItemsDesktopTableProps) {
|
||||||
|
return (
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-slate-200 text-sm">
|
||||||
|
<thead className="bg-slate-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-medium uppercase tracking-wide text-slate-500">
|
||||||
|
Item
|
||||||
|
</th>
|
||||||
|
{visibleAttributes.map((attribute) => (
|
||||||
|
<th
|
||||||
|
key={attribute}
|
||||||
|
className="px-4 py-3 text-left font-medium uppercase tracking-wide text-slate-500"
|
||||||
|
>
|
||||||
|
{attribute}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th className="px-4 py-3 text-left font-medium uppercase tracking-wide text-slate-500">
|
||||||
|
Prices
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-200">
|
||||||
|
{items.map((item) => {
|
||||||
|
const attributes = (item.attributes ?? {}) as Record<string, unknown>;
|
||||||
|
const priceDisplay = getPriceSummaryDisplay(item.priceSummary);
|
||||||
|
const isExpanded = expandedItemId === item.id;
|
||||||
|
const panelId = `item-${item.id}-prices-desktop`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment key={item.id}>
|
||||||
|
<tr className={`hover:bg-slate-50 ${isExpanded ? 'bg-slate-50' : ''}`}>
|
||||||
|
<td className="px-4 py-3 align-top">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onEditItem(item)}
|
||||||
|
className="group inline-flex items-center gap-2 rounded-md text-left text-slate-900 transition hover:text-blue-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||||
|
title="Edit item details"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={`inline-block h-2.5 w-2.5 rounded-full ${
|
||||||
|
item.status === 'active' ? 'bg-emerald-500' : 'bg-slate-300'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="font-semibold group-hover:underline">
|
||||||
|
{item.manufacturer ? `${item.manufacturer} ${item.model}` : item.model}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{item.sourceUrl ? (
|
||||||
|
<a
|
||||||
|
href={item.sourceUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-blue-600 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||||
|
title="Open source link"
|
||||||
|
>
|
||||||
|
<ExternalLink aria-hidden className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Open source link</span>
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{item.note ? (
|
||||||
|
<span className="text-xs text-slate-500">{item.note}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{visibleAttributes.map((attribute) => (
|
||||||
|
<td key={attribute} className="px-4 py-3 align-top text-xs text-slate-600">
|
||||||
|
{formatAttributeValue(attributes[attribute])}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className="px-4 py-3 align-top">
|
||||||
|
<div className="flex flex-col gap-0.5 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-slate-900">
|
||||||
|
{priceDisplay.primary}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onTogglePrices(item.id)}
|
||||||
|
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-slate-500 transition hover:text-slate-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||||
|
aria-label="Edit Prices"
|
||||||
|
title="Edit Prices"
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
aria-controls={panelId}
|
||||||
|
>
|
||||||
|
<Pencil aria-hidden className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Edit Prices</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{priceDisplay.secondary ? (
|
||||||
|
<span className="text-slate-500">{priceDisplay.secondary}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{isExpanded ? (
|
||||||
|
<tr className="bg-slate-50">
|
||||||
|
<td colSpan={visibleAttributes.length + 2} className="px-4 py-4">
|
||||||
|
<div id={panelId}>
|
||||||
|
<ItemPricesPanel itemId={item.id} projectId={projectId} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
interface ProjectItemsHeaderProps {
|
||||||
|
itemCount: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
showDifferencesOnly: boolean;
|
||||||
|
hasAttributeDifferences: boolean;
|
||||||
|
onToggleDifferences: (value: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectItemsHeader({
|
||||||
|
itemCount,
|
||||||
|
isLoading,
|
||||||
|
showDifferencesOnly,
|
||||||
|
hasAttributeDifferences,
|
||||||
|
onToggleDifferences
|
||||||
|
}: ProjectItemsHeaderProps) {
|
||||||
|
const subtitle = isLoading
|
||||||
|
? 'Loading items…'
|
||||||
|
: itemCount
|
||||||
|
? `${itemCount} item${itemCount === 1 ? '' : 's'} tracked.`
|
||||||
|
: 'No items yet – add your first one below.';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="mb-6 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-slate-900">Items</h2>
|
||||||
|
<p className="text-sm text-slate-500">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500 disabled:cursor-not-allowed disabled:border-slate-200 disabled:text-slate-300"
|
||||||
|
checked={showDifferencesOnly}
|
||||||
|
onChange={(event) => onToggleDifferences(event.target.checked)}
|
||||||
|
disabled={!hasAttributeDifferences}
|
||||||
|
/>
|
||||||
|
Show differences only
|
||||||
|
</label>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { ExternalLink, Pencil } from 'lucide-react';
|
||||||
|
import type { Item } from '@shared/models/item';
|
||||||
|
import { ItemPricesPanel } from './item-prices-panel';
|
||||||
|
import { formatAttributeValue, getPriceSummaryDisplay } from './project-items-utils';
|
||||||
|
|
||||||
|
interface ProjectItemsMobileListProps {
|
||||||
|
items: Item[];
|
||||||
|
visibleAttributes: string[];
|
||||||
|
expandedItemId: string | null;
|
||||||
|
onTogglePrices: (itemId: string) => void;
|
||||||
|
onEditItem: (item: Item) => void;
|
||||||
|
projectId: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectItemsMobileList({
|
||||||
|
items,
|
||||||
|
visibleAttributes,
|
||||||
|
expandedItemId,
|
||||||
|
onTogglePrices,
|
||||||
|
onEditItem,
|
||||||
|
projectId
|
||||||
|
}: ProjectItemsMobileListProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 md:hidden">
|
||||||
|
{items.map((item) => {
|
||||||
|
const attributes = (item.attributes ?? {}) as Record<string, unknown>;
|
||||||
|
const priceDisplay = getPriceSummaryDisplay(item.priceSummary);
|
||||||
|
const isExpanded = expandedItemId === item.id;
|
||||||
|
const panelId = `item-${item.id}-prices-mobile`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.id} className="rounded-xl border border-slate-200 p-4 shadow-sm">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onEditItem(item)}
|
||||||
|
className="group flex flex-1 items-center gap-2 text-left text-slate-900 transition hover:text-blue-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||||
|
title="Edit item details"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={`inline-block h-2.5 w-2.5 rounded-full ${
|
||||||
|
item.status === 'active' ? 'bg-emerald-500' : 'bg-slate-300'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="font-semibold group-hover:underline">
|
||||||
|
{item.manufacturer ? `${item.manufacturer} ${item.model}` : item.model}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{item.sourceUrl ? (
|
||||||
|
<a
|
||||||
|
href={item.sourceUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex h-9 w-9 items-center justify-center rounded-md text-blue-600 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||||
|
title="Open source link"
|
||||||
|
>
|
||||||
|
<ExternalLink aria-hidden className="h-5 w-5" />
|
||||||
|
<span className="sr-only">Open source link</span>
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.note ? <p className="mt-1 text-sm text-slate-600">Note: {item.note}</p> : null}
|
||||||
|
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{visibleAttributes.map((attribute) => (
|
||||||
|
<div key={attribute} className="flex justify-between text-xs text-slate-600">
|
||||||
|
<span className="font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
{attribute}
|
||||||
|
</span>
|
||||||
|
<span className="text-right">{formatAttributeValue(attributes[attribute])}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-col gap-0.5 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-slate-900">{priceDisplay.primary}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onTogglePrices(item.id)}
|
||||||
|
className="inline-flex h-9 w-9 items-center justify-center rounded-md text-slate-500 transition hover:text-slate-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||||
|
aria-label="Edit Prices"
|
||||||
|
title="Edit Prices"
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
aria-controls={panelId}
|
||||||
|
>
|
||||||
|
<Pencil aria-hidden className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Edit Prices</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{priceDisplay.secondary ? (
|
||||||
|
<span className="text-slate-500">{priceDisplay.secondary}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{isExpanded ? (
|
||||||
|
<div id={panelId} className="mt-4">
|
||||||
|
<ItemPricesPanel itemId={item.id} projectId={projectId} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { ExternalLink, Pencil } from 'lucide-react';
|
|
||||||
import type { Item } from '@shared/models/item';
|
import type { Item } from '@shared/models/item';
|
||||||
import { ItemPricesPanel } from './item-prices-panel';
|
import { areAttributeValuesEqual } from './project-items-utils';
|
||||||
|
import { ProjectItemsHeader } from './project-items-header';
|
||||||
|
import { ProjectItemsDesktopTable } from './project-items-desktop-table';
|
||||||
|
import { ProjectItemsMobileList } from './project-items-mobile-list';
|
||||||
|
import { ProjectItemsAddItemSection } from './project-items-add-item-section';
|
||||||
|
|
||||||
interface ProjectItemsSectionProps {
|
interface ProjectItemsSectionProps {
|
||||||
items: Item[];
|
items: Item[];
|
||||||
@@ -18,195 +21,6 @@ interface ProjectItemsSectionProps {
|
|||||||
projectId: string | undefined;
|
projectId: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatAttributeValue = (value: unknown): string => {
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
if (typeof value === 'boolean') {
|
|
||||||
return value ? 'Yes' : 'No';
|
|
||||||
}
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
if (value.length === 0) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
const formattedValues = value
|
|
||||||
.map((item) => {
|
|
||||||
if (item === null || item === undefined) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
if (typeof item === 'string') {
|
|
||||||
return item.trim();
|
|
||||||
}
|
|
||||||
if (typeof item === 'number') {
|
|
||||||
return String(item);
|
|
||||||
}
|
|
||||||
if (typeof item === 'boolean') {
|
|
||||||
return item ? 'Yes' : 'No';
|
|
||||||
}
|
|
||||||
return JSON.stringify(item);
|
|
||||||
})
|
|
||||||
.filter((item) => item.length > 0);
|
|
||||||
|
|
||||||
return formattedValues.length ? formattedValues.join(', ') : '—';
|
|
||||||
}
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
return value.length ? value : '—';
|
|
||||||
}
|
|
||||||
if (typeof value === 'number') {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
return JSON.stringify(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const createSortKey = (value: unknown): string => {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return `array:${value.length}:${value.map((item) => createSortKey(item)).join('|')}`;
|
|
||||||
}
|
|
||||||
if (value === null) {
|
|
||||||
return 'null';
|
|
||||||
}
|
|
||||||
const type = typeof value;
|
|
||||||
if (type === 'number') {
|
|
||||||
return `${type}:${(value as number).toString()}`;
|
|
||||||
}
|
|
||||||
if (type === 'boolean') {
|
|
||||||
return `${type}:${value ? '1' : '0'}`;
|
|
||||||
}
|
|
||||||
if (type === 'string') {
|
|
||||||
return `${type}:${value}`;
|
|
||||||
}
|
|
||||||
return `${type}:${JSON.stringify(value)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeAttributeValue = (value: unknown): unknown => {
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
const normalizedItems = value.map((item) => normalizeAttributeValue(item));
|
|
||||||
normalizedItems.sort((first, second) => {
|
|
||||||
const firstKey = createSortKey(first);
|
|
||||||
const secondKey = createSortKey(second);
|
|
||||||
if (firstKey < secondKey) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (firstKey > secondKey) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
return normalizedItems;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const areAttributeValuesEqual = (first: unknown, second: unknown): boolean => {
|
|
||||||
const normalizedFirst = normalizeAttributeValue(first);
|
|
||||||
const normalizedSecond = normalizeAttributeValue(second);
|
|
||||||
|
|
||||||
if (Array.isArray(normalizedFirst) && Array.isArray(normalizedSecond)) {
|
|
||||||
if (normalizedFirst.length !== normalizedSecond.length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
for (let index = 0; index < normalizedFirst.length; index += 1) {
|
|
||||||
if (!areAttributeValuesEqual(normalizedFirst[index], normalizedSecond[index])) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.is(normalizedFirst, normalizedSecond);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatPriceAmount = (amount: number, currency: string | null): string => {
|
|
||||||
if (!Number.isFinite(amount)) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
if (currency) {
|
|
||||||
try {
|
|
||||||
return new Intl.NumberFormat(undefined, {
|
|
||||||
style: 'currency',
|
|
||||||
currency,
|
|
||||||
maximumFractionDigits: 2
|
|
||||||
}).format(amount);
|
|
||||||
} catch {
|
|
||||||
return `${amount.toFixed(2)} ${currency}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return amount.toFixed(2);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPriceSummaryDisplay = (
|
|
||||||
summary: Item['priceSummary'] | null
|
|
||||||
): { primary: string; secondary: string | null } => {
|
|
||||||
if (!summary || summary.priceCount === 0) {
|
|
||||||
return { primary: '—', secondary: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolveCurrency = (preferred: string | null) => preferred ?? summary.currency ?? null;
|
|
||||||
const formatConditionLabel = (
|
|
||||||
condition: 'new' | 'used',
|
|
||||||
amount: number | null,
|
|
||||||
count: number,
|
|
||||||
currency: string | null,
|
|
||||||
hasMixedCurrency: boolean
|
|
||||||
): string | null => {
|
|
||||||
if (!count) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (amount === null || hasMixedCurrency) {
|
|
||||||
return `${condition} prices vary (${count})`;
|
|
||||||
}
|
|
||||||
const resolvedCurrency = resolveCurrency(currency);
|
|
||||||
const formatted = formatPriceAmount(amount, resolvedCurrency);
|
|
||||||
return `${condition} from ${formatted} (${count})`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const newLabel = formatConditionLabel(
|
|
||||||
'new',
|
|
||||||
summary.newMinAmount,
|
|
||||||
summary.newCount,
|
|
||||||
summary.newCurrency,
|
|
||||||
summary.newHasMixedCurrency
|
|
||||||
);
|
|
||||||
const usedLabel = formatConditionLabel(
|
|
||||||
'used',
|
|
||||||
summary.usedMinAmount,
|
|
||||||
summary.usedCount,
|
|
||||||
summary.usedCurrency,
|
|
||||||
summary.usedHasMixedCurrency
|
|
||||||
);
|
|
||||||
|
|
||||||
if (newLabel || usedLabel) {
|
|
||||||
if (newLabel && usedLabel) {
|
|
||||||
return { primary: newLabel, secondary: usedLabel };
|
|
||||||
}
|
|
||||||
return { primary: newLabel ?? usedLabel ?? '—', secondary: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!summary.hasMixedCurrency) {
|
|
||||||
const resolvedCurrency = resolveCurrency(null);
|
|
||||||
const formattedMin = formatPriceAmount(summary.minAmount, resolvedCurrency);
|
|
||||||
const formattedMax = formatPriceAmount(summary.maxAmount, resolvedCurrency);
|
|
||||||
if (summary.minAmount === summary.maxAmount) {
|
|
||||||
return {
|
|
||||||
primary: `${formattedMin} (${summary.priceCount})`,
|
|
||||||
secondary: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
primary: `from ${formattedMin} (${summary.priceCount})`,
|
|
||||||
secondary: `up to ${formattedMax}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
primary: `Multiple currencies (${summary.priceCount})`,
|
|
||||||
secondary: null
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ProjectItemsSection({
|
export function ProjectItemsSection({
|
||||||
items,
|
items,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -295,28 +109,13 @@ export function ProjectItemsSection({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
|
<section className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||||
<header className="mb-6 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<ProjectItemsHeader
|
||||||
<div>
|
itemCount={items.length}
|
||||||
<h2 className="text-xl font-semibold text-slate-900">Items</h2>
|
isLoading={isLoading}
|
||||||
<p className="text-sm text-slate-500">
|
showDifferencesOnly={showDifferencesOnly}
|
||||||
{isLoading
|
hasAttributeDifferences={hasAttributeDifferences}
|
||||||
? 'Loading items…'
|
onToggleDifferences={(value) => setShowDifferencesOnly(value)}
|
||||||
: items.length
|
|
||||||
? `${items.length} item${items.length === 1 ? '' : 's'} tracked.`
|
|
||||||
: 'No items yet – add your first one below.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<label className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-slate-500">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500 disabled:cursor-not-allowed disabled:border-slate-200 disabled:text-slate-300"
|
|
||||||
checked={showDifferencesOnly}
|
|
||||||
onChange={(event) => setShowDifferencesOnly(event.target.checked)}
|
|
||||||
disabled={!hasAttributeDifferences}
|
|
||||||
/>
|
/>
|
||||||
Show differences only
|
|
||||||
</label>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<p className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{error.message}</p>
|
<p className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{error.message}</p>
|
||||||
@@ -328,281 +127,33 @@ export function ProjectItemsSection({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="hidden md:block">
|
<ProjectItemsDesktopTable
|
||||||
<div className="overflow-x-auto">
|
items={items}
|
||||||
<table className="min-w-full divide-y divide-slate-200 text-sm">
|
visibleAttributes={visibleAttributes}
|
||||||
<thead className="bg-slate-50">
|
expandedItemId={expandedItemId}
|
||||||
<tr>
|
onTogglePrices={handleTogglePrices}
|
||||||
<th className="px-4 py-3 text-left font-medium uppercase tracking-wide text-slate-500">
|
onEditItem={onEditItem}
|
||||||
Item
|
projectId={projectId}
|
||||||
</th>
|
|
||||||
{visibleAttributes.map((attribute) => (
|
|
||||||
<th
|
|
||||||
key={attribute}
|
|
||||||
className="px-4 py-3 text-left font-medium uppercase tracking-wide text-slate-500"
|
|
||||||
>
|
|
||||||
{attribute}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
<th className="px-4 py-3 text-left font-medium uppercase tracking-wide text-slate-500">
|
|
||||||
Prices
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-200">
|
|
||||||
{items.map((item) => {
|
|
||||||
const attributes = (item.attributes ?? {}) as Record<string, unknown>;
|
|
||||||
const priceDisplay = getPriceSummaryDisplay(item.priceSummary);
|
|
||||||
const isExpanded = expandedItemId === item.id;
|
|
||||||
const desktopPanelId = `item-${item.id}-prices-desktop`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Fragment key={item.id}>
|
|
||||||
<tr className={`hover:bg-slate-50 ${isExpanded ? 'bg-slate-50' : ''}`}>
|
|
||||||
<td className="px-4 py-3 align-top">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onEditItem(item)}
|
|
||||||
className="group inline-flex items-center gap-2 rounded-md text-left text-slate-900 transition hover:text-blue-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
|
||||||
title="Edit item details"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className={`inline-block h-2.5 w-2.5 rounded-full ${
|
|
||||||
item.status === 'active' ? 'bg-emerald-500' : 'bg-slate-300'
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
<span className="font-semibold group-hover:underline">
|
|
||||||
{item.manufacturer ? `${item.manufacturer} ${item.model}` : item.model}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{item.sourceUrl ? (
|
|
||||||
<a
|
|
||||||
href={item.sourceUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-blue-600 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
|
||||||
title="Open source link"
|
|
||||||
>
|
|
||||||
<ExternalLink aria-hidden className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Open source link</span>
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{item.note ? (
|
|
||||||
<span className="text-xs text-slate-500">{item.note}</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
{visibleAttributes.map((attribute) => (
|
|
||||||
<td key={attribute} className="px-4 py-3 align-top text-xs text-slate-600">
|
|
||||||
{formatAttributeValue(attributes[attribute])}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
<td className="px-4 py-3 align-top">
|
|
||||||
<div className="flex flex-col gap-0.5 text-xs">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-semibold text-slate-900">
|
|
||||||
{priceDisplay.primary}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleTogglePrices(item.id)}
|
|
||||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-slate-500 transition hover:text-slate-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
|
||||||
aria-label="Edit Prices"
|
|
||||||
title="Edit Prices"
|
|
||||||
aria-expanded={isExpanded}
|
|
||||||
aria-controls={desktopPanelId}
|
|
||||||
>
|
|
||||||
<Pencil aria-hidden className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Edit Prices</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{priceDisplay.secondary ? (
|
|
||||||
<span className="text-slate-500">{priceDisplay.secondary}</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{isExpanded ? (
|
|
||||||
<tr className="bg-slate-50">
|
|
||||||
<td colSpan={visibleAttributes.length + 2} className="px-4 py-4">
|
|
||||||
<div id={desktopPanelId}>
|
|
||||||
<ItemPricesPanel itemId={item.id} projectId={projectId} />
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : null}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4 md:hidden">
|
<ProjectItemsMobileList
|
||||||
{items.map((item) => {
|
items={items}
|
||||||
const attributes = (item.attributes ?? {}) as Record<string, unknown>;
|
visibleAttributes={visibleAttributes}
|
||||||
const priceDisplay = getPriceSummaryDisplay(item.priceSummary);
|
expandedItemId={expandedItemId}
|
||||||
const isExpanded = expandedItemId === item.id;
|
onTogglePrices={handleTogglePrices}
|
||||||
const mobilePanelId = `item-${item.id}-prices-mobile`;
|
onEditItem={onEditItem}
|
||||||
|
projectId={projectId}
|
||||||
return (
|
|
||||||
<div key={item.id} className="rounded-xl border border-slate-200 p-4 shadow-sm">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onEditItem(item)}
|
|
||||||
className="group flex flex-1 items-center gap-2 text-left text-slate-900 transition hover:text-blue-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
|
||||||
title="Edit item details"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className={`inline-block h-2.5 w-2.5 rounded-full ${
|
|
||||||
item.status === 'active' ? 'bg-emerald-500' : 'bg-slate-300'
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
<span className="text-lg font-semibold group-hover:underline">
|
|
||||||
{item.manufacturer ? `${item.manufacturer} ${item.model}` : item.model}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{item.sourceUrl ? (
|
|
||||||
<a
|
|
||||||
href={item.sourceUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="inline-flex h-9 w-9 items-center justify-center rounded-md text-blue-600 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
|
||||||
title="Open source link"
|
|
||||||
>
|
|
||||||
<ExternalLink aria-hidden className="h-5 w-5" />
|
|
||||||
<span className="sr-only">Open source link</span>
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{item.note ? (
|
|
||||||
<p className="mt-1 text-sm text-slate-600">Note: {item.note}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="mt-3 space-y-2">
|
|
||||||
{visibleAttributes.map((attribute) => (
|
|
||||||
<div key={attribute} className="flex justify-between text-xs text-slate-600">
|
|
||||||
<span className="font-semibold uppercase tracking-wide text-slate-500">
|
|
||||||
{attribute}
|
|
||||||
</span>
|
|
||||||
<span className="text-right">
|
|
||||||
{formatAttributeValue(attributes[attribute])}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 flex flex-col gap-0.5 text-xs">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-semibold text-slate-900">
|
|
||||||
{priceDisplay.primary}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleTogglePrices(item.id)}
|
|
||||||
className="inline-flex h-9 w-9 items-center justify-center rounded-md text-slate-500 transition hover:text-slate-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
|
||||||
aria-label="Edit Prices"
|
|
||||||
title="Edit Prices"
|
|
||||||
aria-expanded={isExpanded}
|
|
||||||
aria-controls={mobilePanelId}
|
|
||||||
>
|
|
||||||
<Pencil aria-hidden className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Edit Prices</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{priceDisplay.secondary ? (
|
|
||||||
<span className="text-slate-500">{priceDisplay.secondary}</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{isExpanded ? (
|
|
||||||
<div id={mobilePanelId} className="mt-4">
|
|
||||||
<ItemPricesPanel itemId={item.id} projectId={projectId} />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-2xl border border-dashed border-slate-300 bg-slate-50/60 p-6">
|
<ProjectItemsAddItemSection
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500">
|
quickUrl={quickUrl}
|
||||||
Add Item
|
quickError={quickError}
|
||||||
</h3>
|
onQuickUrlChange={onQuickUrlChange}
|
||||||
<p className="mt-2 text-sm text-slate-500">
|
onAddUrl={onAddUrl}
|
||||||
Paste a product link to pre-fill details or switch to manual entry.
|
onAddManual={onAddManual}
|
||||||
</p>
|
isImportingFromUrl={isImportingFromUrl}
|
||||||
<div className="mt-4 flex flex-col gap-4 md:flex-row md:items-end md:gap-6">
|
|
||||||
<div className="flex-1">
|
|
||||||
<label className="text-xs font-medium text-slate-600" htmlFor="quick-item-url">
|
|
||||||
Item URL
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="quick-item-url"
|
|
||||||
type="url"
|
|
||||||
value={quickUrl}
|
|
||||||
onChange={(event) => onQuickUrlChange(event.target.value)}
|
|
||||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
|
|
||||||
placeholder="https://example.com/product"
|
|
||||||
/>
|
/>
|
||||||
{quickError ? (
|
|
||||||
<p className="mt-2 text-xs font-medium text-red-600">{quickError}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onAddUrl}
|
|
||||||
disabled={isImportingFromUrl}
|
|
||||||
className="inline-flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-400"
|
|
||||||
>
|
|
||||||
{isImportingFromUrl ? (
|
|
||||||
<>
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
className="h-4 w-4 animate-spin text-white"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
className="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
className="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Importing…
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Add from URL'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onAddManual}
|
|
||||||
className="rounded-md border border-blue-600 px-4 py-2 text-sm font-semibold text-blue-600 transition hover:bg-blue-50"
|
|
||||||
>
|
|
||||||
Add Manually
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import type { Item } from '@shared/models/item';
|
||||||
|
|
||||||
|
const createSortKey = (value: unknown): string => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return `array:${value.length}:${value.map((item) => createSortKey(item)).join('|')}`;
|
||||||
|
}
|
||||||
|
if (value === null) {
|
||||||
|
return 'null';
|
||||||
|
}
|
||||||
|
const type = typeof value;
|
||||||
|
if (type === 'number') {
|
||||||
|
return `${type}:${(value as number).toString()}`;
|
||||||
|
}
|
||||||
|
if (type === 'boolean') {
|
||||||
|
return `${type}:${value ? '1' : '0'}`;
|
||||||
|
}
|
||||||
|
if (type === 'string') {
|
||||||
|
return `${type}:${value}`;
|
||||||
|
}
|
||||||
|
return `${type}:${JSON.stringify(value)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeAttributeValue = (value: unknown): unknown => {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const normalizedItems = value.map((item) => normalizeAttributeValue(item));
|
||||||
|
normalizedItems.sort((first, second) => {
|
||||||
|
const firstKey = createSortKey(first);
|
||||||
|
const secondKey = createSortKey(second);
|
||||||
|
if (firstKey < secondKey) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (firstKey > secondKey) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
return normalizedItems;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const areAttributeValuesEqual = (first: unknown, second: unknown): boolean => {
|
||||||
|
const normalizedFirst = normalizeAttributeValue(first);
|
||||||
|
const normalizedSecond = normalizeAttributeValue(second);
|
||||||
|
|
||||||
|
if (Array.isArray(normalizedFirst) && Array.isArray(normalizedSecond)) {
|
||||||
|
if (normalizedFirst.length !== normalizedSecond.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (let index = 0; index < normalizedFirst.length; index += 1) {
|
||||||
|
if (!areAttributeValuesEqual(normalizedFirst[index], normalizedSecond[index])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.is(normalizedFirst, normalizedSecond);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatAttributeValue = (value: unknown): string => {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value ? 'Yes' : 'No';
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length === 0) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
const formattedValues = value
|
||||||
|
.map((item) => {
|
||||||
|
if (item === null || item === undefined) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
return item.trim();
|
||||||
|
}
|
||||||
|
if (typeof item === 'number') {
|
||||||
|
return String(item);
|
||||||
|
}
|
||||||
|
if (typeof item === 'boolean') {
|
||||||
|
return item ? 'Yes' : 'No';
|
||||||
|
}
|
||||||
|
return JSON.stringify(item);
|
||||||
|
})
|
||||||
|
.filter((item) => item.length > 0);
|
||||||
|
|
||||||
|
return formattedValues.length ? formattedValues.join(', ') : '—';
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value.length ? value : '—';
|
||||||
|
}
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
return JSON.stringify(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatPriceAmount = (amount: number, currency: string | null): string => {
|
||||||
|
if (!Number.isFinite(amount)) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
if (currency) {
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat(undefined, {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
}).format(amount);
|
||||||
|
} catch {
|
||||||
|
return `${amount.toFixed(2)} ${currency}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return amount.toFixed(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPriceSummaryDisplay = (
|
||||||
|
summary: Item['priceSummary'] | null
|
||||||
|
): { primary: string; secondary: string | null } => {
|
||||||
|
if (!summary || summary.priceCount === 0) {
|
||||||
|
return { primary: '—', secondary: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveCurrency = (preferred: string | null) => preferred ?? summary.currency ?? null;
|
||||||
|
const formatConditionLabel = (
|
||||||
|
condition: 'new' | 'used',
|
||||||
|
amount: number | null,
|
||||||
|
count: number,
|
||||||
|
currency: string | null,
|
||||||
|
hasMixedCurrency: boolean
|
||||||
|
): string | null => {
|
||||||
|
if (!count) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (amount === null || hasMixedCurrency) {
|
||||||
|
return `${condition} prices vary (${count})`;
|
||||||
|
}
|
||||||
|
const resolvedCurrency = resolveCurrency(currency);
|
||||||
|
const formatted = formatPriceAmount(amount, resolvedCurrency);
|
||||||
|
return `${condition} from ${formatted} (${count})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const newLabel = formatConditionLabel(
|
||||||
|
'new',
|
||||||
|
summary.newMinAmount,
|
||||||
|
summary.newCount,
|
||||||
|
summary.newCurrency,
|
||||||
|
summary.newHasMixedCurrency
|
||||||
|
);
|
||||||
|
const usedLabel = formatConditionLabel(
|
||||||
|
'used',
|
||||||
|
summary.usedMinAmount,
|
||||||
|
summary.usedCount,
|
||||||
|
summary.usedCurrency,
|
||||||
|
summary.usedHasMixedCurrency
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newLabel || usedLabel) {
|
||||||
|
if (newLabel && usedLabel) {
|
||||||
|
return { primary: newLabel, secondary: usedLabel };
|
||||||
|
}
|
||||||
|
return { primary: newLabel ?? usedLabel ?? '—', secondary: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!summary.hasMixedCurrency) {
|
||||||
|
const resolvedCurrency = resolveCurrency(null);
|
||||||
|
const formattedMin = formatPriceAmount(summary.minAmount, resolvedCurrency);
|
||||||
|
const formattedMax = formatPriceAmount(summary.maxAmount, resolvedCurrency);
|
||||||
|
if (summary.minAmount === summary.maxAmount) {
|
||||||
|
return {
|
||||||
|
primary: `${formattedMin} (${summary.priceCount})`,
|
||||||
|
secondary: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
primary: `from ${formattedMin} (${summary.priceCount})`,
|
||||||
|
secondary: `up to ${formattedMax}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
primary: `Multiple currencies (${summary.priceCount})`,
|
||||||
|
secondary: null
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user