diff --git a/client/src/layouts/app-layout.tsx b/client/src/layouts/app-layout.tsx index 0fe5354..8a1a040 100644 --- a/client/src/layouts/app-layout.tsx +++ b/client/src/layouts/app-layout.tsx @@ -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' diff --git a/client/src/pages/project-detail/project-items-add-item-section.tsx b/client/src/pages/project-detail/project-items-add-item-section.tsx new file mode 100644 index 0000000..5216c4d --- /dev/null +++ b/client/src/pages/project-detail/project-items-add-item-section.tsx @@ -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 ( +
+

Add Item

+

+ Paste a product link to pre-fill details or switch to manual entry. +

+
+
+ + 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 ?

{quickError}

: null} +
+ +
+ + +
+
+
+ ); +} diff --git a/client/src/pages/project-detail/project-items-desktop-table.tsx b/client/src/pages/project-detail/project-items-desktop-table.tsx new file mode 100644 index 0000000..2acb1e7 --- /dev/null +++ b/client/src/pages/project-detail/project-items-desktop-table.tsx @@ -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 ( +
+
+ + + + + {visibleAttributes.map((attribute) => ( + + ))} + + + + + {items.map((item) => { + const attributes = (item.attributes ?? {}) as Record; + const priceDisplay = getPriceSummaryDisplay(item.priceSummary); + const isExpanded = expandedItemId === item.id; + const panelId = `item-${item.id}-prices-desktop`; + + return ( + + + + {visibleAttributes.map((attribute) => ( + + ))} + + + {isExpanded ? ( + + + + ) : null} + + ); + })} + +
+ Item + + {attribute} + + Prices +
+
+
+ + {item.sourceUrl ? ( + + + Open source link + + ) : null} +
+ {item.note ? ( + {item.note} + ) : null} +
+
+ {formatAttributeValue(attributes[attribute])} + +
+
+ + {priceDisplay.primary} + + +
+ {priceDisplay.secondary ? ( + {priceDisplay.secondary} + ) : null} +
+
+
+ +
+
+
+
+ ); +} diff --git a/client/src/pages/project-detail/project-items-header.tsx b/client/src/pages/project-detail/project-items-header.tsx new file mode 100644 index 0000000..ab18cfe --- /dev/null +++ b/client/src/pages/project-detail/project-items-header.tsx @@ -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 ( +
+
+

Items

+

{subtitle}

+
+ +
+ ); +} diff --git a/client/src/pages/project-detail/project-items-mobile-list.tsx b/client/src/pages/project-detail/project-items-mobile-list.tsx new file mode 100644 index 0000000..a5578e8 --- /dev/null +++ b/client/src/pages/project-detail/project-items-mobile-list.tsx @@ -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 ( +
+ {items.map((item) => { + const attributes = (item.attributes ?? {}) as Record; + const priceDisplay = getPriceSummaryDisplay(item.priceSummary); + const isExpanded = expandedItemId === item.id; + const panelId = `item-${item.id}-prices-mobile`; + + return ( +
+
+ + {item.sourceUrl ? ( + + + Open source link + + ) : null} +
+ + {item.note ?

Note: {item.note}

: null} + +
+ {visibleAttributes.map((attribute) => ( +
+ + {attribute} + + {formatAttributeValue(attributes[attribute])} +
+ ))} +
+
+
+ {priceDisplay.primary} + +
+ {priceDisplay.secondary ? ( + {priceDisplay.secondary} + ) : null} +
+ {isExpanded ? ( +
+ +
+ ) : null} +
+ ); + })} +
+ ); +} diff --git a/client/src/pages/project-detail/project-items-section.tsx b/client/src/pages/project-detail/project-items-section.tsx index 2a7aaac..7931c1a 100644 --- a/client/src/pages/project-detail/project-items-section.tsx +++ b/client/src/pages/project-detail/project-items-section.tsx @@ -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 ( <>
-
-
-

Items

-

- {isLoading - ? 'Loading items…' - : items.length - ? `${items.length} item${items.length === 1 ? '' : 's'} tracked.` - : 'No items yet – add your first one below.'} -

-
- -
+ setShowDifferencesOnly(value)} + /> {error ? (

{error.message}

@@ -328,281 +127,33 @@ export function ProjectItemsSection({

) : null} -
-
- - - - - {visibleAttributes.map((attribute) => ( - - ))} - - - - - {items.map((item) => { - const attributes = (item.attributes ?? {}) as Record; - const priceDisplay = getPriceSummaryDisplay(item.priceSummary); - const isExpanded = expandedItemId === item.id; - const desktopPanelId = `item-${item.id}-prices-desktop`; - - return ( - - - - {visibleAttributes.map((attribute) => ( - - ))} - - - {isExpanded ? ( - - - - ) : null} - - ); - })} - -
- Item - - {attribute} - - Prices -
-
-
- - {item.sourceUrl ? ( - - - Open source link - - ) : null} -
- {item.note ? ( - {item.note} - ) : null} -
-
- {formatAttributeValue(attributes[attribute])} - -
-
- - {priceDisplay.primary} - - -
- {priceDisplay.secondary ? ( - {priceDisplay.secondary} - ) : null} -
-
-
- -
-
-
-
- -
- {items.map((item) => { - const attributes = (item.attributes ?? {}) as Record; - const priceDisplay = getPriceSummaryDisplay(item.priceSummary); - const isExpanded = expandedItemId === item.id; - const mobilePanelId = `item-${item.id}-prices-mobile`; - - return ( -
-
- - {item.sourceUrl ? ( - - - Open source link - - ) : null} -
- - {item.note ? ( -

Note: {item.note}

- ) : null} - -
- {visibleAttributes.map((attribute) => ( -
- - {attribute} - - - {formatAttributeValue(attributes[attribute])} - -
- ))} -
-
-
- - {priceDisplay.primary} - - -
- {priceDisplay.secondary ? ( - {priceDisplay.secondary} - ) : null} -
- {isExpanded ? ( -
- -
- ) : null} -
- ); - })} -
+ +
-
-

- Add Item -

-

- Paste a product link to pre-fill details or switch to manual entry. -

-
-
- - 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 ? ( -

{quickError}

- ) : null} -
- -
- - -
-
-
+ ); } diff --git a/client/src/pages/project-detail/project-items-utils.ts b/client/src/pages/project-detail/project-items-utils.ts new file mode 100644 index 0000000..e8a4c6e --- /dev/null +++ b/client/src/pages/project-detail/project-items-utils.ts @@ -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 + }; +};