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; data: Item;
} }
export interface ImportedItemData {
manufacturer: string | null;
model: string | null;
note: string | null;
attributes: Record<string, unknown>;
}
export const createItem = async ( export const createItem = async (
projectId: string, projectId: string,
payload: CreateItemPayload payload: CreateItemPayload
@@ -105,3 +112,22 @@ export const createItem = async (
return response.data; 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;
};
+240 -94
View File
@@ -1,10 +1,5 @@
import { FormEvent, useEffect, useState } from 'react'; import { FormEvent, useEffect, useState } from 'react';
import type { CreateItemPayload, ItemStatus } from '../api/items'; import type { CreateItemPayload, ImportedItemData } 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);
export interface ItemFormModalProps { export interface ItemFormModalProps {
isOpen: boolean; isOpen: boolean;
@@ -15,8 +10,36 @@ export interface ItemFormModalProps {
onSubmit: (payload: CreateItemPayload) => Promise<unknown>; onSubmit: (payload: CreateItemPayload) => Promise<unknown>;
isSubmitting: boolean; isSubmitting: boolean;
projectAttributes: string[]; 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({ export function ItemFormModal({
isOpen, isOpen,
mode, mode,
@@ -25,29 +48,67 @@ export function ItemFormModal({
onSuccess, onSuccess,
onSubmit, onSubmit,
isSubmitting, isSubmitting,
projectAttributes projectAttributes,
initialData,
isInitialDataLoading,
initialDataError
}: ItemFormModalProps) { }: ItemFormModalProps) {
const [manufacturer, setManufacturer] = useState(''); const [manufacturer, setManufacturer] = useState('');
const [model, setModel] = useState(''); const [model, setModel] = useState('');
const [status, setStatus] = useState<ItemStatus>('active');
const [note, setNote] = useState(''); const [note, setNote] = useState('');
const [sourceUrl, setSourceUrl] = useState(''); const [sourceUrl, setSourceUrl] = useState('');
const [attributesJson, setAttributesJson] = useState('{}'); const [attributeEntries, setAttributeEntries] = useState<AttributeEntry[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const isFormDisabled = isSubmitting || isInitialDataLoading;
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
return; return;
} }
setManufacturer('');
setModel('');
setStatus('active');
setNote('');
setAttributesJson('{}');
setError(null); setError(null);
setSourceUrl(initialUrl ?? ''); 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(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
@@ -68,6 +129,9 @@ export function ItemFormModal({
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => { const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
if (isFormDisabled) {
return;
}
setError(null); setError(null);
if (!model.trim()) { if (!model.trim()) {
@@ -75,30 +139,33 @@ export function ItemFormModal({
return; return;
} }
let attributes: Record<string, unknown> = {}; const attributesPayload: Record<string, unknown> = {};
const trimmedAttributes = attributesJson.trim(); const seenAttributeKeys = new Set<string>();
if (trimmedAttributes) { for (const attribute of attributeEntries) {
try { const key = attribute.key.trim();
const parsed = JSON.parse(trimmedAttributes); if (!key) {
if (!isPlainObject(parsed)) { setError('Attribute name cannot be empty.');
setError('Attributes must be a JSON object.');
return; return;
} }
attributes = parsed;
} catch (parseError) { const normalizedKey = key.toLowerCase();
setError(`Attributes JSON is invalid: ${(parseError as Error).message}`); if (seenAttributeKeys.has(normalizedKey)) {
setError(`Attribute name "${key}" is already in use.`);
return; return;
} }
seenAttributeKeys.add(normalizedKey);
attributesPayload[key] = attribute.value.trim() ? attribute.value.trim() : null;
} }
try { try {
await onSubmit({ await onSubmit({
manufacturer: manufacturer.trim() ? manufacturer.trim() : null, manufacturer: manufacturer.trim() ? manufacturer.trim() : null,
model: model.trim(), model: model.trim(),
status, status: 'active',
note: note.trim() ? note.trim() : null, note: note.trim() ? note.trim() : null,
attributes, attributes: attributesPayload,
sourceUrl: sourceUrl.trim() ? sourceUrl.trim() : null sourceUrl: sourceUrl.trim() ? sourceUrl.trim() : null
}); });
onSuccess(); onSuccess();
@@ -109,7 +176,7 @@ export function ItemFormModal({
return ( return (
<div <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" role="dialog"
aria-modal="true" aria-modal="true"
onClick={() => { onClick={() => {
@@ -119,7 +186,7 @@ export function ItemFormModal({
}} }}
> >
<div <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()} onClick={(event) => event.stopPropagation()}
> >
<button <button
@@ -131,7 +198,7 @@ export function ItemFormModal({
Close Close
</button> </button>
<div className="px-8 py-6"> <div className="flex-1 overflow-y-auto px-8 py-6">
<header className="mb-6"> <header className="mb-6">
<h2 className="text-xl font-semibold text-slate-900"> <h2 className="text-xl font-semibold text-slate-900">
{mode === 'url' ? 'Review Item Details' : 'Add Item Manually'} {mode === 'url' ? 'Review Item Details' : 'Add Item Manually'}
@@ -143,7 +210,58 @@ export function ItemFormModal({
</p> </p>
</header> </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"> <div className="flex flex-col gap-2">
<label className="text-sm font-medium text-slate-700" htmlFor="modal-item-manufacturer"> <label className="text-sm font-medium text-slate-700" htmlFor="modal-item-manufacturer">
Manufacturer / Brand Manufacturer / Brand
@@ -153,7 +271,8 @@ export function ItemFormModal({
type="text" type="text"
value={manufacturer} value={manufacturer}
onChange={(event) => setManufacturer(event.target.value)} 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" placeholder="Logitech"
/> />
</div> </div>
@@ -168,44 +287,13 @@ export function ItemFormModal({
value={model} value={model}
onChange={(event) => setModel(event.target.value)} onChange={(event) => setModel(event.target.value)}
required 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" placeholder="MX Master 3"
/> />
</div> </div>
<div className="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-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">
<label className="text-sm font-medium text-slate-700" htmlFor="modal-item-note"> <label className="text-sm font-medium text-slate-700" htmlFor="modal-item-note">
Note Note
</label> </label>
@@ -213,47 +301,105 @@ export function ItemFormModal({
id="modal-item-note" id="modal-item-note"
value={note} value={note}
onChange={(event) => setNote(event.target.value)} onChange={(event) => setNote(event.target.value)}
rows={4} rows={2}
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" 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?" placeholder="Why is this item interesting?"
/> />
</div> </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-4">
<label className="text-sm font-medium text-slate-700" htmlFor="modal-item-attributes"> <span className="text-sm font-medium text-slate-700">Attributes</span>
Attributes JSON {attributeEntries.length ? (
</label> <div className="overflow-x-auto">
<textarea <div className="min-w-[420px] space-y-3">
id="modal-item-attributes" <div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-3 text-xs font-medium uppercase tracking-wide text-slate-500">
value={attributesJson} <span>Key</span>
onChange={(event) => setAttributesJson(event.target.value)} <span>Value</span>
rows={6} <span className="text-right">Actions</span>
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>
{attributeEntries.map((attribute) => (
<div className="md:col-span-2 flex flex-col gap-2"> <div
<span className="text-sm font-medium text-slate-700">Tracked Attributes</span> key={attribute.id}
{projectAttributes.length ? ( className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] items-start gap-3"
<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} <input
</span> 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>
</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>
<div className="md:col-span-2 flex items-center justify-between"> <div className="md:col-span-2 flex items-center justify-between">
<div className="text-xs text-slate-500"> <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>
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
@@ -267,7 +413,7 @@ export function ItemFormModal({
<button <button
type="submit" 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" 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'} {isSubmitting ? 'Saving…' : 'Save Item'}
</button> </button>
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'; import { useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { import {
useCreateItemMutation, useCreateItemMutation,
@@ -6,6 +6,7 @@ import {
useProjectQuery, useProjectQuery,
useUpdateProjectMutation useUpdateProjectMutation
} from '../../query/projects'; } from '../../query/projects';
import { importItemFromUrl, type ImportedItemData } from '../../api/items';
import { ItemFormModal } from '../../components/item-form-modal'; import { ItemFormModal } from '../../components/item-form-modal';
import { ProjectHeader } from './project-header'; import { ProjectHeader } from './project-header';
import { ProjectDescriptionSection } from './project-description-section'; import { ProjectDescriptionSection } from './project-description-section';
@@ -25,6 +26,10 @@ export function ProjectDetailPage() {
const [isItemModalOpen, setIsItemModalOpen] = useState(false); const [isItemModalOpen, setIsItemModalOpen] = useState(false);
const [itemModalMode, setItemModalMode] = useState<'url' | 'manual'>('manual'); const [itemModalMode, setItemModalMode] = useState<'url' | 'manual'>('manual');
const [modalInitialUrl, setModalInitialUrl] = useState<string | null>(null); 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 project = projectQuery.data;
const items = useMemo(() => itemsQuery.data?.data ?? [], [itemsQuery.data]); const items = useMemo(() => itemsQuery.data?.data ?? [], [itemsQuery.data]);
@@ -58,24 +63,71 @@ export function ProjectDetailPage() {
setIsItemModalOpen(true); setIsItemModalOpen(true);
}; };
const handleAddUrlClick = () => { const handleAddUrlClick = async () => {
if (isImportingItem) {
return;
}
const trimmed = quickUrl.trim(); const trimmed = quickUrl.trim();
if (!trimmed) { if (!trimmed) {
setQuickError('Enter a URL to import item details.'); setQuickError('Enter a URL to import item details.');
return; return;
} }
if (!projectId) {
setQuickError('Project identifier is missing. Reload the page and try again.');
return;
}
setQuickError(null); setQuickError(null);
setImportError(null);
setImportedItemData(null);
const requestId = importRequestIdRef.current + 1;
importRequestIdRef.current = requestId;
openItemModal('url', trimmed); 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 = () => { const handleAddManualClick = () => {
setQuickError(null); setQuickError(null);
setImportError(null);
setImportedItemData(null);
setIsImportingItem(false);
importRequestIdRef.current += 1;
openItemModal('manual', quickUrl.trim() || null); openItemModal('manual', quickUrl.trim() || null);
}; };
const handleModalClose = () => { const handleModalClose = () => {
setIsItemModalOpen(false); setIsItemModalOpen(false);
setQuickError(null); setQuickError(null);
setImportError(null);
setImportedItemData(null);
setIsImportingItem(false);
importRequestIdRef.current += 1;
}; };
const handleModalSuccess = () => { const handleModalSuccess = () => {
@@ -84,6 +136,10 @@ export function ProjectDetailPage() {
} }
setQuickError(null); setQuickError(null);
setIsItemModalOpen(false); setIsItemModalOpen(false);
setImportError(null);
setImportedItemData(null);
setIsImportingItem(false);
importRequestIdRef.current += 1;
}; };
const handleDescriptionUpdate = async (description: string | null) => { const handleDescriptionUpdate = async (description: string | null) => {
@@ -154,6 +210,7 @@ export function ProjectDetailPage() {
onQuickUrlChange={handleQuickUrlChange} onQuickUrlChange={handleQuickUrlChange}
onAddUrl={handleAddUrlClick} onAddUrl={handleAddUrlClick}
onAddManual={handleAddManualClick} onAddManual={handleAddManualClick}
isImportingFromUrl={isImportingItem}
/> />
<ItemFormModal <ItemFormModal
@@ -165,6 +222,9 @@ export function ProjectDetailPage() {
onSubmit={createItemMutation.mutateAsync} onSubmit={createItemMutation.mutateAsync}
isSubmitting={createItemMutation.isPending} isSubmitting={createItemMutation.isPending}
projectAttributes={projectAttributes} projectAttributes={projectAttributes}
initialData={itemModalMode === 'url' ? importedItemData : null}
isInitialDataLoading={itemModalMode === 'url' ? isImportingItem : false}
initialDataError={itemModalMode === 'url' ? importError : null}
/> />
</div> </div>
); );
@@ -14,6 +14,7 @@ interface ProjectItemsSectionProps {
onQuickUrlChange: (value: string) => void; onQuickUrlChange: (value: string) => void;
onAddUrl: () => void; onAddUrl: () => void;
onAddManual: () => void; onAddManual: () => void;
isImportingFromUrl: boolean;
} }
const formatAttributeValue = (value: unknown): string => { const formatAttributeValue = (value: unknown): string => {
@@ -178,7 +179,8 @@ export function ProjectItemsSection({
quickError, quickError,
onQuickUrlChange, onQuickUrlChange,
onAddUrl, onAddUrl,
onAddManual onAddManual,
isImportingFromUrl
}: ProjectItemsSectionProps) { }: ProjectItemsSectionProps) {
const baseColumnCount = 2; // Item, Prices (status indicator lives inside item cell) const baseColumnCount = 2; // Item, Prices (status indicator lives inside item cell)
const [showDifferencesOnly, setShowDifferencesOnly] = useState(false); 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"> <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"> <h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500">
Quick Add Item Add Item
</h3> </h3>
<p className="mt-2 text-sm text-slate-500"> <p className="mt-2 text-sm text-slate-500">
Paste a product link to pre-fill details or switch to manual entry. Paste a product link to pre-fill details or switch to manual entry.
@@ -500,9 +502,36 @@ export function ProjectItemsSection({
<button <button
type="button" type="button"
onClick={onAddUrl} 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>
<button <button
type="button" type="button"
@@ -11,6 +11,7 @@ import { resolveUrlId } from './url-helpers.js';
import { parsePaginationParams, parseUuid } from '../validation/common.js'; import { parsePaginationParams, parseUuid } from '../validation/common.js';
import { import {
parseItemCreatePayload, parseItemCreatePayload,
parseItemImportPayload,
parseItemStatusFilter, parseItemStatusFilter,
parseItemUpdatePayload parseItemUpdatePayload
} from '../validation/items.js'; } from '../validation/items.js';
@@ -120,3 +121,64 @@ export const deleteItem = async (req: Request, res: Response) => {
res.status(204).send(); res.status(204).send();
}; };
export const importItemFromUrl = async (req: Request, res: Response) => {
await new Promise((resolve) => {
setTimeout(resolve, 1000);
})
const projectId = parseUuid(req.params.projectId, 'projectId');
// projectId is validated but not otherwise used for the mock response.
void projectId;
const { url } = parseItemImportPayload(req.body);
const loweredUrl = url.toLowerCase();
if (loweredUrl.includes('fail') || loweredUrl.includes('error')) {
throw new HttpError(
422,
'We could not read that URL. Please enter the item details manually.'
);
}
let hostname: string | null = null;
let lastPathSegment: string | null = null;
try {
const parsed = new URL(url);
hostname = parsed.hostname.replace(/^www\./, '');
const pathSegments = parsed.pathname.split('/').filter(Boolean);
if (pathSegments.length) {
lastPathSegment = pathSegments[pathSegments.length - 1];
}
} catch {
// non-standard URLs fall back to generic mock content
}
const manufacturerBase = hostname ? hostname.split('.')[0] : 'imported';
const manufacturer =
manufacturerBase.charAt(0).toUpperCase() + manufacturerBase.slice(1);
const cleanedSegment =
lastPathSegment?.replace(/[-_]/g, ' ').replace(/\s+/g, ' ').trim() ?? 'Item';
const model = cleanedSegment.length ? cleanedSegment : 'Imported Item';
const attributes: Record<string, unknown> = {
originUrl: url,
importedAt: new Date().toISOString(),
condition: 'New',
availability: 'In stock',
dpi: 1200,
};
if (hostname) {
attributes.domain = hostname;
}
res.json({
data: {
manufacturer,
model,
note: null,
attributes
}
});
};
+2
View File
@@ -3,6 +3,7 @@ import {
createItem, createItem,
deleteItem, deleteItem,
getItem, getItem,
importItemFromUrl,
listItems, listItems,
updateItem updateItem
} from '../controllers/items-controller.js'; } from '../controllers/items-controller.js';
@@ -33,6 +34,7 @@ apiRouter.delete('/projects/:projectId', asyncHandler(deleteProject));
// Project items // Project items
apiRouter.get('/projects/:projectId/items', asyncHandler(listItems)); apiRouter.get('/projects/:projectId/items', asyncHandler(listItems));
apiRouter.post('/projects/:projectId/items/import', asyncHandler(importItemFromUrl));
apiRouter.post('/projects/:projectId/items', asyncHandler(createItem)); apiRouter.post('/projects/:projectId/items', asyncHandler(createItem));
// Items // Items
+18
View File
@@ -183,3 +183,21 @@ export const parseItemStatusFilter = (
} }
return value as 'active' | 'rejected'; return value as 'active' | 'rejected';
}; };
export interface ItemImportPayload {
url: string;
}
export const parseItemImportPayload = (payload: unknown): ItemImportPayload => {
if (payload === null || typeof payload !== 'object') {
throw new HttpError(400, 'Body must be an object');
}
const { url } = payload as Record<string, unknown>;
if (typeof url !== 'string' || url.trim().length === 0) {
throw new HttpError(400, 'url must be a non-empty string');
}
return { url: url.trim() };
};