add item by url 1st step

This commit is contained in:
Jurgis Sakalauskas
2025-10-13 15:46:04 +03:00
parent afc3da3e65
commit ff18d0daeb
7 changed files with 448 additions and 105 deletions
+26
View File
@@ -83,6 +83,13 @@ interface SingleItemResponse {
data: Item;
}
export interface ImportedItemData {
manufacturer: string | null;
model: string | null;
note: string | null;
attributes: Record<string, unknown>;
}
export const createItem = async (
projectId: string,
payload: CreateItemPayload
@@ -105,3 +112,22 @@ export const createItem = async (
return response.data;
};
interface ImportItemResponse {
data: ImportedItemData;
}
export const importItemFromUrl = async (
projectId: string,
url: string
): Promise<ImportedItemData> => {
const response = await apiFetch<ImportItemResponse>(
`/projects/${projectId}/items/import`,
{
method: 'POST',
body: { url }
}
);
return response.data;
};
+245 -99
View File
@@ -1,10 +1,5 @@
import { FormEvent, useEffect, useState } from 'react';
import type { CreateItemPayload, ItemStatus } from '../api/items';
const itemStatusOptions: ItemStatus[] = ['active', 'rejected'];
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
import type { CreateItemPayload, ImportedItemData } from '../api/items';
export interface ItemFormModalProps {
isOpen: boolean;
@@ -15,8 +10,36 @@ export interface ItemFormModalProps {
onSubmit: (payload: CreateItemPayload) => Promise<unknown>;
isSubmitting: boolean;
projectAttributes: string[];
initialData: ImportedItemData | null;
isInitialDataLoading: boolean;
initialDataError: string | null;
}
interface AttributeEntry {
id: string;
key: string;
value: string;
}
const createUniqueId = () => Math.random().toString(36).slice(2);
const stringifyAttributeValue = (value: unknown): string => {
if (value === null || value === undefined) {
return '';
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
try {
return JSON.stringify(value);
} catch {
return String(value);
}
};
export function ItemFormModal({
isOpen,
mode,
@@ -25,29 +48,67 @@ export function ItemFormModal({
onSuccess,
onSubmit,
isSubmitting,
projectAttributes
projectAttributes,
initialData,
isInitialDataLoading,
initialDataError
}: ItemFormModalProps) {
const [manufacturer, setManufacturer] = useState('');
const [model, setModel] = useState('');
const [status, setStatus] = useState<ItemStatus>('active');
const [note, setNote] = useState('');
const [sourceUrl, setSourceUrl] = useState('');
const [attributesJson, setAttributesJson] = useState('{}');
const [attributeEntries, setAttributeEntries] = useState<AttributeEntry[]>([]);
const [error, setError] = useState<string | null>(null);
const isFormDisabled = isSubmitting || isInitialDataLoading;
useEffect(() => {
if (!isOpen) {
return;
}
setManufacturer('');
setModel('');
setStatus('active');
setNote('');
setAttributesJson('{}');
setError(null);
setSourceUrl(initialUrl ?? '');
}, [isOpen, initialUrl, mode]);
setManufacturer(initialData?.manufacturer ?? '');
setModel(initialData?.model ?? '');
setNote(initialData?.note ?? '');
const trackedAttributeSet = new Set(
projectAttributes.map((attribute) => attribute.trim()).filter((attribute) => attribute.length)
);
const trackedEntries: AttributeEntry[] = [];
const additionalEntries: AttributeEntry[] = [];
if (initialData?.attributes) {
Object.entries(initialData.attributes).forEach(([rawKey, value]) => {
const key = rawKey.trim();
if (!key.length) {
return;
}
const entry = {
id: createUniqueId(),
key,
value: stringifyAttributeValue(value)
};
if (trackedAttributeSet.has(key)) {
trackedEntries.push(entry);
trackedAttributeSet.delete(key);
} else {
additionalEntries.push(entry);
}
});
}
trackedAttributeSet.forEach((key) => {
trackedEntries.push({
id: createUniqueId(),
key,
value: ''
});
});
setAttributeEntries([...trackedEntries, ...additionalEntries]);
}, [isOpen, initialUrl, initialData, projectAttributes]);
useEffect(() => {
if (!isOpen) {
@@ -68,6 +129,9 @@ export function ItemFormModal({
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (isFormDisabled) {
return;
}
setError(null);
if (!model.trim()) {
@@ -75,30 +139,33 @@ export function ItemFormModal({
return;
}
let attributes: Record<string, unknown> = {};
const trimmedAttributes = attributesJson.trim();
const attributesPayload: Record<string, unknown> = {};
const seenAttributeKeys = new Set<string>();
if (trimmedAttributes) {
try {
const parsed = JSON.parse(trimmedAttributes);
if (!isPlainObject(parsed)) {
setError('Attributes must be a JSON object.');
return;
}
attributes = parsed;
} catch (parseError) {
setError(`Attributes JSON is invalid: ${(parseError as Error).message}`);
for (const attribute of attributeEntries) {
const key = attribute.key.trim();
if (!key) {
setError('Attribute name cannot be empty.');
return;
}
const normalizedKey = key.toLowerCase();
if (seenAttributeKeys.has(normalizedKey)) {
setError(`Attribute name "${key}" is already in use.`);
return;
}
seenAttributeKeys.add(normalizedKey);
attributesPayload[key] = attribute.value.trim() ? attribute.value.trim() : null;
}
try {
await onSubmit({
manufacturer: manufacturer.trim() ? manufacturer.trim() : null,
model: model.trim(),
status,
status: 'active',
note: note.trim() ? note.trim() : null,
attributes,
attributes: attributesPayload,
sourceUrl: sourceUrl.trim() ? sourceUrl.trim() : null
});
onSuccess();
@@ -109,7 +176,7 @@ export function ItemFormModal({
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 px-4"
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 px-4 py-10 md:py-16"
role="dialog"
aria-modal="true"
onClick={() => {
@@ -119,7 +186,7 @@ export function ItemFormModal({
}}
>
<div
className="relative w-full max-w-3xl rounded-2xl bg-white shadow-2xl"
className="relative flex w-full max-w-3xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl max-h-[calc(100vh-5rem)] md:max-h-[calc(100vh-8rem)]"
onClick={(event) => event.stopPropagation()}
>
<button
@@ -131,7 +198,7 @@ export function ItemFormModal({
Close
</button>
<div className="px-8 py-6">
<div className="flex-1 overflow-y-auto px-8 py-6">
<header className="mb-6">
<h2 className="text-xl font-semibold text-slate-900">
{mode === 'url' ? 'Review Item Details' : 'Add Item Manually'}
@@ -143,7 +210,58 @@ export function ItemFormModal({
</p>
</header>
<form className="grid grid-cols-1 gap-5 md:grid-cols-2" onSubmit={handleSubmit}>
{isInitialDataLoading ? (
<div className="mb-4 inline-flex items-center gap-2 rounded-md bg-blue-50 px-3 py-2 text-sm text-blue-700">
<svg
aria-hidden="true"
className="h-4 w-4 animate-spin text-blue-600"
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>
<span>Importing item details</span>
</div>
) : null}
{initialDataError ? (
<p className="mb-4 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-700">
{initialDataError}
</p>
) : null}
<form
className="grid grid-cols-1 gap-5 md:grid-cols-2"
onSubmit={handleSubmit}
aria-busy={isInitialDataLoading}
>
<div className="flex flex-col gap-2 md:col-span-2">
<label className="text-sm font-medium text-slate-700" htmlFor="modal-item-source-url">
Source URL {mode === 'manual' ? '(optional)' : ''}
</label>
<input
id="modal-item-source-url"
type="url"
value={sourceUrl}
onChange={(event) => setSourceUrl(event.target.value)}
disabled={isFormDisabled}
className="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 disabled:cursor-not-allowed disabled:border-slate-200 disabled:bg-slate-100"
placeholder="https://example.com/specs"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-slate-700" htmlFor="modal-item-manufacturer">
Manufacturer / Brand
@@ -153,7 +271,8 @@ export function ItemFormModal({
type="text"
value={manufacturer}
onChange={(event) => setManufacturer(event.target.value)}
className="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"
disabled={isFormDisabled}
className="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 disabled:cursor-not-allowed disabled:border-slate-200 disabled:bg-slate-100"
placeholder="Logitech"
/>
</div>
@@ -168,44 +287,13 @@ export function ItemFormModal({
value={model}
onChange={(event) => setModel(event.target.value)}
required
className="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"
disabled={isFormDisabled}
className="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 disabled:cursor-not-allowed disabled:border-slate-200 disabled:bg-slate-100"
placeholder="MX Master 3"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-slate-700" htmlFor="modal-item-status">
Status
</label>
<select
id="modal-item-status"
value={status}
onChange={(event) => setStatus(event.target.value as ItemStatus)}
className="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"
>
{itemStatusOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-slate-700" htmlFor="modal-item-source-url">
Source URL {mode === 'manual' ? '(optional)' : ''}
</label>
<input
id="modal-item-source-url"
type="url"
value={sourceUrl}
onChange={(event) => setSourceUrl(event.target.value)}
className="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/specs"
/>
</div>
<div className="md:col-span-1 md:row-span-2 flex flex-col gap-2">
<div className="md:col-span-2 flex flex-col gap-2">
<label className="text-sm font-medium text-slate-700" htmlFor="modal-item-note">
Note
</label>
@@ -213,47 +301,105 @@ export function ItemFormModal({
id="modal-item-note"
value={note}
onChange={(event) => setNote(event.target.value)}
rows={4}
className="h-full min-h-[96px] 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"
rows={2}
disabled={isFormDisabled}
className="min-h-[64px] 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 disabled:cursor-not-allowed disabled:border-slate-200 disabled:bg-slate-100"
placeholder="Why is this item interesting?"
/>
</div>
<div className="md:col-span-1 md:row-span-2 flex flex-col gap-2">
<label className="text-sm font-medium text-slate-700" htmlFor="modal-item-attributes">
Attributes JSON
</label>
<textarea
id="modal-item-attributes"
value={attributesJson}
onChange={(event) => setAttributesJson(event.target.value)}
rows={6}
className="h-full min-h-[120px] font-mono 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='{"dpi": 1000, "wireless": true}'
/>
</div>
<div className="md:col-span-2 flex flex-col gap-2">
<span className="text-sm font-medium text-slate-700">Tracked Attributes</span>
{projectAttributes.length ? (
<div className="flex flex-wrap gap-2">
{projectAttributes.map((attribute) => (
<span
key={attribute}
className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-600"
>
{attribute}
</span>
))}
<div className="md:col-span-2 flex flex-col gap-4">
<span className="text-sm font-medium text-slate-700">Attributes</span>
{attributeEntries.length ? (
<div className="overflow-x-auto">
<div className="min-w-[420px] space-y-3">
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-3 text-xs font-medium uppercase tracking-wide text-slate-500">
<span>Key</span>
<span>Value</span>
<span className="text-right">Actions</span>
</div>
{attributeEntries.map((attribute) => (
<div
key={attribute.id}
className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] items-start gap-3"
>
<input
id={`attribute-name-${attribute.id}`}
type="text"
value={attribute.key}
disabled={isFormDisabled}
onChange={(event) => {
const { value } = event.target;
setAttributeEntries((previous) =>
previous.map((item) =>
item.id === attribute.id ? { ...item, key: value } : item
)
);
setError(null);
}}
className="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 disabled:cursor-not-allowed disabled:border-slate-200 disabled:bg-slate-100"
placeholder="Attribute name"
/>
<input
id={`attribute-value-${attribute.id}`}
type="text"
value={attribute.value}
disabled={isFormDisabled}
onChange={(event) => {
const { value } = event.target;
setAttributeEntries((previous) =>
previous.map((item) =>
item.id === attribute.id ? { ...item, value } : item
)
);
setError(null);
}}
className="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 disabled:cursor-not-allowed disabled:border-slate-200 disabled:bg-slate-100"
placeholder="Attribute value"
/>
<div className="flex justify-end">
<button
type="button"
onClick={() => {
setAttributeEntries((previous) =>
previous.filter((item) => item.id !== attribute.id)
);
setError(null);
}}
disabled={isFormDisabled}
className="rounded-md border border-red-200 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-red-600 transition hover:bg-red-50 disabled:cursor-not-allowed disabled:border-slate-200 disabled:text-slate-400 disabled:hover:bg-transparent"
>
Remove
</button>
</div>
</div>
))}
</div>
</div>
) : (
<p className="text-sm text-slate-400">No attributes configured for this project.</p>
<p className="text-sm text-slate-400">No attributes added yet. Add one to get started.</p>
)}
<div className="flex justify-start">
<button
type="button"
onClick={() => {
setAttributeEntries((previous) => [
...previous,
{ id: createUniqueId(), key: '', value: '' }
]);
setError(null);
}}
disabled={isFormDisabled}
className="rounded-md border border-blue-600 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-blue-600 transition hover:bg-blue-50 disabled:cursor-not-allowed disabled:border-slate-200 disabled:text-slate-400 disabled:hover:bg-transparent"
>
Add Attribute
</button>
</div>
</div>
<div className="md:col-span-2 flex items-center justify-between">
<div className="text-xs text-slate-500">
Fields marked with * are required. Attributes must be valid JSON.
Fields marked with * are required. Remove any attribute row you do not need.
</div>
<div className="flex gap-3">
<button
@@ -267,7 +413,7 @@ export function ItemFormModal({
<button
type="submit"
className="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"
disabled={isSubmitting}
disabled={isFormDisabled}
>
{isSubmitting ? 'Saving…' : 'Save Item'}
</button>
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import {
useCreateItemMutation,
@@ -6,6 +6,7 @@ import {
useProjectQuery,
useUpdateProjectMutation
} from '../../query/projects';
import { importItemFromUrl, type ImportedItemData } from '../../api/items';
import { ItemFormModal } from '../../components/item-form-modal';
import { ProjectHeader } from './project-header';
import { ProjectDescriptionSection } from './project-description-section';
@@ -25,6 +26,10 @@ export function ProjectDetailPage() {
const [isItemModalOpen, setIsItemModalOpen] = useState(false);
const [itemModalMode, setItemModalMode] = useState<'url' | 'manual'>('manual');
const [modalInitialUrl, setModalInitialUrl] = useState<string | null>(null);
const [importedItemData, setImportedItemData] = useState<ImportedItemData | null>(null);
const [isImportingItem, setIsImportingItem] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const importRequestIdRef = useRef(0);
const project = projectQuery.data;
const items = useMemo(() => itemsQuery.data?.data ?? [], [itemsQuery.data]);
@@ -58,24 +63,71 @@ export function ProjectDetailPage() {
setIsItemModalOpen(true);
};
const handleAddUrlClick = () => {
const handleAddUrlClick = async () => {
if (isImportingItem) {
return;
}
const trimmed = quickUrl.trim();
if (!trimmed) {
setQuickError('Enter a URL to import item details.');
return;
}
if (!projectId) {
setQuickError('Project identifier is missing. Reload the page and try again.');
return;
}
setQuickError(null);
setImportError(null);
setImportedItemData(null);
const requestId = importRequestIdRef.current + 1;
importRequestIdRef.current = requestId;
openItemModal('url', trimmed);
setIsImportingItem(true);
try {
const data = await importItemFromUrl(projectId, trimmed);
if (importRequestIdRef.current !== requestId) {
return;
}
setImportedItemData(data);
} catch (error) {
if (importRequestIdRef.current !== requestId) {
return;
}
const message =
error instanceof Error && error.message.trim().length
? error.message.trim()
: 'We could not load that URL.';
const normalizedMessage = message.endsWith('.') ? message : `${message}.`;
setImportError(`${normalizedMessage} You can fill in the details manually.`);
} finally {
if (importRequestIdRef.current === requestId) {
setIsImportingItem(false);
}
}
};
const handleAddManualClick = () => {
setQuickError(null);
setImportError(null);
setImportedItemData(null);
setIsImportingItem(false);
importRequestIdRef.current += 1;
openItemModal('manual', quickUrl.trim() || null);
};
const handleModalClose = () => {
setIsItemModalOpen(false);
setQuickError(null);
setImportError(null);
setImportedItemData(null);
setIsImportingItem(false);
importRequestIdRef.current += 1;
};
const handleModalSuccess = () => {
@@ -84,6 +136,10 @@ export function ProjectDetailPage() {
}
setQuickError(null);
setIsItemModalOpen(false);
setImportError(null);
setImportedItemData(null);
setIsImportingItem(false);
importRequestIdRef.current += 1;
};
const handleDescriptionUpdate = async (description: string | null) => {
@@ -154,6 +210,7 @@ export function ProjectDetailPage() {
onQuickUrlChange={handleQuickUrlChange}
onAddUrl={handleAddUrlClick}
onAddManual={handleAddManualClick}
isImportingFromUrl={isImportingItem}
/>
<ItemFormModal
@@ -165,6 +222,9 @@ export function ProjectDetailPage() {
onSubmit={createItemMutation.mutateAsync}
isSubmitting={createItemMutation.isPending}
projectAttributes={projectAttributes}
initialData={itemModalMode === 'url' ? importedItemData : null}
isInitialDataLoading={itemModalMode === 'url' ? isImportingItem : false}
initialDataError={itemModalMode === 'url' ? importError : null}
/>
</div>
);
@@ -14,6 +14,7 @@ interface ProjectItemsSectionProps {
onQuickUrlChange: (value: string) => void;
onAddUrl: () => void;
onAddManual: () => void;
isImportingFromUrl: boolean;
}
const formatAttributeValue = (value: unknown): string => {
@@ -178,7 +179,8 @@ export function ProjectItemsSection({
quickError,
onQuickUrlChange,
onAddUrl,
onAddManual
onAddManual,
isImportingFromUrl
}: ProjectItemsSectionProps) {
const baseColumnCount = 2; // Item, Prices (status indicator lives inside item cell)
const [showDifferencesOnly, setShowDifferencesOnly] = useState(false);
@@ -473,7 +475,7 @@ export function ProjectItemsSection({
<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">
Quick Add Item
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.
@@ -500,9 +502,36 @@ export function ProjectItemsSection({
<button
type="button"
onClick={onAddUrl}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700"
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"
>
Add from URL
{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"