Add category suggestions to product editor, deal with taxonomy embeddings better, fix category badge overflow
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { useState, useMemo, useCallback, useLayoutEffect, useRef } from "react";
|
||||
import { Check, ChevronsUpDown, Sparkles, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { badgeVariants } from "@/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FieldOption } from "./types";
|
||||
import type { TaxonomySuggestion } from "@/components/product-import/steps/ValidationStep/store/types";
|
||||
|
||||
interface ColorOption extends FieldOption {
|
||||
hex?: string;
|
||||
@@ -34,6 +36,39 @@ function isWhite(hex: string) {
|
||||
return /^#?f{3,6}$/i.test(hex);
|
||||
}
|
||||
|
||||
function TruncatedBadge({ label, hex }: { label: string; hex?: string }) {
|
||||
const textRef = useRef<HTMLSpanElement>(null);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = textRef.current;
|
||||
if (el) setIsTruncated(el.scrollWidth > el.clientWidth);
|
||||
}, [label]);
|
||||
|
||||
return (
|
||||
<Tooltip open={isTruncated ? undefined : false}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn(badgeVariants({ variant: "secondary" }), "text-[11px] py-0 px-1.5 gap-1 font-normal max-w-full")}>
|
||||
{hex && (
|
||||
<span
|
||||
className={cn("inline-block h-2.5 w-2.5 rounded-full shrink-0", isWhite(hex) && "border border-black")}
|
||||
style={{ backgroundColor: hex }}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
ref={textRef}
|
||||
className="overflow-hidden whitespace-nowrap"
|
||||
style={{ direction: "rtl", textOverflow: "ellipsis" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditableMultiSelect({
|
||||
options,
|
||||
value,
|
||||
@@ -42,6 +77,9 @@ export function EditableMultiSelect({
|
||||
placeholder,
|
||||
searchPlaceholder,
|
||||
showColors,
|
||||
suggestions,
|
||||
isLoadingSuggestions,
|
||||
onOpen,
|
||||
}: {
|
||||
options: FieldOption[];
|
||||
value: string[];
|
||||
@@ -50,9 +88,17 @@ export function EditableMultiSelect({
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
showColors?: boolean;
|
||||
suggestions?: TaxonomySuggestion[];
|
||||
isLoadingSuggestions?: boolean;
|
||||
onOpen?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleOpenChange = useCallback((isOpen: boolean) => {
|
||||
setOpen(isOpen);
|
||||
if (isOpen) onOpen?.();
|
||||
}, [onOpen]);
|
||||
|
||||
const selectedLabels = useMemo(() => {
|
||||
return value.map((v) => {
|
||||
const opt = options.find((o) => String(o.value) === String(v));
|
||||
@@ -82,7 +128,7 @@ export function EditableMultiSelect({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
onClick={() => handleOpenChange(true)}
|
||||
className={cn(
|
||||
"flex flex-col h-auto w-full rounded-md border border-muted-foreground/50 bg-transparent px-3 py-1 text-sm text-left",
|
||||
"hover:border-input hover:bg-muted/50 transition-colors",
|
||||
@@ -98,22 +144,7 @@ export function EditableMultiSelect({
|
||||
) : (
|
||||
<span className="flex flex-wrap gap-1 w-full">
|
||||
{selectedLabels.map((s) => (
|
||||
<Badge
|
||||
key={s.value}
|
||||
variant="secondary"
|
||||
className="text-[11px] py-0 px-1.5 gap-1 shrink-0 font-normal"
|
||||
>
|
||||
{s.hex && (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-2.5 w-2.5 rounded-full shrink-0",
|
||||
isWhite(s.hex) && "border border-black"
|
||||
)}
|
||||
style={{ backgroundColor: s.hex }}
|
||||
/>
|
||||
)}
|
||||
{s.label}
|
||||
</Badge>
|
||||
<TruncatedBadge key={s.value} label={s.label} hex={s.hex} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
@@ -126,7 +157,7 @@ export function EditableMultiSelect({
|
||||
{label && (
|
||||
<span className="text-xs text-muted-foreground mb-1 block">{label}</span>
|
||||
)}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -192,9 +223,54 @@ export function EditableMultiSelect({
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* AI Suggestions section */}
|
||||
{(suggestions && suggestions.length > 0) || isLoadingSuggestions ? (
|
||||
<CommandGroup>
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-purple-600 dark:text-purple-400 bg-purple-50/80 dark:bg-purple-950/40 border-b border-purple-100 dark:border-purple-900">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
<span>Suggested</span>
|
||||
{isLoadingSuggestions && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
</div>
|
||||
{suggestions?.slice(0, 5).map((suggestion) => {
|
||||
const isSelected = value.includes(String(suggestion.id));
|
||||
if (isSelected) return null;
|
||||
const similarityPercent = Math.round(suggestion.similarity * 100);
|
||||
const opt = options.find((o) => String(o.value) === String(suggestion.id)) as ColorOption | undefined;
|
||||
const hex = showColors && opt ? getHex(opt) : undefined;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`suggestion-${suggestion.id}`}
|
||||
value={`suggestion-${suggestion.name}`}
|
||||
onSelect={() => handleSelect(String(suggestion.id))}
|
||||
className="bg-purple-50/30 dark:bg-purple-950/20"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<Check className="h-4 w-4 flex-shrink-0 opacity-0" />
|
||||
{hex && (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-3.5 w-3.5 rounded-full mr-2 shrink-0",
|
||||
isWhite(hex) && "border border-black"
|
||||
)}
|
||||
style={{ backgroundColor: hex }}
|
||||
/>
|
||||
)}
|
||||
<span title={suggestion.fullPath || suggestion.name}>
|
||||
{showColors ? suggestion.name : (suggestion.fullPath || suggestion.name)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-purple-500 dark:text-purple-400 ml-2 flex-shrink-0">
|
||||
{similarityPercent}%
|
||||
</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
) : null}
|
||||
|
||||
{/* All options (excluding already-selected) */}
|
||||
<CommandGroup
|
||||
heading={value.length > 0 ? "All Options" : undefined}
|
||||
heading={value.length > 0 || (suggestions && suggestions.length > 0) ? "All Options" : undefined}
|
||||
>
|
||||
{options
|
||||
.filter((o) => !value.includes(String(o.value)))
|
||||
|
||||
@@ -16,6 +16,7 @@ import { submitProductEdit, submitImageChanges, submitTaxonomySet, type ImageCha
|
||||
import { EditableComboboxField } from "./EditableComboboxField";
|
||||
import { EditableInput } from "./EditableInput";
|
||||
import { EditableMultiSelect } from "./EditableMultiSelect";
|
||||
import { useProductSuggestions } from "./useProductSuggestions";
|
||||
import { ImageManager, MiniImagePreview } from "./ImageManager";
|
||||
import type {
|
||||
SearchProduct,
|
||||
@@ -503,6 +504,20 @@ export function ProductEditForm({
|
||||
}
|
||||
}, [getValues, fieldOptions, validateDescription, clearDescriptionResult]);
|
||||
|
||||
// --- Embedding-based taxonomy suggestions ---
|
||||
const {
|
||||
categories: categorySuggestions,
|
||||
themes: themeSuggestions,
|
||||
colors: colorSuggestions,
|
||||
isLoading: isSuggestionsLoading,
|
||||
triggerFetch: triggerSuggestions,
|
||||
} = useProductSuggestions({
|
||||
name: product.title,
|
||||
description: product.description,
|
||||
company_name: product.brand,
|
||||
line_name: product.line,
|
||||
});
|
||||
|
||||
const hasImageChanges = computeImageChanges() !== null;
|
||||
const changedCount = Object.keys(dirtyFields).length;
|
||||
|
||||
@@ -560,6 +575,11 @@ export function ProductEditForm({
|
||||
);
|
||||
}
|
||||
if (fc.type === "multiselect") {
|
||||
const fieldSuggestions =
|
||||
fc.key === "categories" ? categorySuggestions :
|
||||
fc.key === "themes" ? themeSuggestions :
|
||||
fc.key === "colors" ? colorSuggestions :
|
||||
undefined;
|
||||
return wrapSpan(
|
||||
<Controller
|
||||
key={fc.key}
|
||||
@@ -574,6 +594,9 @@ export function ProductEditForm({
|
||||
placeholder="—"
|
||||
searchPlaceholder={fc.searchPlaceholder}
|
||||
showColors={fc.showColors}
|
||||
suggestions={fieldSuggestions}
|
||||
isLoadingSuggestions={isSuggestionsLoading}
|
||||
onOpen={triggerSuggestions}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* useProductSuggestions Hook
|
||||
*
|
||||
* Lazily fetches embedding-based taxonomy suggestions (categories, themes, colors)
|
||||
* for a product in the product editor.
|
||||
*
|
||||
* Mirrors the logic in AiSuggestionsContext but simplified for single-product use:
|
||||
* - Fetches once on first triggerFetch() call (no eager batch loading)
|
||||
* - Caches results in local state for the lifetime of the component
|
||||
* - Module-level init promise shared across all instances
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import type { TaxonomySuggestion, ProductSuggestions } from '@/components/product-import/steps/ValidationStep/store/types';
|
||||
|
||||
const API_BASE = '/api/ai';
|
||||
|
||||
// Module-level init promise — shared so we only call /initialize once
|
||||
let initPromise: Promise<boolean> | null = null;
|
||||
|
||||
async function ensureInitialized(): Promise<boolean> {
|
||||
if (!initPromise) {
|
||||
initPromise = fetch(`${API_BASE}/initialize`, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then((d) => Boolean(d.success))
|
||||
.catch(() => {
|
||||
initPromise = null; // allow retry on next call
|
||||
return false;
|
||||
});
|
||||
}
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
interface ProductInput {
|
||||
name?: string;
|
||||
description?: string;
|
||||
company_name?: string;
|
||||
line_name?: string;
|
||||
}
|
||||
|
||||
export interface ProductSuggestionResults {
|
||||
categories: TaxonomySuggestion[];
|
||||
themes: TaxonomySuggestion[];
|
||||
colors: TaxonomySuggestion[];
|
||||
isLoading: boolean;
|
||||
/** Call when a taxonomy dropdown opens to trigger a lazy fetch */
|
||||
triggerFetch: () => void;
|
||||
}
|
||||
|
||||
export function useProductSuggestions(product: ProductInput): ProductSuggestionResults {
|
||||
const [categories, setCategories] = useState<TaxonomySuggestion[]>([]);
|
||||
const [themes, setThemes] = useState<TaxonomySuggestion[]>([]);
|
||||
const [colors, setColors] = useState<TaxonomySuggestion[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Store current product in a ref so triggerFetch can read it without being re-created
|
||||
const productRef = useRef(product);
|
||||
productRef.current = product;
|
||||
|
||||
// Pre-warm: start initialization as soon as the form mounts so it's ready before
|
||||
// the first dropdown opens. With the disk cache this completes in < 1 second.
|
||||
useEffect(() => {
|
||||
ensureInitialized();
|
||||
}, []);
|
||||
|
||||
// Prevent duplicate fetches
|
||||
const hasFetchedRef = useRef(false);
|
||||
|
||||
const triggerFetch = useCallback(async () => {
|
||||
if (hasFetchedRef.current) return;
|
||||
const p = productRef.current;
|
||||
if (!p.name && !p.company_name) return;
|
||||
|
||||
hasFetchedRef.current = true;
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const ready = await ensureInitialized();
|
||||
if (!ready) {
|
||||
hasFetchedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/suggestions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ product: p }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
hasFetchedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const data: ProductSuggestions = await response.json();
|
||||
setCategories(data.categories ?? []);
|
||||
setThemes(data.themes ?? []);
|
||||
setColors(data.colors ?? []);
|
||||
} catch {
|
||||
hasFetchedRef.current = false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { categories, themes, colors, isLoading, triggerFetch };
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user