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;
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
attributes = parsed;
|
|
||||||
} catch (parseError) {
|
|
||||||
setError(`Attributes JSON is invalid: ${(parseError as Error).message}`);
|
|
||||||
return;
|
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 {
|
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"
|
</div>
|
||||||
placeholder='{"dpi": 1000, "wireless": true}'
|
{attributeEntries.map((attribute) => (
|
||||||
/>
|
<div
|
||||||
</div>
|
key={attribute.id}
|
||||||
|
className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] items-start gap-3"
|
||||||
<div className="md:col-span-2 flex flex-col gap-2">
|
>
|
||||||
<span className="text-sm font-medium text-slate-700">Tracked Attributes</span>
|
<input
|
||||||
{projectAttributes.length ? (
|
id={`attribute-name-${attribute.id}`}
|
||||||
<div className="flex flex-wrap gap-2">
|
type="text"
|
||||||
{projectAttributes.map((attribute) => (
|
value={attribute.key}
|
||||||
<span
|
disabled={isFormDisabled}
|
||||||
key={attribute}
|
onChange={(event) => {
|
||||||
className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-600"
|
const { value } = event.target;
|
||||||
>
|
setAttributeEntries((previous) =>
|
||||||
{attribute}
|
previous.map((item) =>
|
||||||
</span>
|
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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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() };
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user