price edit below the item

This commit is contained in:
Jurgis Sakalauskas
2025-10-14 13:55:53 +03:00
parent bba0cb86a7
commit 5cd864318f
2 changed files with 162 additions and 182 deletions
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import type { Item } from '@shared/models/item'; import type { Item } from '@shared/models/item';
import { import {
@@ -19,7 +19,6 @@ import { ProjectHeader } from './project-header';
import { ProjectDescriptionSection } from './project-description-section'; import { ProjectDescriptionSection } from './project-description-section';
import { TrackedAttributesSection } from './tracked-attributes-section'; import { TrackedAttributesSection } from './tracked-attributes-section';
import { ProjectItemsSection } from './project-items-section'; import { ProjectItemsSection } from './project-items-section';
import { ItemPricesModal } from './item-prices-modal';
export function ProjectDetailPage() { export function ProjectDetailPage() {
const { projectId } = useParams<{ projectId: string }>(); const { projectId } = useParams<{ projectId: string }>();
@@ -29,7 +28,6 @@ export function ProjectDetailPage() {
const updateItemMutation = useUpdateItemMutation(projectId); const updateItemMutation = useUpdateItemMutation(projectId);
const updateProjectMutation = useUpdateProjectMutation(projectId); const updateProjectMutation = useUpdateProjectMutation(projectId);
const [priceModalItemId, setPriceModalItemId] = useState<string | null>(null);
const [quickUrl, setQuickUrl] = useState(''); const [quickUrl, setQuickUrl] = useState('');
const [quickError, setQuickError] = useState<string | null>(null); const [quickError, setQuickError] = useState<string | null>(null);
const [isItemModalOpen, setIsItemModalOpen] = useState(false); const [isItemModalOpen, setIsItemModalOpen] = useState(false);
@@ -44,17 +42,6 @@ export function ProjectDetailPage() {
const project = projectQuery.data; const project = projectQuery.data;
const items = useMemo(() => itemsQuery.data?.data ?? [], [itemsQuery.data]); const items = useMemo(() => itemsQuery.data?.data ?? [], [itemsQuery.data]);
const projectAttributes = useMemo(() => project?.attributes ?? [], [project]); const projectAttributes = useMemo(() => project?.attributes ?? [], [project]);
const priceModalItem = useMemo(
() => items.find((item) => item.id === priceModalItemId) ?? null,
[items, priceModalItemId]
);
useEffect(() => {
if (priceModalItemId && !priceModalItem) {
setPriceModalItemId(null);
}
}, [priceModalItemId, priceModalItem]);
const availableAttributes = useMemo(() => { const availableAttributes = useMemo(() => {
const attributeSet = new Set<string>(); const attributeSet = new Set<string>();
@@ -229,14 +216,6 @@ export function ProjectDetailPage() {
} }
}; };
const handleEditPrices = (item: Item) => {
setPriceModalItemId(item.id);
};
const handlePricesModalClose = () => {
setPriceModalItemId(null);
};
const modalInitialData = const modalInitialData =
itemModalMode === 'url' itemModalMode === 'url'
? importedItemData ? importedItemData
@@ -295,7 +274,7 @@ export function ProjectDetailPage() {
onAddManual={handleAddManualClick} onAddManual={handleAddManualClick}
isImportingFromUrl={isImportingItem} isImportingFromUrl={isImportingItem}
onEditItem={handleEditItem} onEditItem={handleEditItem}
onEditPrices={handleEditPrices} projectId={projectId}
/> />
<ItemFormModal <ItemFormModal
@@ -311,12 +290,6 @@ export function ProjectDetailPage() {
isInitialDataLoading={itemModalInitialDataLoading} isInitialDataLoading={itemModalInitialDataLoading}
initialDataError={itemModalInitialDataError} initialDataError={itemModalInitialDataError}
/> />
<ItemPricesModal
isOpen={Boolean(priceModalItem)}
item={priceModalItem}
projectId={projectId}
onClose={handlePricesModalClose}
/>
</div> </div>
); );
} }
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { Fragment, useEffect, useMemo, useState } from 'react';
import { ExternalLink, Pencil } from 'lucide-react'; import { ExternalLink, Pencil } from 'lucide-react';
import type { Item } from '@shared/models/item'; import type { Item } from '@shared/models/item';
import { ItemPricesPanel } from './item-prices-panel';
interface ProjectItemsSectionProps { interface ProjectItemsSectionProps {
items: Item[]; items: Item[];
@@ -14,7 +15,7 @@ interface ProjectItemsSectionProps {
onAddManual: () => void; onAddManual: () => void;
isImportingFromUrl: boolean; isImportingFromUrl: boolean;
onEditItem: (item: Item) => void; onEditItem: (item: Item) => void;
onEditPrices: (item: Item) => void; projectId: string | undefined;
} }
const formatAttributeValue = (value: unknown): string => { const formatAttributeValue = (value: unknown): string => {
@@ -144,105 +145,66 @@ const getPriceSummaryDisplay = (
} }
const resolveCurrency = (preferred: string | null) => preferred ?? summary.currency ?? null; const resolveCurrency = (preferred: string | null) => preferred ?? summary.currency ?? null;
const formatConditionPrice = ( const formatConditionLabel = (
amount: number | null,
condition: 'new' | 'used', condition: 'new' | 'used',
useFrom: boolean, amount: number | null,
count: number,
currency: string | null, currency: string | null,
isMixed: boolean hasMixedCurrency: boolean
): string | null => { ): string | null => {
if (amount === null || isMixed) { if (!count) {
return null; return null;
} }
if (amount === null || hasMixedCurrency) {
return `${condition} prices vary (${count})`;
}
const resolvedCurrency = resolveCurrency(currency); const resolvedCurrency = resolveCurrency(currency);
const formatted = formatPriceAmount(amount, resolvedCurrency); const formatted = formatPriceAmount(amount, resolvedCurrency);
const prefix = useFrom ? 'from ' : ''; return `${condition} from ${formatted} (${count})`;
return `${prefix}${formatted} ${condition}`;
}; };
const hasMultiplePrices = summary.priceCount > 1; const newLabel = formatConditionLabel(
const hasNewPrices =
summary.newCount > 0 && summary.newMinAmount !== null && !summary.newHasMixedCurrency;
const hasUsedPrices =
summary.usedCount > 0 && summary.usedMinAmount !== null && !summary.usedHasMixedCurrency;
if (summary.priceCount === 1) {
if (hasNewPrices) {
const label = formatConditionPrice(
summary.newMinAmount,
'new', 'new',
false, summary.newMinAmount,
summary.newCount,
summary.newCurrency, summary.newCurrency,
summary.newHasMixedCurrency summary.newHasMixedCurrency
); );
if (label) { const usedLabel = formatConditionLabel(
return { primary: label, secondary: null };
}
}
if (hasUsedPrices) {
const label = formatConditionPrice(
summary.usedMinAmount,
'used', 'used',
false, summary.usedMinAmount,
summary.usedCount,
summary.usedCurrency, summary.usedCurrency,
summary.usedHasMixedCurrency summary.usedHasMixedCurrency
); );
if (label) {
return { primary: label, secondary: null };
}
}
return { primary: formatPriceAmount(summary.minAmount, summary.currency), secondary: null };
}
if (hasMultiplePrices) { if (newLabel || usedLabel) {
const parts: string[] = []; if (newLabel && usedLabel) {
return { primary: newLabel, secondary: usedLabel };
if (hasNewPrices) {
const label = formatConditionPrice(
summary.newMinAmount,
'new',
true,
summary.newCurrency,
summary.newHasMixedCurrency
);
if (label) {
parts.push(label);
} }
} return { primary: newLabel ?? usedLabel ?? '—', secondary: null };
if (hasUsedPrices) {
const label = formatConditionPrice(
summary.usedMinAmount,
'used',
true,
summary.usedCurrency,
summary.usedHasMixedCurrency
);
if (label) {
parts.push(label);
}
}
if (parts.length === 1) {
return { primary: parts[0], secondary: null };
}
if (parts.length >= 2) {
return { primary: parts[0], secondary: parts[1] };
} }
if (!summary.hasMixedCurrency) { if (!summary.hasMixedCurrency) {
const formattedMin = formatPriceAmount(summary.minAmount, summary.currency); const resolvedCurrency = resolveCurrency(null);
const formattedMax = formatPriceAmount(summary.maxAmount, summary.currency); const formattedMin = formatPriceAmount(summary.minAmount, resolvedCurrency);
const formattedMax = formatPriceAmount(summary.maxAmount, resolvedCurrency);
if (summary.minAmount === summary.maxAmount) { if (summary.minAmount === summary.maxAmount) {
return { primary: formattedMin, secondary: null }; return {
primary: `${formattedMin} (${summary.priceCount})`,
secondary: null
};
} }
return { primary: `from ${formattedMin}`, secondary: `up to ${formattedMax}` }; return {
primary: `from ${formattedMin} (${summary.priceCount})`,
secondary: `up to ${formattedMax}`
};
} }
return { primary: 'Multiple currencies', secondary: null }; return {
} primary: `Multiple currencies (${summary.priceCount})`,
secondary: null
return { primary: formatPriceAmount(summary.minAmount, summary.currency), secondary: null }; };
}; };
export function ProjectItemsSection({ export function ProjectItemsSection({
@@ -257,9 +219,10 @@ export function ProjectItemsSection({
onAddManual, onAddManual,
isImportingFromUrl, isImportingFromUrl,
onEditItem, onEditItem,
onEditPrices projectId
}: ProjectItemsSectionProps) { }: ProjectItemsSectionProps) {
const [showDifferencesOnly, setShowDifferencesOnly] = useState(false); const [showDifferencesOnly, setShowDifferencesOnly] = useState(false);
const [expandedItemId, setExpandedItemId] = useState<string | null>(null);
const mismatchedAttributes = useMemo(() => { const mismatchedAttributes = useMemo(() => {
const result = new Set<string>(); const result = new Set<string>();
@@ -303,6 +266,16 @@ export function ProjectItemsSection({
} }
}, [showDifferencesOnly, mismatchedAttributes]); }, [showDifferencesOnly, mismatchedAttributes]);
useEffect(() => {
if (!expandedItemId) {
return;
}
const itemStillPresent = items.some((item) => item.id === expandedItemId);
if (!itemStillPresent) {
setExpandedItemId(null);
}
}, [items, expandedItemId]);
const visibleAttributes = useMemo(() => { const visibleAttributes = useMemo(() => {
if (!showDifferencesOnly) { if (!showDifferencesOnly) {
return projectAttributes; return projectAttributes;
@@ -315,6 +288,10 @@ export function ProjectItemsSection({
const showNoDifferencesMessage = const showNoDifferencesMessage =
!hasAttributeDifferences && projectAttributes.length > 0 && items.length > 1; !hasAttributeDifferences && projectAttributes.length > 0 && items.length > 1;
const handleTogglePrices = (itemId: string) => {
setExpandedItemId((current) => (current === itemId ? null : itemId));
};
return ( return (
<> <>
<section className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm"> <section className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
@@ -376,9 +353,12 @@ export function ProjectItemsSection({
{items.map((item) => { {items.map((item) => {
const attributes = (item.attributes ?? {}) as Record<string, unknown>; const attributes = (item.attributes ?? {}) as Record<string, unknown>;
const priceDisplay = getPriceSummaryDisplay(item.priceSummary); const priceDisplay = getPriceSummaryDisplay(item.priceSummary);
const isExpanded = expandedItemId === item.id;
const desktopPanelId = `item-${item.id}-prices-desktop`;
return ( return (
<tr key={item.id} className="hover:bg-slate-50"> <Fragment key={item.id}>
<tr className={`hover:bg-slate-50 ${isExpanded ? 'bg-slate-50' : ''}`}>
<td className="px-4 py-3 align-top"> <td className="px-4 py-3 align-top">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -403,7 +383,7 @@ export function ProjectItemsSection({
href={item.sourceUrl} href={item.sourceUrl}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-blue-200 text-blue-600 transition hover:bg-blue-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white" 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" title="Open source link"
> >
<ExternalLink aria-hidden className="h-4 w-4" /> <ExternalLink aria-hidden className="h-4 w-4" />
@@ -422,28 +402,40 @@ export function ProjectItemsSection({
</td> </td>
))} ))}
<td className="px-4 py-3 align-top"> <td className="px-4 py-3 align-top">
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-0.5 text-xs"> <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"> <span className="text-sm font-semibold text-slate-900">
{priceDisplay.primary} {priceDisplay.primary}
</span> </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 ? ( {priceDisplay.secondary ? (
<span className="text-slate-500">{priceDisplay.secondary}</span> <span className="text-slate-500">{priceDisplay.secondary}</span>
) : null} ) : null}
</div> </div>
<button </td>
type="button" </tr>
onClick={() => onEditPrices(item)} {isExpanded ? (
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-blue-200 text-blue-600 transition hover:bg-blue-50" <tr className="bg-slate-50">
aria-label="Edit Prices" <td colSpan={visibleAttributes.length + 2} className="px-4 py-4">
title="Edit Prices" <div id={desktopPanelId}>
> <ItemPricesPanel itemId={item.id} projectId={projectId} />
<Pencil aria-hidden className="h-5 w-5 text-slate-400 transition hover:text-blue-600" />
<span className="sr-only">Edit Prices</span>
</button>
</div> </div>
</td> </td>
</tr> </tr>
) : null}
</Fragment>
); );
})} })}
</tbody> </tbody>
@@ -455,6 +447,8 @@ export function ProjectItemsSection({
{items.map((item) => { {items.map((item) => {
const attributes = (item.attributes ?? {}) as Record<string, unknown>; const attributes = (item.attributes ?? {}) as Record<string, unknown>;
const priceDisplay = getPriceSummaryDisplay(item.priceSummary); const priceDisplay = getPriceSummaryDisplay(item.priceSummary);
const isExpanded = expandedItemId === item.id;
const mobilePanelId = `item-${item.id}-prices-mobile`;
return ( return (
<div key={item.id} className="rounded-xl border border-slate-200 p-4 shadow-sm"> <div key={item.id} className="rounded-xl border border-slate-200 p-4 shadow-sm">
@@ -480,7 +474,7 @@ export function ProjectItemsSection({
href={item.sourceUrl} href={item.sourceUrl}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-blue-200 text-blue-600 transition hover:bg-blue-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white" 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" title="Open source link"
> >
<ExternalLink aria-hidden className="h-5 w-5" /> <ExternalLink aria-hidden className="h-5 w-5" />
@@ -506,19 +500,32 @@ export function ProjectItemsSection({
))} ))}
</div> </div>
<div className="mt-3 flex flex-col gap-0.5 text-xs"> <div className="mt-3 flex flex-col gap-0.5 text-xs">
<span className="text-sm font-semibold text-slate-900">{priceDisplay.primary}</span> <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 ? ( {priceDisplay.secondary ? (
<span className="text-slate-500">{priceDisplay.secondary}</span> <span className="text-slate-500">{priceDisplay.secondary}</span>
) : null} ) : null}
</div> </div>
<button {isExpanded ? (
type="button" <div id={mobilePanelId} className="mt-4">
onClick={() => onEditPrices(item)} <ItemPricesPanel itemId={item.id} projectId={projectId} />
className="mt-4 inline-flex w-full items-center justify-center gap-2 rounded-md border border-blue-200 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-blue-600 transition hover:bg-blue-50" </div>
> ) : null}
<Pencil aria-hidden className="h-5 w-5 text-slate-400 transition hover:text-blue-600" />
Edit Prices
</button>
</div> </div>
); );
})} })}