Add AI name/description validation to product editor

This commit is contained in:
2026-02-17 09:54:37 -05:00
parent bae8c575bc
commit c3e09d5fd1
208 changed files with 833 additions and 71901 deletions
@@ -0,0 +1,234 @@
/**
* AiDescriptionCompare
*
* Shared side-by-side description editor for AI validation results.
* Shows the current description next to the AI-suggested version,
* both editable, with issues list and accept/dismiss actions.
*
* Layout uses a ResizeObserver to measure the right-side header+issues
* area and mirrors that height as a spacer on the left so both
* textareas start at the same vertical position. Textareas auto-resize
* to fit their content; the parent container controls overflow.
*
* Used by:
* - MultilineInput (inside a Popover, in the import validation table)
* - ProductEditForm (inside a Dialog, in the product editor)
*/
import { useState, useEffect, useRef, useCallback } from "react";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Sparkles, AlertCircle, Check } from "lucide-react";
import { cn } from "@/lib/utils";
export interface AiDescriptionCompareProps {
currentValue: string;
onCurrentChange: (value: string) => void;
suggestion: string;
issues: string[];
onAccept: (editedSuggestion: string) => void;
onDismiss: () => void;
productName?: string;
className?: string;
}
export function AiDescriptionCompare({
currentValue,
onCurrentChange,
suggestion,
issues,
onAccept,
onDismiss,
productName,
className,
}: AiDescriptionCompareProps) {
const [editedSuggestion, setEditedSuggestion] = useState(suggestion);
const [aiHeaderHeight, setAiHeaderHeight] = useState(0);
const aiHeaderRef = useRef<HTMLDivElement>(null);
const mainTextareaRef = useRef<HTMLTextAreaElement>(null);
const suggestionTextareaRef = useRef<HTMLTextAreaElement>(null);
// Reset edited suggestion when the suggestion prop changes
useEffect(() => {
setEditedSuggestion(suggestion);
}, [suggestion]);
// Measure right-side header+issues area for left-side spacer alignment.
// Wrapped in rAF because Radix portals mount asynchronously — the ref
// is null on the first synchronous run.
useEffect(() => {
let observer: ResizeObserver | null = null;
const rafId = requestAnimationFrame(() => {
const el = aiHeaderRef.current;
if (!el) return;
observer = new ResizeObserver(([entry]) => {
// Subtract 8px to compensate for the left column's py-2 top padding,
// so both "Current Description" and "Suggested" labels align vertically.
setAiHeaderHeight(Math.max(0, entry.contentRect.height - 8));
});
observer.observe(el);
});
return () => {
cancelAnimationFrame(rafId);
observer?.disconnect();
};
}, []);
// Auto-resize both textareas to fit content, then equalize their heights
// on desktop so tops and bottoms align exactly.
const syncTextareaHeights = useCallback(() => {
const main = mainTextareaRef.current;
const suggestion = suggestionTextareaRef.current;
if (!main && !suggestion) return;
// Reset to auto to measure natural content height
if (main) main.style.height = "auto";
if (suggestion) suggestion.style.height = "auto";
const mainH = main?.scrollHeight ?? 0;
const suggestionH = suggestion?.scrollHeight ?? 0;
// On desktop (lg), equalize so both textareas are the same height
const isDesktop = window.matchMedia("(min-width: 1024px)").matches;
const targetH = isDesktop ? Math.max(mainH, suggestionH) : 0;
if (main) main.style.height = `${targetH || mainH}px`;
if (suggestion) suggestion.style.height = `${targetH || suggestionH}px`;
}, []);
// Sync heights on mount and when content changes.
// Retry after a short delay to handle dialog/popover entry animations
// where the DOM isn't fully laid out on the first frame.
useEffect(() => {
requestAnimationFrame(syncTextareaHeights);
const timer = setTimeout(syncTextareaHeights, 200);
return () => clearTimeout(timer);
}, [currentValue, editedSuggestion, syncTextareaHeights]);
return (
<div className={cn("flex flex-col lg:flex-row items-stretch", className)}>
{/* Left: current description */}
<div className="flex flex-col min-h-0 w-full lg:w-1/2">
<div className="px-3 py-2 bg-accent flex flex-col flex-1 min-h-0">
{/* Product name - shown inline on mobile */}
{productName && (
<div className="flex-shrink-0 flex flex-col lg:hidden px-1 mb-2">
<div className="text-sm font-medium text-foreground mb-1">
Editing description for:
</div>
<div className="text-md font-semibold text-foreground">
{productName}
</div>
</div>
)}
{/* Desktop spacer matching the right-side header+issues height */}
{aiHeaderHeight > 0 && (
<div
className="flex-shrink-0 hidden lg:flex items-start"
style={{ height: aiHeaderHeight }}
>
{productName && (
<div className="flex flex-col">
<div className="text-sm font-medium text-foreground px-1 mb-1">
Editing description for:
</div>
<div className="text-md font-semibold text-foreground px-1">
{productName}
</div>
</div>
)}
</div>
)}
<div className="text-sm mb-1 font-medium flex items-center gap-2 flex-shrink-0">
Current Description:
</div>
<Textarea
ref={mainTextareaRef}
value={currentValue}
onChange={(e) => {
onCurrentChange(e.target.value);
syncTextareaHeights();
}}
className="overflow-y-auto overscroll-contain text-sm resize-y bg-white min-h-[120px]"
/>
{/* Footer spacer matching the action buttons height on the right */}
<div className="h-[43px] flex-shrink-0 hidden lg:block" />
</div>
</div>
{/* Right: AI suggestion */}
<div className="bg-purple-50/80 dark:bg-purple-950/30 flex flex-col w-full lg:w-1/2">
{/* Measured header + issues area (height mirrored as spacer on the left) */}
<div ref={aiHeaderRef} className="flex-shrink-0">
{/* Header */}
<div className="w-full flex items-center justify-between px-3 py-2">
<div className="flex items-center gap-2">
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
<span className="text-xs font-medium text-purple-600 dark:text-purple-400">
AI Suggestion
</span>
<span className="text-xs text-purple-500 dark:text-purple-400">
({issues.length} {issues.length === 1 ? "issue" : "issues"})
</span>
</div>
</div>
{/* Issues list */}
{issues.length > 0 && (
<div className="flex flex-col gap-1 px-3 pb-3">
{issues.map((issue, index) => (
<div
key={index}
className="flex items-start gap-1.5 text-xs text-purple-600 dark:text-purple-400"
>
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" />
<span>{issue}</span>
</div>
))}
</div>
)}
</div>
{/* Content */}
<div className="px-3 pb-3 flex flex-col flex-1 gap-3">
{/* Editable suggestion */}
<div className="flex flex-col flex-1">
<div className="text-sm text-purple-500 dark:text-purple-400 mb-1 font-medium flex-shrink-0">
Suggested (editable):
</div>
<Textarea
ref={suggestionTextareaRef}
value={editedSuggestion}
onChange={(e) => {
setEditedSuggestion(e.target.value);
syncTextareaHeights();
}}
className="overflow-y-auto overscroll-contain text-sm bg-white dark:bg-black/20 border-purple-200 dark:border-purple-700 focus-visible:ring-purple-400 resize-y min-h-[120px]"
/>
</div>
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0">
<Button
size="sm"
variant="outline"
className="h-7 px-3 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400"
onClick={() => onAccept(editedSuggestion)}
>
<Check className="h-3 w-3 mr-1" />
Replace With Suggestion
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-3 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400"
onClick={onDismiss}
>
Ignore
</Button>
</div>
</div>
</div>
</div>
);
}
@@ -17,6 +17,7 @@ export function EditableInput({
copyable,
alwaysShowCopy,
formatDisplay,
rightAction,
}: {
value: string;
onChange: (val: string) => void;
@@ -30,6 +31,7 @@ export function EditableInput({
copyable?: boolean;
alwaysShowCopy?: boolean;
formatDisplay?: (val: string) => string;
rightAction?: React.ReactNode;
}) {
const [editing, setEditing] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
@@ -106,6 +108,7 @@ export function EditableInput({
<Copy className="h-3 w-3" />
</button>
)}
{rightAction}
</div>
);
}
@@ -7,7 +7,11 @@ import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Loader2, X, Copy, Maximize2, Minus, Store, Terminal, ExternalLink } from "lucide-react";
import { Loader2, X, Copy, Maximize2, Minus, Store, Terminal, ExternalLink, Sparkles } from "lucide-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useInlineAiValidation } from "@/components/product-import/steps/ValidationStep/hooks/useInlineAiValidation";
import { AiSuggestionBadge } from "@/components/product-import/steps/ValidationStep/components/AiSuggestionBadge";
import { AiDescriptionCompare } from "@/components/ai/AiDescriptionCompare";
import { submitProductEdit, type ImageChanges } from "@/services/productEditor";
import { EditableComboboxField } from "./EditableComboboxField";
import { EditableInput } from "./EditableInput";
@@ -207,6 +211,8 @@ export function ProductEditForm({
handleSubmit,
reset,
watch,
setValue,
getValues,
formState: { dirtyFields },
} = useForm<ProductFormValues>();
@@ -411,6 +417,62 @@ export function ProductEditForm({
[fieldOptions, lineOptions, sublineOptions]
);
// --- AI inline validation ---
const [validatingField, setValidatingField] = useState<"name" | "description" | null>(null);
const [descDialogOpen, setDescDialogOpen] = useState(false);
const {
validateName,
validateDescription,
nameResult,
descriptionResult,
clearNameResult,
clearDescriptionResult,
} = useInlineAiValidation();
const handleValidateName = useCallback(async () => {
const values = getValues();
if (!values.name?.trim()) return;
clearNameResult();
setValidatingField("name");
const companyLabel = fieldOptions.companies.find((c) => c.value === values.company)?.label;
const lineLabel = lineOptions.find((l) => l.value === values.line)?.label;
const sublineLabel = sublineOptions.find((s) => s.value === values.subline)?.label;
const result = await validateName({
name: values.name,
company_name: companyLabel,
company_id: values.company,
line_name: lineLabel,
subline_name: sublineLabel,
});
setValidatingField((prev) => (prev === "name" ? null : prev));
if (result && result.isValid && !result.suggestion) {
toast.success("Name looks good!");
}
}, [getValues, fieldOptions, lineOptions, sublineOptions, validateName, clearNameResult]);
const handleValidateDescription = useCallback(async () => {
const values = getValues();
if (!values.description?.trim()) return;
clearDescriptionResult();
setValidatingField("description");
const companyLabel = fieldOptions.companies.find((c) => c.value === values.company)?.label;
const categoryLabels = values.categories
?.map((id) => fieldOptions.categories.find((c) => c.value === id)?.label)
.filter(Boolean)
.join(", ");
const result = await validateDescription({
name: values.name,
description: values.description,
company_name: companyLabel,
company_id: values.company,
categories: categoryLabels || undefined,
});
setValidatingField((prev) => (prev === "description" ? null : prev));
if (result && result.isValid && !result.suggestion) {
toast.success("Description looks good!");
}
}, [getValues, fieldOptions, validateDescription, clearDescriptionResult]);
const hasImageChanges = computeImageChanges() !== null;
const changedCount = Object.keys(dirtyFields).length;
@@ -483,9 +545,42 @@ export function ProductEditForm({
);
}
if (fc.type === "textarea") {
const isDescription = fc.key === "description";
return (
<div key={fc.key} className="col-span-full flex flex-col gap-0.5 rounded-md border border-muted-foreground/50 bg-transparent px-3 py-1 text-sm hover:border-input hover:bg-muted/50 transition-colors">
<span className="text-muted-foreground text-xs shrink-0">{fc.label}</span>
<div className="flex items-center justify-between relative">
<span className="text-muted-foreground text-xs shrink-0">{fc.label}</span>
{isDescription && (
descriptionResult?.suggestion ? (
<button
type="button"
onClick={() => setDescDialogOpen(true)}
className="flex items-center gap-1 px-1.5 -mr-1.5 py-0.5 rounded bg-purple-100 hover:bg-purple-200 dark:bg-purple-900/50 dark:hover:bg-purple-800/50 text-purple-600 dark:text-purple-400 text-xs transition-colors shrink-0"
title="View AI suggestion"
>
<Sparkles className="h-3.5 w-3.5" />
<span>{descriptionResult.issues.length}</span>
</button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 absolute top-0.5 -right-1 text-purple-500 hover:text-purple-600 transition-colors p-0.5"
onClick={handleValidateDescription}
disabled={validatingField === "description"}
>
{validatingField === "description"
? <Loader2 className="h-4 w-4 animate-spin" />
: <Sparkles className="h-4 w-4" />
}
</button>
</TooltipTrigger>
<TooltipContent side="top">AI validate description</TooltipContent>
</Tooltip>
)
)}
</div>
<Textarea {...register(fc.key)} rows={(fc.key === "description" && MODE_LAYOUTS[layoutMode].descriptionRows) || fc.rows || 3} className="border-0 p-0 h-auto shadow-none focus-visible:ring-0 resize-y text-sm min-h-0" />
</div>
);
@@ -499,7 +594,7 @@ export function ProductEditForm({
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0 flex items-center gap-1">
<div className="flex-1 min-w-0 flex items-start gap-1">
<div className="flex-1 min-w-0">
<Controller
name="name"
@@ -512,9 +607,40 @@ export function ProductEditForm({
placeholder="Product name"
className="text-base font-semibold"
inputClassName="text-base font-semibold"
rightAction={
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 text-purple-500 hover:text-purple-600 transition-colors"
onClick={(e) => { e.stopPropagation(); handleValidateName(); }}
disabled={validatingField === "name"}
>
{validatingField === "name"
? <Loader2 className="h-4 w-4 animate-spin" />
: <Sparkles className="h-4 w-4" />
}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">AI validate name</TooltipContent>
</Tooltip>
}
/>
)}
/>
{nameResult?.suggestion && (
<AiSuggestionBadge
suggestion={nameResult.suggestion}
issues={nameResult.issues}
onAccept={() => {
setValue("name", nameResult.suggestion!, { shouldDirty: true });
clearNameResult();
}}
onDismiss={clearNameResult}
compact
className="mt-1"
/>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
@@ -611,6 +737,36 @@ export function ProductEditForm({
renderFieldGroup(group, gi + MODE_LAYOUTS[layoutMode].sidebarGroups)
)}
{/* AI Description Review Dialog */}
{descriptionResult?.suggestion && (
<Dialog open={descDialogOpen} onOpenChange={setDescDialogOpen}>
<DialogContent className="sm:max-w-4xl max-h-[85vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-purple-500" />
AI Description Review
</DialogTitle>
</DialogHeader>
<AiDescriptionCompare
currentValue={getValues("description")}
onCurrentChange={(v) => setValue("description", v, { shouldDirty: true })}
suggestion={descriptionResult.suggestion}
issues={descriptionResult.issues}
productName={getValues("name")}
onAccept={(text) => {
setValue("description", text, { shouldDirty: true });
clearDescriptionResult();
setDescDialogOpen(false);
}}
onDismiss={() => {
clearDescriptionResult();
setDescDialogOpen(false);
}}
/>
</DialogContent>
</Dialog>
)}
{/* Submit */}
<div className="flex items-center justify-end gap-3 pt-2">
<Button
@@ -16,8 +16,9 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { X, Loader2, Sparkles, AlertCircle, Check } from 'lucide-react';
import { X, Loader2, Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { AiDescriptionCompare } from '@/components/ai/AiDescriptionCompare';
import type { Field, SelectOption } from '../../../../types';
import type { ValidationError } from '../../store/types';
import { useValidationStore } from '../../store/validationStore';
@@ -67,8 +68,6 @@ const MultilineInputComponent = ({
const [popoverOpen, setPopoverOpen] = useState(false);
const [editValue, setEditValue] = useState('');
const [localDisplayValue, setLocalDisplayValue] = useState<string | null>(null);
const [aiSuggestionExpanded, setAiSuggestionExpanded] = useState(false);
const [editedSuggestion, setEditedSuggestion] = useState('');
const [popoverWidth, setPopoverWidth] = useState(400);
const [popoverHeight, setPopoverHeight] = useState<number | undefined>(undefined);
const resizeContainerRef = useRef<HTMLDivElement>(null);
@@ -77,12 +76,8 @@ const MultilineInputComponent = ({
// Tracks intentional closes (close button, accept/dismiss) vs click-outside closes
const intentionalCloseRef = useRef(false);
const mainTextareaRef = useRef<HTMLTextAreaElement>(null);
const suggestionTextareaRef = useRef<HTMLTextAreaElement>(null);
// Tracks the value when popover opened, to detect actual changes
const initialEditValueRef = useRef('');
// Ref for the right-side header+issues area to measure its height for left-side spacer
const aiHeaderRef = useRef<HTMLDivElement>(null);
const [aiHeaderHeight, setAiHeaderHeight] = useState(0);
// Get the product name for this row from the store
const productName = useValidationStore(
@@ -121,13 +116,6 @@ const MultilineInputComponent = ({
}
}, [value, localDisplayValue]);
// Initialize edited suggestion when AI suggestion changes
useEffect(() => {
if (aiSuggestion?.suggestion) {
setEditedSuggestion(aiSuggestion.suggestion);
}
}, [aiSuggestion?.suggestion]);
// Auto-resize a textarea to fit its content
const autoResizeTextarea = useCallback((textarea: HTMLTextAreaElement | null) => {
if (!textarea) return;
@@ -145,61 +133,25 @@ const MultilineInputComponent = ({
}
}, [popoverOpen, editValue, autoResizeTextarea]);
// Auto-resize suggestion textarea when expanded/visible or value changes
// Set initial popover height to fit the textarea content, capped by window height.
// Only applies on desktop (lg breakpoint) and non-AI mode (AI mode uses AiDescriptionCompare's own sizing).
useEffect(() => {
if (aiSuggestionExpanded || (popoverOpen && hasAiSuggestion)) {
requestAnimationFrame(() => {
autoResizeTextarea(suggestionTextareaRef.current);
});
}
}, [aiSuggestionExpanded, popoverOpen, hasAiSuggestion, editedSuggestion, autoResizeTextarea]);
// Set initial popover height to fit the tallest textarea content, capped by window height.
// Only applies on desktop (lg breakpoint) — mobile uses natural flow with individually resizable textareas.
useEffect(() => {
if (!popoverOpen) { setPopoverHeight(undefined); return; }
if (!popoverOpen || hasAiSuggestion) { setPopoverHeight(undefined); return; }
const isDesktop = window.matchMedia('(min-width: 1024px)').matches;
if (!isDesktop) { setPopoverHeight(undefined); return; }
const rafId = requestAnimationFrame(() => {
const main = mainTextareaRef.current;
const suggestion = suggestionTextareaRef.current;
const container = resizeContainerRef.current;
if (!container) return;
// Get textarea natural content heights
const mainScrollH = main ? main.scrollHeight : 0;
const suggestionScrollH = suggestion ? suggestion.scrollHeight : 0;
const tallestTextarea = Math.max(mainScrollH, suggestionScrollH);
// Measure chrome for both columns (everything except the textarea)
const leftChrome = main ? (main.closest('[data-col="left"]')?.scrollHeight ?? 0) - main.offsetHeight : 0;
const rightChrome = suggestion ? (suggestion.closest('[data-col="right"]')?.scrollHeight ?? 0) - suggestion.offsetHeight : 0;
const chrome = Math.max(leftChrome, rightChrome);
const naturalHeight = chrome + tallestTextarea;
const naturalHeight = leftChrome + mainScrollH;
const maxHeight = Math.floor(window.innerHeight * 0.7);
setPopoverHeight(Math.max(Math.min(naturalHeight, maxHeight), 200));
});
return () => cancelAnimationFrame(rafId);
}, [popoverOpen]);
// Measure the right-side header+issues area so the left spacer matches.
// Uses rAF because Radix portals mount asynchronously, so the ref is null on the first synchronous run.
useEffect(() => {
if (!popoverOpen || !hasAiSuggestion) { setAiHeaderHeight(0); return; }
let observer: ResizeObserver | null = null;
const rafId = requestAnimationFrame(() => {
const el = aiHeaderRef.current;
if (!el) return;
observer = new ResizeObserver(([entry]) => {
setAiHeaderHeight(entry.contentRect.height-7);
});
observer.observe(el);
});
return () => {
cancelAnimationFrame(rafId);
observer?.disconnect();
};
}, [popoverOpen, hasAiSuggestion]);
// Check if another cell's popover was recently closed (prevents immediate focus on click-outside)
@@ -261,7 +213,6 @@ const MultilineInputComponent = ({
// Immediately close popover
setPopoverOpen(false);
setAiSuggestionExpanded(false);
// Prevent reopening this same cell
preventReopenRef.current = true;
@@ -291,7 +242,6 @@ const MultilineInputComponent = ({
}
setPopoverOpen(false);
setAiSuggestionExpanded(false);
// Signal to other cells that a popover just closed via click-outside
setCellPopoverClosed();
@@ -322,23 +272,19 @@ const MultilineInputComponent = ({
autoResizeTextarea(e.target);
}, [autoResizeTextarea]);
// Handle accepting the AI suggestion (possibly edited)
const handleAcceptSuggestion = useCallback(() => {
// Use the edited suggestion
setEditValue(editedSuggestion);
setLocalDisplayValue(editedSuggestion);
// onBlur handles both cell update and validation
onBlur(editedSuggestion);
onDismissAiSuggestion?.(); // Clear the suggestion after accepting
setAiSuggestionExpanded(false);
// Handle accepting the AI suggestion (possibly edited) via AiDescriptionCompare
const handleAcceptSuggestion = useCallback((text: string) => {
setEditValue(text);
setLocalDisplayValue(text);
onBlur(text);
onDismissAiSuggestion?.();
intentionalCloseRef.current = true;
setPopoverOpen(false);
}, [editedSuggestion, onBlur, onDismissAiSuggestion]);
}, [onBlur, onDismissAiSuggestion]);
// Handle dismissing the AI suggestion
// Handle dismissing the AI suggestion via AiDescriptionCompare
const handleDismissSuggestion = useCallback(() => {
onDismissAiSuggestion?.();
setAiSuggestionExpanded(false);
intentionalCloseRef.current = true;
setPopoverOpen(false);
}, [onDismissAiSuggestion]);
@@ -380,7 +326,6 @@ const MultilineInputComponent = ({
return;
}
updatePopoverWidth();
setAiSuggestionExpanded(true);
setPopoverOpen(true);
// Initialize edit value and track it for change detection
const initValue = localDisplayValue || String(value ?? '');
@@ -449,116 +394,27 @@ const MultilineInputComponent = ({
<X className="h-3 w-3" />
</Button>
{/* Main textarea */}
<div data-col="left" className={cn("flex flex-col min-h-0 w-full", hasAiSuggestion && "lg:w-1/2")}>
<div className={cn(hasAiSuggestion ? 'px-3 py-2 bg-accent' : '', 'flex flex-col flex-1 min-h-0')}>
{/* Product name - shown inline on mobile, in measured spacer on desktop */}
{hasAiSuggestion && productName && (
<div className="flex-shrink-0 flex flex-col lg:hidden px-1 mb-2">
<div className="text-sm font-medium text-foreground mb-1">Editing description for:</div>
<div className="text-md font-semibold text-foreground line-clamp-1">{productName}</div>
</div>
)}
{hasAiSuggestion && aiHeaderHeight > 0 && (
<div className="flex-shrink-0 hidden lg:flex items-start" style={{ height: aiHeaderHeight }}>
{productName && (
<div className="flex flex-col">
<div className="text-sm font-medium text-foreground px-1 mb-1">Editing description for:</div>
<div className="text-md font-semibold text-foreground line-clamp-1 px-1">{productName}</div>
</div>
)}
</div>
)}
{hasAiSuggestion && <div className="text-sm mb-1 font-medium flex items-center gap-2 flex-shrink-0">
Current Description:
</div>}
{/* Dynamic spacer matching the right-side header+issues height */}
<Textarea
ref={mainTextareaRef}
value={editValue}
onChange={handleChange}
onWheel={handleTextareaWheel}
className={cn("overflow-y-auto overscroll-contain text-sm lg:flex-1 resize-y lg:resize-none bg-white min-h-[120px] lg:min-h-0")}
placeholder={`Enter ${field.label || 'text'}...`}
autoFocus
/>
{hasAiSuggestion && <div className="h-[43px] flex-shrink-0 hidden lg:block" />}
</div></div>
{/* AI Suggestion section */}
{hasAiSuggestion && (
<div data-col="right" className="bg-purple-50/80 dark:bg-purple-950/30 flex flex-col w-full lg:w-1/2">
{/* Measured header + issues area (mirrored as spacer on the left) */}
<div ref={aiHeaderRef} className="flex-shrink-0">
{/* Header */}
<div className="w-full flex items-center justify-between px-3 py-2">
<div className="flex items-center gap-2">
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
<span className="text-xs font-medium text-purple-600 dark:text-purple-400">
AI Suggestion
</span>
<span className="text-xs text-purple-500 dark:text-purple-400">
({aiIssues.length} {aiIssues.length === 1 ? 'issue' : 'issues'})
</span>
</div>
</div>
{/* Issues list */}
{aiIssues.length > 0 && (
<div className="flex flex-col gap-1 px-3 pb-3">
{aiIssues.map((issue, index) => (
<div
key={index}
className="flex items-start gap-1.5 text-xs text-purple-600 dark:text-purple-400"
>
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" />
<span>{issue}</span>
</div>
))}
</div>
)}
</div>
{/* Content */}
<div className="px-3 pb-3 flex flex-col flex-1 gap-3">
{/* Editable suggestion */}
<div className="flex flex-col flex-1">
<div className="text-sm text-purple-500 dark:text-purple-400 mb-1 font-medium flex-shrink-0">
Suggested (editable):
</div>
<Textarea
ref={suggestionTextareaRef}
value={editedSuggestion}
onChange={(e) => {
setEditedSuggestion(e.target.value);
autoResizeTextarea(e.target);
}}
onWheel={handleTextareaWheel}
className="overflow-y-auto overscroll-contain text-sm bg-white dark:bg-black/20 border-purple-200 dark:border-purple-700 focus-visible:ring-purple-400 resize-y lg:resize-none lg:flex-1 min-h-[120px] lg:min-h-0"
/>
</div>
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0">
<Button
size="sm"
variant="outline"
className="h-7 px-3 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400"
onClick={handleAcceptSuggestion}
>
<Check className="h-3 w-3 mr-1" />
Replace With Suggestion
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-3 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400"
onClick={handleDismissSuggestion}
>
Ignore
</Button>
</div>
</div>
{hasAiSuggestion ? (
<AiDescriptionCompare
currentValue={editValue}
onCurrentChange={setEditValue}
suggestion={aiSuggestion.suggestion!}
issues={aiIssues}
productName={productName}
onAccept={handleAcceptSuggestion}
onDismiss={handleDismissSuggestion}
/>
) : (
<div data-col="left" className="flex flex-col min-h-0 w-full">
<Textarea
ref={mainTextareaRef}
value={editValue}
onChange={handleChange}
onWheel={handleTextareaWheel}
className="overflow-y-auto overscroll-contain text-sm lg:flex-1 resize-y lg:resize-none bg-white min-h-[120px] lg:min-h-0"
placeholder={`Enter ${field.label || 'text'}...`}
autoFocus
/>
</div>
)}
</div>
File diff suppressed because one or more lines are too long