mirror of
https://github.com/sakaljurgis/best-choice.git
synced 2026-07-08 21:47:40 +00:00
add item by url 1st step
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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.');
|
||||
for (const attribute of attributeEntries) {
|
||||
const key = attribute.key.trim();
|
||||
if (!key) {
|
||||
setError('Attribute name cannot be empty.');
|
||||
return;
|
||||
}
|
||||
attributes = parsed;
|
||||
} catch (parseError) {
|
||||
setError(`Attributes JSON is invalid: ${(parseError as Error).message}`);
|
||||
|
||||
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 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>
|
||||
|
||||
<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"
|
||||
{attributeEntries.map((attribute) => (
|
||||
<div
|
||||
key={attribute.id}
|
||||
className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] items-start gap-3"
|
||||
>
|
||||
{attribute}
|
||||
</span>
|
||||
<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"
|
||||
|
||||
@@ -11,6 +11,7 @@ import { resolveUrlId } from './url-helpers.js';
|
||||
import { parsePaginationParams, parseUuid } from '../validation/common.js';
|
||||
import {
|
||||
parseItemCreatePayload,
|
||||
parseItemImportPayload,
|
||||
parseItemStatusFilter,
|
||||
parseItemUpdatePayload
|
||||
} from '../validation/items.js';
|
||||
@@ -120,3 +121,64 @@ export const deleteItem = async (req: Request, res: Response) => {
|
||||
|
||||
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
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
createItem,
|
||||
deleteItem,
|
||||
getItem,
|
||||
importItemFromUrl,
|
||||
listItems,
|
||||
updateItem
|
||||
} from '../controllers/items-controller.js';
|
||||
@@ -33,6 +34,7 @@ apiRouter.delete('/projects/:projectId', asyncHandler(deleteProject));
|
||||
|
||||
// Project items
|
||||
apiRouter.get('/projects/:projectId/items', asyncHandler(listItems));
|
||||
apiRouter.post('/projects/:projectId/items/import', asyncHandler(importItemFromUrl));
|
||||
apiRouter.post('/projects/:projectId/items', asyncHandler(createItem));
|
||||
|
||||
// Items
|
||||
|
||||
@@ -183,3 +183,21 @@ export const parseItemStatusFilter = (
|
||||
}
|
||||
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() };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user