import { FormEvent, useMemo, useState } from 'react'; import { Pencil, Trash2 } from 'lucide-react'; import { useCreateItemPriceMutation, useDeleteItemPriceMutation, useItemPricesQuery, useUpdateItemPriceMutation } from '../../query/item-prices'; import type { PriceCondition } from '../../api/item-prices'; import { formatRelativeTime } from '../../utils/relative-time'; const getSourceDomainLabel = (rawUrl: string): string => { const trimmed = rawUrl.trim(); if (!trimmed) { return 'Source'; } const normalizeHostname = (hostname: string) => hostname.replace(/^www\./i, '') || hostname; try { const parsed = new URL(trimmed); const hostname = normalizeHostname(parsed.hostname); return hostname.length ? hostname : parsed.hostname || 'Source'; } catch { try { const parsed = new URL(`https://${trimmed}`); const hostname = normalizeHostname(parsed.hostname); return hostname.length ? hostname : parsed.hostname || trimmed; } catch { const withoutProtocol = trimmed.replace(/^[a-z]+:\/\//i, ''); const slashIndex = withoutProtocol.indexOf('/'); const candidate = ( slashIndex === -1 ? withoutProtocol : withoutProtocol.slice(0, slashIndex) ).trim(); return candidate.length ? candidate : trimmed; } } }; interface ItemPricesPanelProps { itemId: string; projectId: string | undefined; } export function ItemPricesPanel({ itemId, projectId }: ItemPricesPanelProps) { const [condition, setCondition] = useState('new'); const [amount, setAmount] = useState(''); const [currency, setCurrency] = useState('EUR'); const [sourceUrl, setSourceUrl] = useState(''); const [note, setNote] = useState(''); const [formError, setFormError] = useState(null); const [actionError, setActionError] = useState(null); const [pendingDeletionId, setPendingDeletionId] = useState(null); const [editingPriceId, setEditingPriceId] = useState(null); const [editingCondition, setEditingCondition] = useState('new'); const [editingAmount, setEditingAmount] = useState(''); const [editingCurrency, setEditingCurrency] = useState('EUR'); const [editingSourceUrl, setEditingSourceUrl] = useState(''); const [editingNote, setEditingNote] = useState(''); const pricesQuery = useItemPricesQuery(itemId, true); const createPriceMutation = useCreateItemPriceMutation(itemId, projectId); const deletePriceMutation = useDeleteItemPriceMutation(itemId, projectId); const updatePriceMutation = useUpdateItemPriceMutation(itemId, projectId); const prices = pricesQuery.data?.data ?? []; const sortedPrices = useMemo( () => [...prices].sort((first, second) => first.amount - second.amount), [prices] ); const errorMessage = useMemo( () => formError ?? actionError ?? (createPriceMutation.isError ? createPriceMutation.error.message : null) ?? (deletePriceMutation.isError ? deletePriceMutation.error.message : null) ?? (updatePriceMutation.isError ? updatePriceMutation.error.message : null), [ formError, actionError, createPriceMutation.isError, createPriceMutation.error, deletePriceMutation.isError, deletePriceMutation.error, updatePriceMutation.isError, updatePriceMutation.error ] ); const resetEditingState = () => { setEditingPriceId(null); setEditingCondition('new'); setEditingAmount(''); setEditingCurrency('EUR'); setEditingSourceUrl(''); setEditingNote(''); updatePriceMutation.reset(); }; const startEditingPrice = (price: (typeof prices)[number]) => { setFormError(null); setActionError(null); updatePriceMutation.reset(); setEditingPriceId(price.id); setEditingCondition(price.condition); setEditingAmount(price.amount.toFixed(2)); setEditingCurrency(price.currency.toUpperCase()); setEditingSourceUrl(price.sourceUrl ?? ''); setEditingNote(price.note ?? ''); }; const handleSubmit = async (event: FormEvent) => { event.preventDefault(); setFormError(null); setActionError(null); createPriceMutation.reset(); const parsedAmount = Number(amount); if (!Number.isFinite(parsedAmount) || parsedAmount < 0) { setFormError('Amount must be a non-negative number.'); return; } try { await createPriceMutation.mutateAsync({ condition, amount: parsedAmount, currency: currency.trim().toUpperCase(), sourceUrl: sourceUrl.trim() ? sourceUrl.trim() : null, note: note.trim() ? note.trim() : null }); setAmount(''); setSourceUrl(''); setNote(''); } catch (error) { setActionError((error as Error).message); } }; const handleCancelEditing = () => { setActionError(null); resetEditingState(); }; const handleUpdatePrice = async () => { if (!editingPriceId) { return; } setActionError(null); const trimmedAmount = editingAmount.trim(); const parsedAmount = Number(trimmedAmount); if (trimmedAmount === '' || !Number.isFinite(parsedAmount) || parsedAmount < 0) { setActionError('Amount must be a non-negative number.'); return; } const trimmedCurrency = editingCurrency.trim().toUpperCase(); if (!trimmedCurrency) { setActionError('Currency is required.'); return; } setEditingCurrency(trimmedCurrency); const trimmedSourceUrl = editingSourceUrl.trim(); const trimmedNote = editingNote.trim(); try { await updatePriceMutation.mutateAsync({ priceId: editingPriceId, payload: { condition: editingCondition, amount: parsedAmount, currency: trimmedCurrency, sourceUrl: trimmedSourceUrl ? trimmedSourceUrl : null, note: trimmedNote ? trimmedNote : null } }); resetEditingState(); } catch (error) { setActionError((error as Error).message); } }; const handleDeletePrice = async (priceId: string) => { setActionError(null); deletePriceMutation.reset(); setPendingDeletionId(priceId); try { await deletePriceMutation.mutateAsync(priceId); } catch (error) { setActionError((error as Error).message); } finally { setPendingDeletionId(null); } }; return (

Prices

Track new / used pricing to compare deals across sources.

{pricesQuery.isLoading ? 'Loading prices…' : prices.length ? `${prices.length} price point${prices.length === 1 ? '' : 's'}` : 'No prices recorded yet'}
{pricesQuery.isError ? (

{(pricesQuery.error as Error).message}

) : null}
{pricesQuery.isLoading ? ( ) : sortedPrices.length ? ( sortedPrices.map((price) => { const lastUpdated = price.updatedAt ?? price.createdAt; const isEditing = editingPriceId === price.id; const sourceLabel = price.sourceUrl !== null && price.sourceUrl !== undefined ? getSourceDomainLabel(price.sourceUrl) : null; return (
Condition Amount Currency Source Notes Updated Actions
Loading prices…
{isEditing ? ( <>
setEditingCondition(event.target.checked ? 'new' : 'used') } className="h-4 w-4 rounded border border-slate-300 text-blue-600 focus:ring-blue-500" /> New
) : ( {price.condition} )}
{isEditing ? ( <> setEditingAmount(event.target.value)} className="w-full rounded-md border border-slate-300 px-2 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200" placeholder="129.99" required /> ) : ( {price.amount.toFixed(2)} )} {isEditing ? ( <> setEditingCurrency(event.target.value.toUpperCase()) } maxLength={3} className="w-full rounded-md border border-slate-300 px-2 py-2 text-sm uppercase shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200" placeholder="EUR" required /> ) : ( {price.currency} )} {isEditing ? ( <> setEditingSourceUrl(event.target.value)} className="w-full rounded-md border border-slate-300 px-2 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200" placeholder="https://shop.example.com/deal" /> ) : price.sourceUrl ? ( {sourceLabel} ) : ( )} {isEditing ? ( <>