split items section

This commit is contained in:
Jurgis Sakalauskas
2025-10-14 14:05:57 +03:00
parent 5cd864318f
commit e30e116187
7 changed files with 601 additions and 490 deletions
+2 -4
View File
@@ -10,17 +10,15 @@ function AppLayout() {
const location = useLocation();
const { data, isLoading, isError } = useProjectsQuery();
const projects = data?.data ?? [];
const recentProjects = useMemo(
() =>
[...projects]
[...(data?.data ?? [])]
.sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
)
.slice(0, 3),
[projects]
[data?.data]
);
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 { ExternalLink, Pencil } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
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 {
items: Item[];
@@ -18,195 +21,6 @@ interface ProjectItemsSectionProps {
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({
items,
isLoading,
@@ -295,28 +109,13 @@ export function ProjectItemsSection({
return (
<>
<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">
<div>
<h2 className="text-xl font-semibold text-slate-900">Items</h2>
<p className="text-sm text-slate-500">
{isLoading
? 'Loading items…'
: 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>
<ProjectItemsHeader
itemCount={items.length}
isLoading={isLoading}
showDifferencesOnly={showDifferencesOnly}
hasAttributeDifferences={hasAttributeDifferences}
onToggleDifferences={(value) => setShowDifferencesOnly(value)}
/>
{error ? (
<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>
) : null}
<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 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">
{items.map((item) => {
const attributes = (item.attributes ?? {}) as Record<string, unknown>;
const priceDisplay = getPriceSummaryDisplay(item.priceSummary);
const isExpanded = expandedItemId === item.id;
const mobilePanelId = `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="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>
<ProjectItemsDesktopTable
items={items}
visibleAttributes={visibleAttributes}
expandedItemId={expandedItemId}
onTogglePrices={handleTogglePrices}
onEditItem={onEditItem}
projectId={projectId}
/>
<ProjectItemsMobileList
items={items}
visibleAttributes={visibleAttributes}
expandedItemId={expandedItemId}
onTogglePrices={handleTogglePrices}
onEditItem={onEditItem}
projectId={projectId}
/>
</section>
<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>
<ProjectItemsAddItemSection
quickUrl={quickUrl}
quickError={quickError}
onQuickUrlChange={onQuickUrlChange}
onAddUrl={onAddUrl}
onAddManual={onAddManual}
isImportingFromUrl={isImportingFromUrl}
/>
</>
);
}
@@ -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
};
};