Add option to not hide submitted products for product import, rework description popover, fix steps

This commit is contained in:
2026-01-29 16:03:07 -05:00
parent ee2f314775
commit f9e8c9265e
18 changed files with 842 additions and 693 deletions

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
* Avoid using glob tool for search as it may not work properly on this codebase. Search using bash instead.

View File

@@ -70,130 +70,8 @@ router.get('/:id', async (req, res) => {
} }
}); });
// Create new named session
router.post('/', async (req, res) => {
try {
const {
user_id,
name,
current_step,
data,
product_images,
global_selections,
validation_state
} = req.body;
// Validate required fields
if (!user_id) {
return res.status(400).json({ error: 'user_id is required' });
}
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return res.status(400).json({ error: 'name is required for creating a named session' });
}
if (!current_step) {
return res.status(400).json({ error: 'current_step is required' });
}
if (!data || !Array.isArray(data)) {
return res.status(400).json({ error: 'data must be an array' });
}
const pool = req.app.locals.pool;
if (!pool) {
throw new Error('Database pool not initialized');
}
const result = await pool.query(`
INSERT INTO import_sessions (
user_id,
name,
current_step,
data,
product_images,
global_selections,
validation_state
) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
`, [
user_id,
name.trim(),
current_step,
JSON.stringify(data),
product_images ? JSON.stringify(product_images) : null,
global_selections ? JSON.stringify(global_selections) : null,
validation_state ? JSON.stringify(validation_state) : null
]);
res.status(201).json(result.rows[0]);
} catch (error) {
console.error('Error creating import session:', error);
res.status(500).json({
error: 'Failed to create import session',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// Update named session by ID
router.put('/:id', async (req, res) => {
try {
const { id } = req.params;
const {
current_step,
data,
product_images,
global_selections,
validation_state
} = req.body;
if (!current_step) {
return res.status(400).json({ error: 'current_step is required' });
}
if (!data || !Array.isArray(data)) {
return res.status(400).json({ error: 'data must be an array' });
}
const pool = req.app.locals.pool;
if (!pool) {
throw new Error('Database pool not initialized');
}
// Check if session exists
const checkResult = await pool.query('SELECT * FROM import_sessions WHERE id = $1', [id]);
if (checkResult.rows.length === 0) {
return res.status(404).json({ error: 'Import session not found' });
}
const result = await pool.query(`
UPDATE import_sessions
SET
current_step = $1,
data = $2,
product_images = $3,
global_selections = $4,
validation_state = $5,
updated_at = CURRENT_TIMESTAMP
WHERE id = $6
RETURNING *
`, [
current_step,
JSON.stringify(data),
product_images ? JSON.stringify(product_images) : null,
global_selections ? JSON.stringify(global_selections) : null,
validation_state ? JSON.stringify(validation_state) : null,
id
]);
res.json(result.rows[0]);
} catch (error) {
console.error('Error updating import session:', error);
res.status(500).json({
error: 'Failed to update import session',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// Autosave - upsert unnamed session for user // Autosave - upsert unnamed session for user
// IMPORTANT: This must be defined before /:id routes to avoid Express matching "autosave" as an :id
router.put('/autosave', async (req, res) => { router.put('/autosave', async (req, res) => {
try { try {
const { const {
@@ -240,7 +118,7 @@ router.put('/autosave', async (req, res) => {
global_selections = EXCLUDED.global_selections, global_selections = EXCLUDED.global_selections,
validation_state = EXCLUDED.validation_state, validation_state = EXCLUDED.validation_state,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
RETURNING * RETURNING id, user_id, name, current_step, created_at, updated_at
`, [ `, [
user_id, user_id,
current_step, current_step,
@@ -260,32 +138,8 @@ router.put('/autosave', async (req, res) => {
} }
}); });
// Delete session by ID
router.delete('/:id', async (req, res) => {
try {
const { id } = req.params;
const pool = req.app.locals.pool;
if (!pool) {
throw new Error('Database pool not initialized');
}
const result = await pool.query('DELETE FROM import_sessions WHERE id = $1 RETURNING *', [id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Import session not found' });
}
res.json({ message: 'Import session deleted successfully' });
} catch (error) {
console.error('Error deleting import session:', error);
res.status(500).json({
error: 'Failed to delete import session',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// Delete unnamed session for user (clear autosave) // Delete unnamed session for user (clear autosave)
// IMPORTANT: This must be defined before /:id routes
router.delete('/autosave/:user_id', async (req, res) => { router.delete('/autosave/:user_id', async (req, res) => {
try { try {
const { user_id } = req.params; const { user_id } = req.params;
@@ -295,7 +149,7 @@ router.delete('/autosave/:user_id', async (req, res) => {
} }
const result = await pool.query( const result = await pool.query(
'DELETE FROM import_sessions WHERE user_id = $1 AND name IS NULL RETURNING *', 'DELETE FROM import_sessions WHERE user_id = $1 AND name IS NULL RETURNING id, user_id, name, current_step, created_at, updated_at',
[user_id] [user_id]
); );
@@ -313,6 +167,164 @@ router.delete('/autosave/:user_id', async (req, res) => {
} }
}); });
// Create new named session
router.post('/', async (req, res) => {
try {
const {
user_id,
name,
current_step,
data,
product_images,
global_selections,
validation_state
} = req.body;
// Validate required fields
if (!user_id) {
return res.status(400).json({ error: 'user_id is required' });
}
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return res.status(400).json({ error: 'name is required for creating a named session' });
}
if (!current_step) {
return res.status(400).json({ error: 'current_step is required' });
}
if (!data || !Array.isArray(data)) {
return res.status(400).json({ error: 'data must be an array' });
}
const pool = req.app.locals.pool;
if (!pool) {
throw new Error('Database pool not initialized');
}
const result = await pool.query(`
INSERT INTO import_sessions (
user_id,
name,
current_step,
data,
product_images,
global_selections,
validation_state
) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, user_id, name, current_step, created_at, updated_at
`, [
user_id,
name.trim(),
current_step,
JSON.stringify(data),
product_images ? JSON.stringify(product_images) : null,
global_selections ? JSON.stringify(global_selections) : null,
validation_state ? JSON.stringify(validation_state) : null
]);
res.status(201).json(result.rows[0]);
} catch (error) {
console.error('Error creating import session:', error);
res.status(500).json({
error: 'Failed to create import session',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// Update named session by ID
router.put('/:id', async (req, res) => {
try {
const { id } = req.params;
const {
name,
current_step,
data,
product_images,
global_selections,
validation_state
} = req.body;
if (!current_step) {
return res.status(400).json({ error: 'current_step is required' });
}
if (!data || !Array.isArray(data)) {
return res.status(400).json({ error: 'data must be an array' });
}
const pool = req.app.locals.pool;
if (!pool) {
throw new Error('Database pool not initialized');
}
// Build update query - optionally include name if provided
const hasName = name !== undefined;
const result = await pool.query(`
UPDATE import_sessions
SET
${hasName ? 'name = $1,' : ''}
current_step = $${hasName ? 2 : 1},
data = $${hasName ? 3 : 2},
product_images = $${hasName ? 4 : 3},
global_selections = $${hasName ? 5 : 4},
validation_state = $${hasName ? 6 : 5},
updated_at = CURRENT_TIMESTAMP
WHERE id = $${hasName ? 7 : 6}
RETURNING id, user_id, name, current_step, created_at, updated_at
`, hasName ? [
typeof name === 'string' ? name.trim() : name,
current_step,
JSON.stringify(data),
product_images ? JSON.stringify(product_images) : null,
global_selections ? JSON.stringify(global_selections) : null,
validation_state ? JSON.stringify(validation_state) : null,
id
] : [
current_step,
JSON.stringify(data),
product_images ? JSON.stringify(product_images) : null,
global_selections ? JSON.stringify(global_selections) : null,
validation_state ? JSON.stringify(validation_state) : null,
id
]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Import session not found' });
}
res.json(result.rows[0]);
} catch (error) {
console.error('Error updating import session:', error);
res.status(500).json({
error: 'Failed to update import session',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// Delete session by ID
router.delete('/:id', async (req, res) => {
try {
const { id } = req.params;
const pool = req.app.locals.pool;
if (!pool) {
throw new Error('Database pool not initialized');
}
const result = await pool.query('DELETE FROM import_sessions WHERE id = $1 RETURNING id, user_id, name, current_step, created_at, updated_at', [id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Import session not found' });
}
res.json({ message: 'Import session deleted successfully' });
} catch (error) {
console.error('Error deleting import session:', error);
res.status(500).json({
error: 'Failed to delete import session',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// Error handling middleware // Error handling middleware
router.use((err, req, res, next) => { router.use((err, req, res, next) => {
console.error('Import sessions route error:', err); console.error('Import sessions route error:', err);

View File

@@ -1,15 +1,15 @@
/** /**
* CloseConfirmationDialog Component * CloseConfirmationDialog Component
* *
* Shown when user attempts to close the import modal. * Single dialog shown when user attempts to close the import modal.
* Offers options to save the session before closing. * Named sessions: Save & Exit or Cancel.
* Unnamed sessions: Keep (autosave), Save with Name (inline input), Discard, or Cancel.
*/ */
import { useState } from 'react'; import { useState, useContext, useEffect } from 'react';
import { Loader2, Save } from 'lucide-react'; import { Loader2, Save, Trash2, ArrowLeft } from 'lucide-react';
import { import {
AlertDialog, AlertDialog,
AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
AlertDialogContent, AlertDialogContent,
AlertDialogDescription, AlertDialogDescription,
@@ -21,9 +21,9 @@ import {
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useImportSession } from '@/contexts/ImportSessionContext'; import { useImportSession } from '@/contexts/ImportSessionContext';
import { toast } from 'sonner'; import { AuthContext } from '@/contexts/AuthContext';
import { deleteAutosaveSession, deleteSession as deleteSessionApi } from '@/services/importSessionApi';
interface CloseConfirmationDialogProps { interface CloseConfirmationDialogProps {
open: boolean; open: boolean;
@@ -38,83 +38,97 @@ export function CloseConfirmationDialog({
}: CloseConfirmationDialogProps) { }: CloseConfirmationDialogProps) {
const { const {
sessionName, sessionName,
sessionId,
isDirty, isDirty,
forceSave, forceSave,
saveAsNamed, saveAsNamed,
clearSession,
getSuggestedSessionName, getSuggestedSessionName,
isSaving,
} = useImportSession(); } = useImportSession();
const { user } = useContext(AuthContext);
const [showNameInput, setShowNameInput] = useState(false);
const [name, setName] = useState(''); const [name, setName] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showNameInput, setShowNameInput] = useState(false);
// Reset state when dialog opens // Reset state when dialog opens
const handleOpenChange = (newOpen: boolean) => { useEffect(() => {
if (newOpen) { if (open) {
// Pre-populate with suggested name when opening
const suggested = getSuggestedSessionName(); const suggested = getSuggestedSessionName();
setName(suggested || ''); setName(suggested || '');
setShowNameInput(false);
setError(null); setError(null);
setShowNameInput(false);
}
}, [open, getSuggestedSessionName]);
// Only use local saving state for disabling buttons — don't block the user
// from closing just because a background autosave is in progress.
const isProcessing = saving;
const handleOpenChange = (newOpen: boolean) => {
if (newOpen) {
const suggested = getSuggestedSessionName();
setName(suggested || '');
setError(null);
setShowNameInput(false);
} }
onOpenChange(newOpen); onOpenChange(newOpen);
}; };
// Handle "Save & Exit" for named sessions // Save & Exit (for named sessions, or "Keep" for unnamed)
const handleSaveAndExit = async () => { const handleSaveAndExit = async () => {
setSaving(true); setSaving(true);
try { try {
await forceSave(); await forceSave();
toast.success('Session saved');
onConfirmClose(); onConfirmClose();
} catch (err) { } catch (err) {
console.error('Failed to save:', err); console.error('Failed to save:', err);
toast.error('Failed to save session'); onConfirmClose();
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
// Handle "Save As" for unnamed sessions // Save with the entered name, then exit
const handleSaveAs = async () => { const handleSaveWithName = async () => {
const trimmedName = name.trim(); const trimmedName = name.trim();
if (!trimmedName) { if (!trimmedName) {
setError('Please enter a name for the session'); setError('Enter a name');
return; return;
} }
setSaving(true); setSaving(true);
try { try {
await saveAsNamed(trimmedName); await saveAsNamed(trimmedName);
toast.success('Session saved');
onConfirmClose(); onConfirmClose();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save session'); setError(err instanceof Error ? err.message : 'Failed to save');
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
// Handle "Exit Without Saving" - still auto-saves as unnamed // Discard session and exit
const handleExitWithoutNaming = async () => { const handleDiscardAndExit = async () => {
setSaving(true); setSaving(true);
try { try {
await forceSave(); if (sessionId) {
await deleteSessionApi(sessionId);
} else if (user?.id) {
await deleteAutosaveSession(user.id);
}
clearSession();
onConfirmClose(); onConfirmClose();
} catch (err) { } catch (err) {
console.error('Failed to autosave:', err); console.error('Failed to discard session:', err);
// Still close even if autosave fails clearSession();
onConfirmClose(); onConfirmClose();
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
const isProcessing = saving || isSaving; // --- Named session: simple save & exit ---
// Session is already named
if (sessionName) { if (sessionName) {
return ( return (
<AlertDialog open={open} onOpenChange={handleOpenChange}> <AlertDialog open={open} onOpenChange={handleOpenChange}>
@@ -131,81 +145,12 @@ export function CloseConfirmationDialog({
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={isProcessing}>Cancel</AlertDialogCancel> <AlertDialogCancel disabled={isProcessing}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleSaveAndExit} disabled={isProcessing}> <Button onClick={handleSaveAndExit} disabled={isProcessing}>
{isProcessing ? ( {isProcessing ? (
<> <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving...</>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving...
</>
) : ( ) : (
'Save & Exit' 'Save & Exit'
)} )}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialog>
);
}
// Session is unnamed - show option to name or exit
if (showNameInput) {
return (
<AlertDialog open={open} onOpenChange={handleOpenChange}>
<AlertDialogPortal>
<AlertDialogOverlay className="z-[1400]" />
<AlertDialogContent className="z-[1500]">
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<Save className="h-5 w-5" />
Save Session
</AlertDialogTitle>
<AlertDialogDescription>
Enter a name for your session so you can find it later.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="session-name-close">Session Name</Label>
<Input
id="session-name-close"
placeholder="e.g., Spring 2025 Products"
value={name}
onChange={(e) => {
setName(e.target.value);
setError(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !isProcessing && name.trim()) {
handleSaveAs();
}
}}
disabled={isProcessing}
autoFocus
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
</div>
<AlertDialogFooter>
<Button
variant="ghost"
onClick={() => setShowNameInput(false)}
disabled={isProcessing}
>
Back
</Button>
<Button onClick={handleSaveAs} disabled={isProcessing || !name.trim()}>
{isProcessing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save & Exit
</>
)}
</Button> </Button>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -214,42 +159,89 @@ export function CloseConfirmationDialog({
); );
} }
// Initial state - unnamed session, ask what to do // --- Unnamed session: all options in one view ---
return ( return (
<AlertDialog open={open} onOpenChange={handleOpenChange}> <AlertDialog open={open} onOpenChange={handleOpenChange}>
<AlertDialogPortal> <AlertDialogPortal>
<AlertDialogOverlay className="z-[1400]" /> <AlertDialogOverlay className="z-[1400]" />
<AlertDialogContent className="z-[1500]"> <AlertDialogContent className={`z-[1500] ${showNameInput ? 'max-w-[400px]' : 'max-w-[700px]'}`}>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Exit Import</AlertDialogTitle> <AlertDialogTitle>{showNameInput ? 'Save As...' : 'Exit Product Import'}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Your progress will be automatically saved. You can restore it later from the upload step {showNameInput ? 'Enter a name for your session to save it.' : 'Your progress has been auto-saved. You can keep it, save it with a name, or discard it.'}
as "Previous Session", or save it with a name for easier reference.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter className="flex-col sm:flex-row gap-2">
{/* Inline name input - shown only when user clicks "Save with Name" */}
{showNameInput && (
<>
<div className="flex items-center justify-end gap-2 px-0 py-1">
<Input
placeholder="Session name"
value={name}
onChange={(e) => { setName(e.target.value); setError(null); }}
onKeyDown={(e) => {
if (e.key === 'Enter' && !isProcessing && name.trim()) {
handleSaveWithName();
}
}}
disabled={isProcessing}
className="w-[200px]"
autoFocus
/>
<Button
onClick={handleSaveWithName}
disabled={isProcessing || !name.trim()}
>
{isProcessing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<><Save className="h-4 w-4 mr-1" />Save & Exit</>
)}
</Button>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</>
)}
{!showNameInput && (
<AlertDialogFooter className="flex-col sm:flex-row gap-1">
<>
<AlertDialogCancel disabled={isProcessing} className="mt-0"> <AlertDialogCancel disabled={isProcessing} className="mt-0">
Continue Editing <ArrowLeft className="h-4 w-4" /> Continue Editing
</AlertDialogCancel> </AlertDialogCancel>
<Button <Button
variant="outline" variant="outline"
onClick={() => setShowNameInput(true)} onClick={handleDiscardAndExit}
disabled={isProcessing} disabled={isProcessing}
className="text-destructive hover:text-destructive/90"
> >
<Save className="h-4 w-4 mr-2" />
Save with Name
</Button>
<AlertDialogAction onClick={handleExitWithoutNaming} disabled={isProcessing}>
{isProcessing ? ( {isProcessing ? (
<> <Loader2 className="h-4 w-4 animate-spin" />
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving...
</>
) : ( ) : (
'Exit' <Trash2 className="h-4 w-4" />
)} )}
</AlertDialogAction> Discard
</AlertDialogFooter> </Button>
<Button
onClick={() => setShowNameInput(true)}
disabled={isProcessing || showNameInput}
variant="outline"
>
<Save className="h-4 w-4" />
Save As...
</Button>
<Button onClick={handleSaveAndExit} disabled={isProcessing}>
{isProcessing ? (
<><Loader2 className="h-4 w-4 animate-spin" />Saving...</>
) : (
'Keep & Exit'
)}
</Button>
</>
</AlertDialogFooter> )}
</AlertDialogContent> </AlertDialogContent>
</AlertDialogPortal> </AlertDialogPortal>
</AlertDialog> </AlertDialog>

View File

@@ -1,17 +1,11 @@
import type React from "react" import type React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { import {
Dialog, Dialog,
DialogContent,
DialogOverlay,
DialogPortal,
DialogClose,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { import { X } from "lucide-react"
AlertDialog,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { useRsi } from "../hooks/useRsi" import { useRsi } from "../hooks/useRsi"
import { useState, useCallback } from "react" import { useState, useCallback, useRef } from "react"
import { CloseConfirmationDialog } from "./CloseConfirmationDialog" import { CloseConfirmationDialog } from "./CloseConfirmationDialog"
type Props = { type Props = {
@@ -23,55 +17,82 @@ type Props = {
export const ModalWrapper = ({ children, isOpen, onClose }: Props) => { export const ModalWrapper = ({ children, isOpen, onClose }: Props) => {
const { rtl } = useRsi() const { rtl } = useRsi()
const [showCloseAlert, setShowCloseAlert] = useState(false) const [showCloseAlert, setShowCloseAlert] = useState(false)
// Guard: when we're programmatically closing, don't re-show the alert
const closingRef = useRef(false)
// Create a handler that resets scroll positions before closing // Called after user confirms close in the dialog
const handleClose = useCallback(() => { const handleConfirmClose = useCallback(() => {
// Reset all scroll positions in the dialog // Dismiss the confirmation dialog
setShowCloseAlert(false)
// Mark that we're intentionally closing so onOpenChange doesn't re-trigger the alert
closingRef.current = true
// Reset scroll positions
const scrollContainers = document.querySelectorAll('.overflow-auto, .overflow-scroll'); const scrollContainers = document.querySelectorAll('.overflow-auto, .overflow-scroll');
scrollContainers.forEach(container => { scrollContainers.forEach(container => {
if (container instanceof HTMLElement) { if (container instanceof HTMLElement) {
// Reset scroll position to top-left
container.scrollTop = 0; container.scrollTop = 0;
container.scrollLeft = 0; container.scrollLeft = 0;
} }
}); });
// Call the original onClose handler // Close the main dialog
onClose(); onClose();
// Reset the guard after a tick (after Radix fires onOpenChange)
requestAnimationFrame(() => {
closingRef.current = false
})
}, [onClose]); }, [onClose]);
// Radix fires this when something tries to change the dialog's open state
// (e.g. focus loss, internal close). We intercept to show our confirmation instead.
const handleDialogOpenChange = useCallback((open: boolean) => {
if (!open && !closingRef.current) {
// Something is trying to close the dialog — show confirmation
setShowCloseAlert(true)
}
}, [])
return ( return (
<> <>
<Dialog open={isOpen} onOpenChange={() => setShowCloseAlert(true)} modal> {/*
<DialogPortal> NOTE: We use DialogPrimitive.Portal/Overlay/Content directly instead of the
<DialogOverlay className="bg-background/80 backdrop-blur-sm" /> shadcn DialogContent component. The shadcn DialogContent internally renders its
<DialogContent own Portal + Overlay, which would create duplicate portals/overlays and break
pointer-events for nested Popovers (e.g., select dropdowns).
*/}
<Dialog open={isOpen} onOpenChange={handleDialogOpenChange} modal>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-primary/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
onEscapeKeyDown={(e) => { onEscapeKeyDown={(e) => {
e.preventDefault() e.preventDefault()
setShowCloseAlert(true) setShowCloseAlert(true)
}} }}
onPointerDownOutside={(e) => e.preventDefault()} onPointerDownOutside={(e) => e.preventDefault()}
className="fixed left-[50%] top-[50%] translate-x-[-50%] translate-y-[-50%] w-[calc(100%-2rem)] h-[calc(100%-2rem)] max-w-[100vw] max-h-[100vh] flex flex-col overflow-hidden rounded-lg border bg-background p-0 shadow-lg sm:w-[calc(100%-3rem)] sm:h-[calc(100%-3rem)] md:w-[calc(100%-4rem)] md:h-[calc(100%-4rem)]" className="fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%] w-[calc(100%-2rem)] h-[calc(100%-2rem)] max-w-[100vw] max-h-[100vh] flex flex-col overflow-hidden rounded-lg border bg-background p-0 shadow-lg sm:w-[calc(100%-3rem)] sm:h-[calc(100%-3rem)] md:w-[calc(100%-4rem)] md:h-[calc(100%-4rem)]"
> >
<AlertDialog> <button
<AlertDialogTrigger asChild> type="button"
<DialogClose className="absolute right-4 top-4" onClick={(e) => { className="absolute right-4 top-4 z-10 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
e.preventDefault() onClick={() => setShowCloseAlert(true)}
setShowCloseAlert(true) >
}} /> <X className="h-4 w-4" />
</AlertDialogTrigger> <span className="sr-only">Close</span>
</AlertDialog> </button>
<div dir={rtl ? "rtl" : "ltr"} className="flex-1 overflow-auto"> <div dir={rtl ? "rtl" : "ltr"} className="flex-1 overflow-auto">
{children} {children}
</div> </div>
</DialogContent> </DialogPrimitive.Content>
</DialogPortal> </DialogPrimitive.Portal>
</Dialog> </Dialog>
<CloseConfirmationDialog <CloseConfirmationDialog
open={showCloseAlert} open={showCloseAlert}
onOpenChange={setShowCloseAlert} onOpenChange={setShowCloseAlert}
onConfirmClose={handleClose} onConfirmClose={handleConfirmClose}
/> />
</> </>
) )

View File

@@ -24,6 +24,16 @@ import { useBulkImageUpload } from "./hooks/useBulkImageUpload";
import { useUrlImageUpload } from "./hooks/useUrlImageUpload"; import { useUrlImageUpload } from "./hooks/useUrlImageUpload";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { AuthContext } from "@/contexts/AuthContext"; import { AuthContext } from "@/contexts/AuthContext";
import { useImportAutosave } from "@/hooks/useImportAutosave"; import { useImportAutosave } from "@/hooks/useImportAutosave";
import { useImportSession } from "@/contexts/ImportSessionContext"; import { useImportSession } from "@/contexts/ImportSessionContext";
@@ -52,9 +62,11 @@ export const ImageUploadStep = ({
const [targetEnvironment, setTargetEnvironment] = useState<SubmitOptions["targetEnvironment"]>("prod"); const [targetEnvironment, setTargetEnvironment] = useState<SubmitOptions["targetEnvironment"]>("prod");
const [useTestDataSource, setUseTestDataSource] = useState<boolean>(false); const [useTestDataSource, setUseTestDataSource] = useState<boolean>(false);
const [skipApiSubmission, setSkipApiSubmission] = useState<boolean>(false); const [skipApiSubmission, setSkipApiSubmission] = useState<boolean>(false);
const [showNewProduct, setShowNewProduct] = useState<boolean>(false);
const [showConfirmDialog, setShowConfirmDialog] = useState<boolean>(false);
// Import session context for cleanup on submit // Import session context for cleanup on submit and global selections
const { deleteSession: deleteImportSession } = useImportSession(); const { deleteSession: deleteImportSession, getGlobalSelections } = useImportSession();
// Use our hook for product images initialization // Use our hook for product images initialization
const { productImages, setProductImages, getFullImageUrl } = useProductImagesInit(data); const { productImages, setProductImages, getFullImageUrl } = useProductImagesInit(data);
@@ -106,14 +118,21 @@ export const ImageUploadStep = ({
current_step: 'imageUpload', current_step: 'imageUpload',
data: data as any[], // Product data data: data as any[], // Product data
product_images: productImages, product_images: productImages,
global_selections: getGlobalSelections(),
}; };
}, [data, productImages]), }, [data, productImages, getGlobalSelections]),
}); });
// Mark dirty when images change // Mark dirty when images change (use ref to avoid depending on markDirty identity)
const markDirtyRef = useRef(markDirty);
markDirtyRef.current = markDirty;
const prevProductImagesRef = useRef(productImages);
useEffect(() => { useEffect(() => {
markDirty(); if (prevProductImagesRef.current !== productImages) {
}, [productImages, markDirty]); prevProductImagesRef.current = productImages;
markDirtyRef.current();
}
}, [productImages]);
// Set up sensors for drag and drop with enhanced configuration // Set up sensors for drag and drop with enhanced configuration
const sensors = useSensors( const sensors = useSensors(
@@ -196,7 +215,9 @@ export const ImageUploadStep = ({
return { return {
...product, ...product,
// Store as comma-separated string to ensure compatibility // Store as comma-separated string to ensure compatibility
product_images: images.join(',') product_images: images.join(','),
// Add show_new_product flag if enabled
...(showNewProduct && { show_new_product: true })
}; };
}); });
@@ -204,6 +225,7 @@ export const ImageUploadStep = ({
targetEnvironment, targetEnvironment,
useTestDataSource, useTestDataSource,
skipApiSubmission, skipApiSubmission,
showNewProduct,
}; };
await onSubmit(updatedData, file, submitOptions); await onSubmit(updatedData, file, submitOptions);
@@ -221,10 +243,19 @@ export const ImageUploadStep = ({
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}, [data, file, onSubmit, productImages, targetEnvironment, useTestDataSource, skipApiSubmission, deleteImportSession]); }, [data, file, onSubmit, productImages, targetEnvironment, useTestDataSource, skipApiSubmission, showNewProduct, deleteImportSession]);
return ( return (
<div className="flex flex-col h-[calc(100vh-9.5rem)] overflow-hidden"> <div className="flex flex-col h-[calc(100vh-9.5rem)] overflow-hidden relative">
{/* Full-screen loading overlay during submit */}
{isSubmitting && (
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm z-50 flex flex-col items-center justify-center gap-4">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
<div className="text-lg font-medium">Submitting products...</div>
<div className="text-sm text-muted-foreground">Please wait while your import is being processed</div>
</div>
)}
{/* Header - fixed at top */} {/* Header - fixed at top */}
<div className="px-8 py-6 bg-background shrink-0"> <div className="px-8 py-6 bg-background shrink-0">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -335,6 +366,24 @@ export const ImageUploadStep = ({
</Button> </Button>
)} )}
<div className="flex flex-1 flex-wrap items-center justify-end gap-6"> <div className="flex flex-1 flex-wrap items-center justify-end gap-6">
<div className="flex items-center gap-1">
<Switch
id="product-import-show-new-product"
checked={showNewProduct}
onCheckedChange={(checked) => {
if (checked) {
setShowConfirmDialog(true);
} else {
setShowNewProduct(false);
}
}}
/>
<div>
<Label htmlFor="product-import-show-new-product" className="text-sm font-medium">
Show these products immediately
</Label>
</div>
</div>
{hasDebugPermission && ( {hasDebugPermission && (
<div className="flex gap-4 text-sm"> <div className="flex gap-4 text-sm">
{!skipApiSubmission && ( {!skipApiSubmission && (
@@ -389,6 +438,30 @@ export const ImageUploadStep = ({
</Button> </Button>
</div> </div>
</div> </div>
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Show products immediately?</AlertDialogTitle>
<AlertDialogDescription>
This will create all of these products with the "hide" option NOT set, so they will be immediately visible on the site when received or put on pre-order. Do NOT use this option for products we're not allowed to show yet.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setShowConfirmDialog(false)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setShowNewProduct(true);
setShowConfirmDialog(false);
}}
>
Yes, show immediately
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
}; };

View File

@@ -1,32 +1,26 @@
import { StepState, StepType, UploadFlow } from "./UploadFlow" import { StepState, StepType, UploadFlow } from "./UploadFlow"
import { useRsi } from "../hooks/useRsi" import { useRsi } from "../hooks/useRsi"
import { useRef, useState, useEffect } from "react" import { useRef, useState, useEffect, useContext } from "react"
import { steps, stepTypeToStepIndex, stepIndexToStepType } from "../utils/steps" import { steps, stepTypeToStepIndex, stepIndexToStepType } from "../utils/steps"
import { CgCheck } from "react-icons/cg" import { CgCheck } from "react-icons/cg"
import { ImportSessionContext } from "@/contexts/ImportSessionContext"
const CheckIcon = ({ color }: { color: string }) => <CgCheck size="24" className={color} /> const CheckIcon = ({ color }: { color: string }) => <CgCheck size="24" className={color} />
export const Steps = () => { export const Steps = () => {
const { initialStepState, translations, isNavigationEnabled, isOpen } = useRsi() const { initialStepState, translations, isNavigationEnabled } = useRsi()
const { clearSession } = useContext(ImportSessionContext)
const initialStep = stepTypeToStepIndex(initialStepState?.type) const initialStep = stepTypeToStepIndex(initialStepState?.type)
const [activeStep, setActiveStep] = useState(initialStep) const [activeStep, setActiveStep] = useState(initialStep)
const [state, setState] = useState<StepState>(initialStepState || { type: StepType.upload }) const [state, setState] = useState<StepState>(initialStepState || { type: StepType.upload })
const history = useRef<StepState[]>([]) const history = useRef<StepState[]>([])
const prevIsOpen = useRef(isOpen)
// Reset state when dialog is reopened // Clear previous session on mount so each open starts fresh.
// Steps unmounts when the dialog closes (Radix portal), so every open = fresh mount.
useEffect(() => { useEffect(() => {
// Check if dialog was closed and is now open again clearSession()
if (isOpen && !prevIsOpen.current) { // eslint-disable-next-line react-hooks/exhaustive-deps
// Reset to initial state }, [])
setActiveStep(initialStep)
setState(initialStepState || { type: StepType.upload })
history.current = []
}
// Update previous isOpen value
prevIsOpen.current = isOpen
}, [isOpen, initialStep, initialStepState])
const onClickStep = (stepIndex: number) => { const onClickStep = (stepIndex: number) => {
const type = stepIndexToStepType(stepIndex) const type = stepIndexToStepType(stepIndex)
@@ -65,7 +59,10 @@ export const Steps = () => {
const validationStepIndex = steps.indexOf('validationStep') const validationStepIndex = steps.indexOf('validationStep')
setActiveStep(validationStepIndex) setActiveStep(validationStepIndex)
} else if (v.type !== StepType.selectSheet) { } else if (v.type !== StepType.selectSheet) {
setActiveStep(activeStep + 1) // Use the step type to determine the correct index directly,
// rather than incrementing, to avoid stale closure issues
const targetIndex = stepTypeToStepIndex(v.type)
setActiveStep(targetIndex)
} }
} }

View File

@@ -130,7 +130,12 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
) )
// Import session context for session restoration // Import session context for session restoration
const { loadSession } = useImportSession() const { loadSession, setGlobalSelections: setSessionGlobalSelections } = useImportSession()
// Sync global selections to session context for autosave
useEffect(() => {
setSessionGlobalSelections(persistedGlobalSelections)
}, [persistedGlobalSelections, setSessionGlobalSelections])
// Handle restoring a saved session // Handle restoring a saved session
const handleRestoreSession = useCallback((session: ImportSession) => { const handleRestoreSession = useCallback((session: ImportSession) => {

View File

@@ -30,6 +30,7 @@ import { SanityCheckDialog } from '../dialogs/SanityCheckDialog';
import { TemplateForm } from '@/components/templates/TemplateForm'; import { TemplateForm } from '@/components/templates/TemplateForm';
import { AiSuggestionsProvider } from '../contexts/AiSuggestionsContext'; import { AiSuggestionsProvider } from '../contexts/AiSuggestionsContext';
import { useImportAutosave } from '@/hooks/useImportAutosave'; import { useImportAutosave } from '@/hooks/useImportAutosave';
import { useImportSession } from '@/contexts/ImportSessionContext';
import type { CleanRowData, RowData } from '../store/types'; import type { CleanRowData, RowData } from '../store/types';
import type { ProductForSanityCheck } from '../hooks/useSanityCheck'; import type { ProductForSanityCheck } from '../hooks/useSanityCheck';
import type { ImportSessionData, SerializedValidationState } from '@/types/importSession'; import type { ImportSessionData, SerializedValidationState } from '@/types/importSession';
@@ -73,6 +74,9 @@ export const ValidationContainer = ({
// Handle UPC validation after copy-down operations on supplier/upc fields // Handle UPC validation after copy-down operations on supplier/upc fields
useCopyDownValidation(); useCopyDownValidation();
// Import session context for global selections
const { getGlobalSelections } = useImportSession();
// Import session autosave // Import session autosave
const { markDirty } = useImportAutosave({ const { markDirty } = useImportAutosave({
enabled: true, enabled: true,
@@ -96,9 +100,10 @@ export const ValidationContainer = ({
return { return {
current_step: 'validation', current_step: 'validation',
data: state.rows, data: state.rows,
global_selections: getGlobalSelections(),
validation_state: serializedValidationState, validation_state: serializedValidationState,
}; };
}, []), }, [getGlobalSelections]),
}); });
// Subscribe to store changes to trigger autosave // Subscribe to store changes to trigger autosave

View File

@@ -27,6 +27,7 @@ import { useValidationStore } from '../store/validationStore';
import { import {
useFields, useFields,
useFilters, useFilters,
useRowCount,
} from '../store/selectors'; } from '../store/selectors';
// NOTE: We intentionally do NOT import useValidationActions or useProductLines here! // NOTE: We intentionally do NOT import useValidationActions or useProductLines here!
// Those hooks subscribe to global state (rows, errors, caches) which would cause // Those hooks subscribe to global state (rows, errors, caches) which would cause
@@ -1491,6 +1492,8 @@ VirtualRow.displayName = 'VirtualRow';
*/ */
const HeaderCheckbox = memo(() => { const HeaderCheckbox = memo(() => {
const filters = useFilters(); const filters = useFilters();
// Subscribe to row count to recalculate when rows are added/removed
const rowCount = useRowCount();
// Compute which rows are visible based on current filters // Compute which rows are visible based on current filters
const { visibleRowIds, visibleCount } = useMemo(() => { const { visibleRowIds, visibleCount } = useMemo(() => {
@@ -1525,7 +1528,7 @@ const HeaderCheckbox = memo(() => {
}); });
return { visibleRowIds: ids, visibleCount: ids.size }; return { visibleRowIds: ids, visibleCount: ids.size };
}, [filters.searchText, filters.showErrorsOnly]); }, [filters.searchText, filters.showErrorsOnly, rowCount]);
// Check selection state against visible rows only // Check selection state against visible rows only
const selectedRows = useValidationStore((state) => state.selectedRows); const selectedRows = useValidationStore((state) => state.selectedRows);

View File

@@ -10,7 +10,7 @@
* This dramatically improves performance for 100+ option lists. * This dramatically improves performance for 100+ option lists.
*/ */
import { useState, useCallback, useRef, memo } from 'react'; import { useState, useCallback, useRef, useMemo, memo } from 'react';
import { Check, ChevronsUpDown, Loader2 } from 'lucide-react'; import { Check, ChevronsUpDown, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -59,6 +59,7 @@ const ComboboxCellComponent = ({
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [isLoadingOptions, setIsLoadingOptions] = useState(false); const [isLoadingOptions, setIsLoadingOptions] = useState(false);
const hasFetchedRef = useRef(false); const hasFetchedRef = useRef(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Get store state for coordinating with popover close behavior // Get store state for coordinating with popover close behavior
const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt); const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt);
@@ -78,6 +79,10 @@ const ComboboxCellComponent = ({
if (isOpen && Date.now() - cellPopoverClosedAt < POPOVER_CLOSE_DELAY) { if (isOpen && Date.now() - cellPopoverClosedAt < POPOVER_CLOSE_DELAY) {
return; return;
} }
// Reset scroll position when opening
if (isOpen && scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = 0;
}
setOpen(isOpen); setOpen(isOpen);
if (isOpen && onFetchOptions && options.length === 0 && !hasFetchedRef.current) { if (isOpen && onFetchOptions && options.length === 0 && !hasFetchedRef.current) {
hasFetchedRef.current = true; hasFetchedRef.current = true;
@@ -90,6 +95,13 @@ const ComboboxCellComponent = ({
[onFetchOptions, options.length, cellPopoverClosedAt] [onFetchOptions, options.length, cellPopoverClosedAt]
); );
// Reset scroll position when search filters the list
const handleSearchChange = useCallback(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = 0;
}
}, []);
// Handle selection // Handle selection
const handleSelect = useCallback( const handleSelect = useCallback(
(selectedValue: string) => { (selectedValue: string) => {
@@ -105,6 +117,11 @@ const ComboboxCellComponent = ({
e.currentTarget.scrollTop += e.deltaY; e.currentTarget.scrollTop += e.deltaY;
}, []); }, []);
// Sort options alphabetically by label for consistent display
const sortedOptions = useMemo(() => {
return [...options].sort((a, b) => a.label.localeCompare(b.label));
}, [options]);
return ( return (
<div className="relative w-full"> <div className="relative w-full">
<Popover open={open} onOpenChange={handleOpenChange}> <Popover open={open} onOpenChange={handleOpenChange}>
@@ -130,7 +147,10 @@ const ComboboxCellComponent = ({
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[250px] p-0" align="start"> <PopoverContent className="w-[250px] p-0" align="start">
<Command> <Command>
<CommandInput placeholder={`Search ${field.label.toLowerCase()}...`} /> <CommandInput
placeholder={`Search ${field.label.toLowerCase()}...`}
onValueChange={handleSearchChange}
/>
<CommandList> <CommandList>
{isLoadingOptions ? ( {isLoadingOptions ? (
<div className="flex items-center justify-center py-6"> <div className="flex items-center justify-center py-6">
@@ -140,11 +160,12 @@ const ComboboxCellComponent = ({
<> <>
<CommandEmpty>No {field.label.toLowerCase()} found.</CommandEmpty> <CommandEmpty>No {field.label.toLowerCase()} found.</CommandEmpty>
<div <div
ref={scrollContainerRef}
className="max-h-[200px] overflow-y-auto overscroll-contain" className="max-h-[200px] overflow-y-auto overscroll-contain"
onWheel={handleWheel} onWheel={handleWheel}
> >
<CommandGroup> <CommandGroup>
{options.map((option) => ( {sortedOptions.map((option) => (
<CommandItem <CommandItem
key={option.value} key={option.value}
value={option.label} // cmdk filters by this value value={option.label} // cmdk filters by this value

View File

@@ -16,7 +16,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip'; } from '@/components/ui/tooltip';
import { X, Loader2, Sparkles, AlertCircle, Check, ChevronDown, ChevronUp } from 'lucide-react'; import { X, Loader2, Sparkles, AlertCircle, Check } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import type { Field, SelectOption } from '../../../../types'; import type { Field, SelectOption } from '../../../../types';
import type { ValidationError } from '../../store/types'; import type { ValidationError } from '../../store/types';
@@ -55,6 +55,7 @@ interface MultilineInputProps {
const MultilineInputComponent = ({ const MultilineInputComponent = ({
field, field,
value, value,
productIndex,
isValidating, isValidating,
errors, errors,
onChange: _onChange, // Unused - onBlur handles both update and validation onChange: _onChange, // Unused - onBlur handles both update and validation
@@ -69,6 +70,8 @@ const MultilineInputComponent = ({
const [aiSuggestionExpanded, setAiSuggestionExpanded] = useState(false); const [aiSuggestionExpanded, setAiSuggestionExpanded] = useState(false);
const [editedSuggestion, setEditedSuggestion] = useState(''); const [editedSuggestion, setEditedSuggestion] = useState('');
const [popoverWidth, setPopoverWidth] = useState(400); const [popoverWidth, setPopoverWidth] = useState(400);
const [popoverHeight, setPopoverHeight] = useState<number | undefined>(undefined);
const resizeContainerRef = useRef<HTMLDivElement>(null);
const cellRef = useRef<HTMLDivElement>(null); const cellRef = useRef<HTMLDivElement>(null);
const preventReopenRef = useRef(false); const preventReopenRef = useRef(false);
// Tracks intentional closes (close button, accept/dismiss) vs click-outside closes // Tracks intentional closes (close button, accept/dismiss) vs click-outside closes
@@ -77,6 +80,14 @@ const MultilineInputComponent = ({
const suggestionTextareaRef = useRef<HTMLTextAreaElement>(null); const suggestionTextareaRef = useRef<HTMLTextAreaElement>(null);
// Tracks the value when popover opened, to detect actual changes // Tracks the value when popover opened, to detect actual changes
const initialEditValueRef = useRef(''); 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(
(s) => s.rows.find((row) => row.__index === productIndex)?.name as string | undefined
);
// Get store state and actions for coordinating popover close behavior across cells // Get store state and actions for coordinating popover close behavior across cells
const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt); const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt);
@@ -134,14 +145,62 @@ const MultilineInputComponent = ({
} }
}, [popoverOpen, editValue, autoResizeTextarea]); }, [popoverOpen, editValue, autoResizeTextarea]);
// Auto-resize suggestion textarea when expanded or value changes // Auto-resize suggestion textarea when expanded/visible or value changes
useEffect(() => { useEffect(() => {
if (aiSuggestionExpanded) { if (aiSuggestionExpanded || (popoverOpen && hasAiSuggestion)) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
autoResizeTextarea(suggestionTextareaRef.current); autoResizeTextarea(suggestionTextareaRef.current);
}); });
} }
}, [aiSuggestionExpanded, editedSuggestion, autoResizeTextarea]); }, [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; }
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 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) // Check if another cell's popover was recently closed (prevents immediate focus on click-outside)
const wasPopoverRecentlyClosed = useCallback(() => { const wasPopoverRecentlyClosed = useCallback(() => {
@@ -352,14 +411,30 @@ const MultilineInputComponent = ({
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
<PopoverContent <PopoverContent
className="p-0 shadow-lg rounded-md" className="p-0 shadow-lg rounded-md max-lg:!w-[95vw]"
style={{ width: popoverWidth }} style={{ width: hasAiSuggestion ? popoverWidth * 2 : popoverWidth }}
align="start" align="start"
side="bottom" side="bottom"
alignOffset={0} alignOffset={0}
sideOffset={-65} sideOffset={-65}
ref={(node) => {
// Override Radix popper positioning to center on screen when AI suggestion is showing
if (node && hasAiSuggestion) {
const wrapper = node.parentElement;
if (wrapper?.hasAttribute('data-radix-popper-content-wrapper')) {
wrapper.style.position = 'fixed';
wrapper.style.top = '50%';
wrapper.style.left = '50%';
wrapper.style.transform = 'translate(-50%, -50%)';
}
}
}}
> >
<div className="flex flex-col"> <div
ref={resizeContainerRef}
className="flex flex-col lg:flex-row items-stretch lg:resize-y lg:overflow-auto lg:min-h-[120px] max-h-[85vh] overflow-y-auto lg:max-h-none"
style={popoverHeight ? { height: popoverHeight } : undefined}
>
{/* Close button */} {/* Close button */}
<Button <Button
size="icon" size="icon"
@@ -371,98 +446,115 @@ const MultilineInputComponent = ({
</Button> </Button>
{/* Main textarea */} {/* Main textarea */}
<div data-col="left" className="flex flex-col min-h-0 w-full 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 <Textarea
ref={mainTextareaRef} ref={mainTextareaRef}
value={editValue} value={editValue}
onChange={handleChange} onChange={handleChange}
onWheel={handleTextareaWheel} onWheel={handleTextareaWheel}
className="overflow-y-auto overscroll-contain border-none focus-visible:ring-0 rounded-t-md rounded-b-none pl-2 pr-4 py-1 resize-y min-h-[65px]" 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'}...`} placeholder={`Enter ${field.label || 'text'}...`}
autoFocus autoFocus
/> />
{hasAiSuggestion && <div className="h-[43px] flex-shrink-0 hidden lg:block" />}
</div></div>
{/* AI Suggestion section */} {/* AI Suggestion section */}
{hasAiSuggestion && ( {hasAiSuggestion && (
<div className="border-t border-purple-200 dark:border-purple-800 bg-purple-50/80 dark:bg-purple-950/30"> <div data-col="right" className="bg-purple-50/80 dark:bg-purple-950/30 flex flex-col w-full lg:w-1/2">
{/* Collapsed header - always visible */} {/* Measured header + issues area (mirrored as spacer on the left) */}
<button <div ref={aiHeaderRef} className="flex-shrink-0">
type="button" {/* Header */}
onClick={() => setAiSuggestionExpanded(!aiSuggestionExpanded)} <div className="w-full flex items-center justify-between px-3 py-2">
className="w-full flex items-center justify-between px-3 py-2 hover:bg-purple-100/50 dark:hover:bg-purple-900/30 transition-colors"
>
<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>
{aiSuggestionExpanded ? (
<ChevronUp className="h-4 w-4 text-purple-400" />
) : (
<ChevronDown className="h-4 w-4 text-purple-400" />
)}
</button>
{/* Expanded content */}
{aiSuggestionExpanded && (
<div className="px-3 pb-3 space-y-3">
{/* Issues list */}
{aiIssues.length > 0 && (
<div className="flex flex-col gap-1">
{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>
)}
{/* Editable suggestion */}
<div>
<div className="text-xs text-purple-500 dark:text-purple-400 mb-1 font-medium">
Suggested (editable):
</div>
<Textarea
ref={suggestionTextareaRef}
value={editedSuggestion}
onChange={(e) => {
setEditedSuggestion(e.target.value);
autoResizeTextarea(e.target);
}}
onWheel={handleTextareaWheel}
className="min-h-[80px] 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"
/>
</div>
{/* Actions */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Sparkles className="h-3.5 w-3.5 text-purple-500" />
size="sm" <span className="text-xs font-medium text-purple-600 dark:text-purple-400">
variant="outline" AI Suggestion
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" </span>
onClick={handleAcceptSuggestion} <span className="text-xs text-purple-500 dark:text-purple-400">
> ({aiIssues.length} {aiIssues.length === 1 ? 'issue' : 'issues'})
<Check className="h-3 w-3 mr-1" /> </span>
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>
</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>
</div> </div>
)} )}
</div> </div>

View File

@@ -65,6 +65,7 @@ const SelectCellComponent = ({
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [isFetchingOptions, setIsFetchingOptions] = useState(false); const [isFetchingOptions, setIsFetchingOptions] = useState(false);
const hasFetchedRef = useRef(false); const hasFetchedRef = useRef(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Get store state for coordinating with popover close behavior // Get store state for coordinating with popover close behavior
const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt); const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt);
@@ -98,11 +99,22 @@ const SelectCellComponent = ({
setIsFetchingOptions(false); setIsFetchingOptions(false);
} }
} }
// Reset scroll position when opening
if (isOpen && scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = 0;
}
setOpen(isOpen); setOpen(isOpen);
}, },
[onFetchOptions, options.length, cellPopoverClosedAt] [onFetchOptions, options.length, cellPopoverClosedAt]
); );
// Reset scroll position when search filters the list
const handleSearchChange = useCallback(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = 0;
}
}, []);
// Handle selection // Handle selection
const handleSelect = useCallback( const handleSelect = useCallback(
(selectedValue: string) => { (selectedValue: string) => {
@@ -118,6 +130,11 @@ const SelectCellComponent = ({
e.currentTarget.scrollTop += e.deltaY; e.currentTarget.scrollTop += e.deltaY;
}, []); }, []);
// Sort options alphabetically by label for consistent display
const sortedOptions = useMemo(() => {
return [...options].sort((a, b) => a.label.localeCompare(b.label));
}, [options]);
// Find display label for current value // Find display label for current value
// IMPORTANT: We need to match against both string and number value types // IMPORTANT: We need to match against both string and number value types
const displayLabel = useMemo(() => { const displayLabel = useMemo(() => {
@@ -182,7 +199,11 @@ const SelectCellComponent = ({
sideOffset={4} sideOffset={4}
> >
<Command shouldFilter={true}> <Command shouldFilter={true}>
<CommandInput placeholder="Search..." className="h-9" /> <CommandInput
placeholder="Search..."
className="h-9"
onValueChange={handleSearchChange}
/>
<CommandList> <CommandList>
{isLoadingOptions ? ( {isLoadingOptions ? (
<div className="flex items-center justify-center py-4"> <div className="flex items-center justify-center py-4">
@@ -192,11 +213,12 @@ const SelectCellComponent = ({
<> <>
<CommandEmpty>No results found.</CommandEmpty> <CommandEmpty>No results found.</CommandEmpty>
<div <div
ref={scrollContainerRef}
className="max-h-[200px] overflow-y-auto overscroll-contain" className="max-h-[200px] overflow-y-auto overscroll-contain"
onWheel={handleWheel} onWheel={handleWheel}
> >
<CommandGroup> <CommandGroup>
{options.map((option) => ( {sortedOptions.map((option) => (
<CommandItem <CommandItem
key={option.value} key={option.value}
value={option.label} value={option.label}

View File

@@ -15,6 +15,7 @@ import {
useTemplates, useTemplates,
useTemplatesLoading, useTemplatesLoading,
useTemplateState, useTemplateState,
useFields,
} from '../store/selectors'; } from '../store/selectors';
import { toast } from 'sonner'; import { toast } from 'sonner';
import config from '@/config'; import config from '@/config';
@@ -41,6 +42,7 @@ export const useTemplateManagement = () => {
const templates = useTemplates(); const templates = useTemplates();
const templatesLoading = useTemplatesLoading(); const templatesLoading = useTemplatesLoading();
const templateState = useTemplateState(); const templateState = useTemplateState();
const fields = useFields();
// Store actions // Store actions
const setTemplates = useValidationStore((state) => state.setTemplates); const setTemplates = useValidationStore((state) => state.setTemplates);
@@ -101,9 +103,17 @@ export const useTemplateManagement = () => {
return; return;
} }
// Extract template fields // Extract template fields, excluding those with empty/null/undefined values
// This preserves existing row values when template has no value for a field
const templateFields = Object.entries(template).filter( const templateFields = Object.entries(template).filter(
([key]) => !TEMPLATE_EXCLUDE_FIELDS.includes(key) ([key, value]) => {
if (TEMPLATE_EXCLUDE_FIELDS.includes(key)) return false;
// Skip empty values so existing row data is preserved
if (value === null || value === undefined || value === '') return false;
// Skip empty arrays
if (Array.isArray(value) && value.length === 0) return false;
return true;
}
); );
// Apply template to each row // Apply template to each row
@@ -295,6 +305,7 @@ export const useTemplateManagement = () => {
/** /**
* Get display text for a template (e.g., "Brand - Product Type") * Get display text for a template (e.g., "Brand - Product Type")
* Looks up company name from field options instead of showing ID
*/ */
const getTemplateDisplayText = useCallback( const getTemplateDisplayText = useCallback(
(templateId: string | null): string => { (templateId: string | null): string => {
@@ -303,12 +314,20 @@ export const useTemplateManagement = () => {
const template = templates.find((t) => t.id.toString() === templateId); const template = templates.find((t) => t.id.toString() === templateId);
if (!template) return ''; if (!template) return '';
// Return "Brand - Product Type" format // Look up company name from field options
const company = template.company || 'Unknown'; const companyField = fields.find((f) => f.key === 'company');
const companyOptions = companyField?.fieldType?.type === 'select'
? companyField.fieldType.options
: [];
const companyOption = companyOptions?.find(
(opt) => String(opt.value) === String(template.company)
);
const companyName = companyOption?.label || template.company || 'Unknown';
const productType = template.product_type || 'Unknown'; const productType = template.product_type || 'Unknown';
return `${company} - ${productType}`; return `${companyName} - ${productType}`;
}, },
[templates] [templates, fields]
); );
return { return {

View File

@@ -10,6 +10,7 @@ export type SubmitOptions = {
targetEnvironment: "dev" | "prod" targetEnvironment: "dev" | "prod"
useTestDataSource: boolean useTestDataSource: boolean
skipApiSubmission?: boolean skipApiSubmission?: boolean
showNewProduct?: boolean
} }
export type RsiProps<T extends string> = { export type RsiProps<T extends string> = {

View File

@@ -26,6 +26,7 @@ import type {
ImportSessionData, ImportSessionData,
ImportSessionContextValue, ImportSessionContextValue,
} from '@/types/importSession'; } from '@/types/importSession';
import { useValidationStore } from '@/components/product-import/steps/ValidationStep/store/validationStore';
const AUTOSAVE_DEBOUNCE_MS = 10000; // 10 seconds const AUTOSAVE_DEBOUNCE_MS = 10000; // 10 seconds
@@ -46,6 +47,8 @@ const defaultContext: ImportSessionContextValue = {
deleteSession: async () => {}, deleteSession: async () => {},
setDataGetter: () => {}, setDataGetter: () => {},
getSuggestedSessionName: () => null, getSuggestedSessionName: () => null,
setGlobalSelections: () => {},
getGlobalSelections: () => undefined,
}; };
export const ImportSessionContext = createContext<ImportSessionContextValue>(defaultContext); export const ImportSessionContext = createContext<ImportSessionContextValue>(defaultContext);
@@ -65,9 +68,26 @@ export function ImportSessionProvider({ children }: ImportSessionProviderProps)
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [currentStep, setCurrentStep] = useState<'validation' | 'imageUpload'>('validation'); const [currentStep, setCurrentStep] = useState<'validation' | 'imageUpload'>('validation');
// Refs to hold current values so closures always read fresh state
const sessionIdRef = useRef(sessionId);
const sessionNameRef = useRef(sessionName);
const isDirtyRef = useRef(isDirty);
const isSavingRef = useRef(isSaving);
const userIdRef = useRef(user?.id);
// Keep refs in sync with state
useEffect(() => { sessionIdRef.current = sessionId; }, [sessionId]);
useEffect(() => { sessionNameRef.current = sessionName; }, [sessionName]);
useEffect(() => { isDirtyRef.current = isDirty; }, [isDirty]);
useEffect(() => { isSavingRef.current = isSaving; }, [isSaving]);
useEffect(() => { userIdRef.current = user?.id; }, [user?.id]);
// Ref to hold the data getter function (set by ValidationStep/ImageUploadStep) // Ref to hold the data getter function (set by ValidationStep/ImageUploadStep)
const dataGetterRef = useRef<(() => ImportSessionData) | null>(null); const dataGetterRef = useRef<(() => ImportSessionData) | null>(null);
// Ref to hold global selections for inclusion in autosave
const globalSelectionsRef = useRef<import('@/components/product-import/steps/MatchColumnsStep/types').GlobalSelections | undefined>(undefined);
// Autosave timer ref // Autosave timer ref
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -87,11 +107,57 @@ export function ImportSessionProvider({ children }: ImportSessionProviderProps)
dataGetterRef.current = getter; dataGetterRef.current = getter;
}, []); }, []);
const setGlobalSelections = useCallback((selections: import('@/components/product-import/steps/MatchColumnsStep/types').GlobalSelections | undefined) => {
globalSelectionsRef.current = selections;
}, []);
const getGlobalSelections = useCallback(() => {
return globalSelectionsRef.current;
}, []);
/** /**
* Mark the session as having unsaved changes * Perform save operation. Reads all values from refs to avoid stale closures.
*/
const performSave = useCallback(async (data: ImportSessionData, force = false) => {
const userId = userIdRef.current;
if (!userId || (!force && isSavingRef.current)) return;
setIsSaving(true);
isSavingRef.current = true;
try {
const id = sessionIdRef.current;
if (id) {
// Update existing session (named or unnamed)
await updateSession(id, data);
} else {
// Create/update unnamed autosave session
const result = await autosaveSession({
user_id: userId,
...data,
});
// Store the session ID for future updates
setSessionId(result.id);
sessionIdRef.current = result.id;
}
setLastSaved(new Date());
setIsDirty(false);
isDirtyRef.current = false;
} catch (error) {
console.error('Autosave failed:', error);
// Don't clear dirty flag - will retry on next change
} finally {
setIsSaving(false);
isSavingRef.current = false;
}
}, []);
/**
* Mark the session as having unsaved changes.
* Schedules a debounced autosave.
*/ */
const markDirty = useCallback(() => { const markDirty = useCallback(() => {
setIsDirty(true); setIsDirty(true);
isDirtyRef.current = true;
// Schedule autosave // Schedule autosave
if (autosaveTimerRef.current) { if (autosaveTimerRef.current) {
@@ -99,59 +165,27 @@ export function ImportSessionProvider({ children }: ImportSessionProviderProps)
} }
autosaveTimerRef.current = setTimeout(() => { autosaveTimerRef.current = setTimeout(() => {
// Trigger autosave if we have a data getter and user if (dataGetterRef.current && userIdRef.current) {
if (dataGetterRef.current && user?.id) {
const data = dataGetterRef.current(); const data = dataGetterRef.current();
performAutosave(data); performSave(data);
} }
}, AUTOSAVE_DEBOUNCE_MS); }, AUTOSAVE_DEBOUNCE_MS);
}, [user?.id]); }, [performSave]);
/**
* Perform autosave operation
*/
const performAutosave = useCallback(async (data: ImportSessionData) => {
if (!user?.id || isSaving) return;
setIsSaving(true);
try {
if (sessionId && sessionName) {
// Update existing named session
await updateSession(sessionId, data);
} else if (sessionId && !sessionName) {
// Update existing unnamed session
await updateSession(sessionId, data);
} else {
// Create/update unnamed autosave session
const result = await autosaveSession({
user_id: user.id,
...data,
});
// Store the session ID for future updates
setSessionId(result.id);
}
setLastSaved(new Date());
setIsDirty(false);
} catch (error) {
console.error('Autosave failed:', error);
// Don't clear dirty flag - will retry on next change
} finally {
setIsSaving(false);
}
}, [user?.id, sessionId, sessionName, isSaving]);
/** /**
* Save current state (autosave or update named session) * Save current state (autosave or update named session)
*/ */
const save = useCallback(async () => { const save = useCallback(async () => {
if (!dataGetterRef.current || !user?.id) return; if (!dataGetterRef.current || !userIdRef.current) return;
const data = dataGetterRef.current(); const data = dataGetterRef.current();
await performAutosave(data); await performSave(data);
}, [user?.id, performAutosave]); }, [performSave]);
/** /**
* Force immediate save, bypassing debounce (used before closing) * Force immediate save, bypassing debounce (used before closing).
* Always saves if there's data, regardless of dirty state — ensures
* at least one save happens even if no edits were made.
*/ */
const forceSave = useCallback(async () => { const forceSave = useCallback(async () => {
// Clear any pending autosave timer // Clear any pending autosave timer
@@ -160,12 +194,11 @@ export function ImportSessionProvider({ children }: ImportSessionProviderProps)
autosaveTimerRef.current = null; autosaveTimerRef.current = null;
} }
// Only save if dirty and we have data if (!dataGetterRef.current || !userIdRef.current) return;
if (!isDirty || !dataGetterRef.current || !user?.id) return;
const data = dataGetterRef.current(); const data = dataGetterRef.current();
await performAutosave(data); await performSave(data, true);
}, [isDirty, user?.id, performAutosave]); }, [performSave]);
/** /**
* Get a suggested session name based on data (Company - Line format) * Get a suggested session name based on data (Company - Line format)
@@ -177,18 +210,32 @@ export function ImportSessionProvider({ children }: ImportSessionProviderProps)
const data = dataGetterRef.current(); const data = dataGetterRef.current();
if (!data.data || data.data.length === 0) return null; if (!data.data || data.data.length === 0) return null;
// Build lookup helpers from the validation store
const storeState = useValidationStore.getState();
const companyField = storeState.fields.find((f) => f.key === 'company');
const companyOptions = companyField?.fieldType && 'options' in companyField.fieldType
? (companyField.fieldType.options as { label: string; value: string }[])
: [];
const resolveCompany = (id: string) =>
companyOptions.find((o) => String(o.value) === id)?.label ?? id;
const resolveLine = (companyId: string, lineId: string) => {
const cached = storeState.productLinesCache?.get(String(companyId));
return cached?.find((o) => String(o.value) === lineId)?.label ?? lineId;
};
// Find the first row with both company and line // Find the first row with both company and line
for (const row of data.data) { for (const row of data.data) {
const company = row.company; const company = row.company;
const line = row.line; const line = row.line;
if (company && line) { if (company && line) {
// Convert values to display strings const companyStr = String(company).trim();
const companyStr = typeof company === 'string' ? company : String(company); const lineStr = String(line).trim();
const lineStr = typeof line === 'string' ? line : String(line);
if (companyStr.trim() && lineStr.trim()) { if (companyStr && lineStr) {
return `${companyStr} - ${lineStr}`; return `${resolveCompany(companyStr)} - ${resolveLine(companyStr, lineStr)}`;
} }
} }
} }
@@ -197,9 +244,9 @@ export function ImportSessionProvider({ children }: ImportSessionProviderProps)
for (const row of data.data) { for (const row of data.data) {
const company = row.company; const company = row.company;
if (company) { if (company) {
const companyStr = typeof company === 'string' ? company : String(company); const companyStr = String(company).trim();
if (companyStr.trim()) { if (companyStr) {
return companyStr; return resolveCompany(companyStr);
} }
} }
} }
@@ -211,46 +258,61 @@ export function ImportSessionProvider({ children }: ImportSessionProviderProps)
}, []); }, []);
/** /**
* Save current state as a new named session * Save current state as a named session.
* If already tracking a named session, updates it. Otherwise creates a new one.
*/ */
const saveAsNamed = useCallback(async (name: string): Promise<ImportSession> => { const saveAsNamed = useCallback(async (name: string): Promise<ImportSession> => {
if (!dataGetterRef.current || !user?.id) { if (!dataGetterRef.current || !userIdRef.current) {
throw new Error('Cannot save: no data or user'); throw new Error('Cannot save: no data or user');
} }
setIsSaving(true); setIsSaving(true);
isSavingRef.current = true;
try { try {
const data = dataGetterRef.current(); const data = dataGetterRef.current();
const result = await createSession({ const existingId = sessionIdRef.current;
user_id: user.id, let result: ImportSession;
name,
...data, if (existingId) {
}); // Existing session (named or unnamed autosave) - update it with the name
result = await updateSession(existingId, { ...data, name });
} else {
// No session exists yet - create a new named session
result = await createSession({
user_id: userIdRef.current,
name,
...data,
});
}
// Update context to track this named session // Update context to track this named session
setSessionId(result.id); setSessionId(result.id);
sessionIdRef.current = result.id;
setSessionName(name); setSessionName(name);
sessionNameRef.current = name;
setLastSaved(new Date()); setLastSaved(new Date());
setIsDirty(false); setIsDirty(false);
isDirtyRef.current = false;
// If we had an unnamed session before, it's now replaced by this named one
// The unnamed slot will be cleared when user starts a new import
return result; return result;
} finally { } finally {
setIsSaving(false); setIsSaving(false);
isSavingRef.current = false;
} }
}, [user?.id]); }, []);
/** /**
* Load a session (called when restoring from saved sessions list) * Load a session (called when restoring from saved sessions list)
*/ */
const loadSession = useCallback((session: ImportSession) => { const loadSession = useCallback((session: ImportSession) => {
setSessionId(session.id); setSessionId(session.id);
sessionIdRef.current = session.id;
setSessionName(session.name); setSessionName(session.name);
sessionNameRef.current = session.name;
setCurrentStep(session.current_step); setCurrentStep(session.current_step);
setLastSaved(new Date(session.updated_at)); setLastSaved(new Date(session.updated_at));
setIsDirty(false); setIsDirty(false);
isDirtyRef.current = false;
}, []); }, []);
/** /**
@@ -261,8 +323,11 @@ export function ImportSessionProvider({ children }: ImportSessionProviderProps)
clearTimeout(autosaveTimerRef.current); clearTimeout(autosaveTimerRef.current);
} }
setSessionId(null); setSessionId(null);
sessionIdRef.current = null;
setSessionName(null); setSessionName(null);
sessionNameRef.current = null;
setIsDirty(false); setIsDirty(false);
isDirtyRef.current = false;
setLastSaved(null); setLastSaved(null);
setCurrentStep('validation'); setCurrentStep('validation');
dataGetterRef.current = null; dataGetterRef.current = null;
@@ -272,17 +337,18 @@ export function ImportSessionProvider({ children }: ImportSessionProviderProps)
* Delete the current session (called on successful submit) * Delete the current session (called on successful submit)
*/ */
const handleDeleteSession = useCallback(async () => { const handleDeleteSession = useCallback(async () => {
if (!sessionId) return; const id = sessionIdRef.current;
if (!id) return;
try { try {
await deleteSession(sessionId); await deleteSession(id);
clearSession(); clearSession();
} catch (error) { } catch (error) {
console.error('Failed to delete session:', error); console.error('Failed to delete session:', error);
// Clear local state anyway // Clear local state anyway
clearSession(); clearSession();
} }
}, [sessionId, clearSession]); }, [clearSession]);
return ( return (
<ImportSessionContext.Provider <ImportSessionContext.Provider
@@ -303,6 +369,8 @@ export function ImportSessionProvider({ children }: ImportSessionProviderProps)
deleteSession: handleDeleteSession, deleteSession: handleDeleteSession,
setDataGetter, setDataGetter,
getSuggestedSessionName, getSuggestedSessionName,
setGlobalSelections,
getGlobalSelections,
}} }}
> >
{children} {children}

View File

@@ -61,13 +61,15 @@ export function useImportAutosave({
getSessionDataRef.current = getSessionData; getSessionDataRef.current = getSessionData;
}, [getSessionData]); }, [getSessionData]);
// Register the data getter with the context // Register the data getter with the context and trigger initial autosave
useEffect(() => { useEffect(() => {
if (enabled) { if (enabled) {
setDataGetter(() => getSessionDataRef.current()); setDataGetter(() => getSessionDataRef.current());
setCurrentStep(step); setCurrentStep(step);
// Trigger initial autosave so the session is persisted immediately
contextMarkDirty();
} }
}, [enabled, setDataGetter, setCurrentStep, step]); }, [enabled, setDataGetter, setCurrentStep, step, contextMarkDirty]);
/** /**
* Mark the session as dirty (has unsaved changes). * Mark the session as dirty (has unsaved changes).

View File

@@ -18,7 +18,7 @@ import { submitNewProducts, type SubmitNewProductsResponse } from "@/services/ap
import { AuthContext } from "@/contexts/AuthContext"; import { AuthContext } from "@/contexts/AuthContext";
import { TemplateForm } from "@/components/templates/TemplateForm"; import { TemplateForm } from "@/components/templates/TemplateForm";
type NormalizedProduct = Record<ImportFieldKey | "product_images", string | string[] | boolean | null>; type NormalizedProduct = Record<ImportFieldKey | "product_images" | "show_new_product", string | string[] | boolean | null>;
type ImportResult = Result<string> & { all?: Result<string>["validData"] }; type ImportResult = Result<string> & { all?: Result<string>["validData"] };
interface BackendProductResult { interface BackendProductResult {
@@ -271,200 +271,6 @@ export function Import() {
const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes("admin:debug")); const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes("admin:debug"));
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// ========== TEMPORARY TEST DATA ==========
// Uncomment the useEffect below to test the results page without submitting actual data
// useEffect(() => {
// // Test scenario: Mix of successful and failed products
// const testSubmittedProducts: NormalizedProduct[] = [
// {
// name: "Test Product 1",
// upc: "123456789012",
// item_number: "ITEM-001",
// company: "Test Company",
// line: "Test Line",
// subline: "Test Subline",
// product_images: ["https://picsum.photos/200/200?random=1"],
// short_description: "This is a test product",
// retail: "29.99",
// wholesale: "15.00",
// weight: "1.5",
// categories: ["Category 1", "Category 2"],
// colors: ["Red", "Blue"],
// size_cat: "Medium",
// tax_cat: "Taxable",
// ship_restrictions: "None",
// supplier: "Test Supplier",
// artist: null,
// themes: ["Theme 1"],
// vendor_sku: "VS-001",
// publish: true,
// list_on_marketplace: false,
// },
// {
// name: "Test Product 2",
// upc: "234567890123",
// item_number: "ITEM-002",
// company: "Test Company",
// line: "Test Line",
// subline: "Test Subline",
// product_images: ["https://picsum.photos/200/200?random=2"],
// short_description: "Another test product",
// retail: "49.99",
// wholesale: "25.00",
// weight: "2.0",
// categories: ["Category 3"],
// colors: ["Green"],
// size_cat: "Large",
// tax_cat: "Taxable",
// ship_restrictions: "None",
// supplier: "Test Supplier",
// artist: "Test Artist",
// themes: [],
// vendor_sku: "VS-002",
// publish: true,
// list_on_marketplace: true,
// },
// {
// name: "Failed Product 1",
// upc: "345678901234",
// item_number: "ITEM-003",
// company: "Test Company",
// line: "Test Line",
// subline: null,
// product_images: ["https://picsum.photos/200/200?random=3"],
// short_description: "This product will fail",
// retail: "19.99",
// wholesale: "10.00",
// weight: "0.5",
// categories: [],
// colors: [],
// size_cat: null,
// tax_cat: "Taxable",
// ship_restrictions: null,
// supplier: null,
// artist: null,
// themes: [],
// vendor_sku: "VS-003",
// publish: false,
// list_on_marketplace: false,
// },
// {
// name: "Failed Product 2",
// upc: "456789012345",
// item_number: "ITEM-004",
// company: "Test Company",
// line: null,
// subline: null,
// product_images: null,
// description: "Another failed product",
// msrp: "99.99",
// cost_each: "50.00",
// weight: "5.0",
// categories: ["Category 1"],
// colors: ["Yellow"],
// size_cat: "Small",
// tax_cat: null,
// ship_restrictions: "Hazmat",
// supplier: "Test Supplier",
// artist: null,
// themes: [],
// vendor_sku: null,
// publish: true,
// list_on_marketplace: false,
// },
// ];
// const testSubmittedRows: Data<string>[] = testSubmittedProducts.map(product => ({ ...product } as Data<string>));
// //Scenario 1: All successful
// const testResponse: SubmitNewProductsResponse = {
// success: true,
// message: "Successfully created 4 products",
// data: {
// created: [
// { pid: 12345, upc: "123456789012", item_number: "ITEM-001" },
// { pid: 12346, upc: "234567890123", item_number: "ITEM-002" },
// { pid: 12347, upc: "345678901234", item_number: "ITEM-003" },
// { pid: 12348, upc: "456789012345", item_number: "ITEM-004" },
// ],
// errored: [],
// },
// };
// // Scenario 2: Partial success (2 created, 2 failed)
// const testResponse: SubmitNewProductsResponse = {
// success: true,
// message: "Created 2 of 4 products. 2 products had errors.",
// data: {
// created: [
// { pid: 12345, upc: "123456789012", item_number: "ITEM-001" },
// { pid: 12346, upc: "234567890123", item_number: "ITEM-002" },
// ],
// errored: [
// {
// upc: "345678901234",
// item_number: "ITEM-003",
// error_msg: "Missing required field: supplier",
// errors: {
// supplier: ["Supplier is required for this product line"],
// categories: ["At least one category must be selected"],
// },
// },
// {
// upc: "456789012345",
// item_number: "ITEM-004",
// error_msg: "Invalid product configuration",
// errors: {
// line: ["Product line is required"],
// tax_cat: ["Tax category must be specified"],
// },
// },
// ],
// query_id: "1234567890",
// },
// };
// // Scenario 3: Complete failure
// const testResponse: SubmitNewProductsResponse = {
// success: false,
// message: "Failed to create products. Please check the errors below.",
// data: {
// created: [],
// errored: [
// {
// upc: "123456789012",
// item_number: "ITEM-001",
// error_msg: "A product with this UPC already exists",
// },
// {
// upc: "234567890123",
// item_number: "ITEM-002",
// error_msg: "Invalid wholesale price",
// },
// {
// upc: "345678901234",
// item_number: "ITEM-003",
// error_msg: "Missing required field: supplier",
// },
// {
// upc: "456789012345",
// item_number: "ITEM-004",
// error_msg: "Invalid product configuration",
// },
// ],
// },
// };
// setImportOutcome({
// submittedProducts: testSubmittedProducts,
// submittedRows: testSubmittedRows,
// response: testResponse,
// });
// }, []);
// ========== END TEST DATA ==========
// Fetch initial field options from the API // Fetch initial field options from the API
const { data: fieldOptions, isLoading: isLoadingOptions } = useQuery({ const { data: fieldOptions, isLoading: isLoadingOptions } = useQuery({
queryKey: ["import-field-options"], queryKey: ["import-field-options"],
@@ -734,9 +540,13 @@ export function Import() {
normalizedProductImages = rawProductImages; normalizedProductImages = rawProductImages;
} }
// Preserve show_new_product flag if it was set
const showNewProduct = (row as Record<string, unknown>).show_new_product;
return { return {
...baseValues, ...baseValues,
product_images: normalizedProductImages, product_images: normalizedProductImages,
...(showNewProduct === true && { show_new_product: true }),
} as NormalizedProduct; } as NormalizedProduct;
}); });

View File

@@ -108,6 +108,10 @@ export interface ImportSessionContextValue {
setDataGetter: (getter: () => ImportSessionData) => void; setDataGetter: (getter: () => ImportSessionData) => void;
/** Get a suggested session name based on data (Company - Line) */ /** Get a suggested session name based on data (Company - Line) */
getSuggestedSessionName: () => string | null; getSuggestedSessionName: () => string | null;
/** Store global selections for inclusion in autosave data */
setGlobalSelections: (selections: GlobalSelections | undefined) => void;
/** Get stored global selections */
getGlobalSelections: () => GlobalSelections | undefined;
} }
// ============================================================================= // =============================================================================
@@ -136,6 +140,7 @@ export interface ImportSessionCreateRequest {
} }
export interface ImportSessionUpdateRequest { export interface ImportSessionUpdateRequest {
name?: string;
current_step: 'validation' | 'imageUpload'; current_step: 'validation' | 'imageUpload';
data: RowData[]; data: RowData[];
product_images?: ProductImageSortable[]; product_images?: ProductImageSortable[];