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 { 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}
|
||||
<ProjectItemsHeader
|
||||
itemCount={items.length}
|
||||
isLoading={isLoading}
|
||||
showDifferencesOnly={showDifferencesOnly}
|
||||
hasAttributeDifferences={hasAttributeDifferences}
|
||||
onToggleDifferences={(value) => setShowDifferencesOnly(value)}
|
||||
/>
|
||||
Show differences only
|
||||
</label>
|
||||
</header>
|
||||
|
||||
{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'
|
||||
}`}
|
||||
<ProjectItemsDesktopTable
|
||||
items={items}
|
||||
visibleAttributes={visibleAttributes}
|
||||
expandedItemId={expandedItemId}
|
||||
onTogglePrices={handleTogglePrices}
|
||||
onEditItem={onEditItem}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<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'
|
||||
}`}
|
||||
<ProjectItemsMobileList
|
||||
items={items}
|
||||
visibleAttributes={visibleAttributes}
|
||||
expandedItemId={expandedItemId}
|
||||
onTogglePrices={handleTogglePrices}
|
||||
onEditItem={onEditItem}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<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 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"
|
||||
<ProjectItemsAddItemSection
|
||||
quickUrl={quickUrl}
|
||||
quickError={quickError}
|
||||
onQuickUrlChange={onQuickUrlChange}
|
||||
onAddUrl={onAddUrl}
|
||||
onAddManual={onAddManual}
|
||||
isImportingFromUrl={isImportingFromUrl}
|
||||
/>
|
||||
{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