Compare commits

..

11 Commits

Author SHA1 Message Date
Gitea Actions
262396ddd0 ci: Bump version to 0.0.5 [skip ci] 2025-12-23 02:29:27 +05:00
c542796048 Merge branch 'main' of https://gitea.projectium.com/torbo/flyer-crawler.projectium.com
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Has been cancelled
2025-12-22 13:22:28 -08:00
5b8f309ad8 oom issue 2025-12-22 13:22:21 -08:00
Gitea Actions
6a73659f85 ci: Bump version to 0.0.4 [skip ci] 2025-12-22 23:28:44 +05:00
22513a967b minor test fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 1h56m3s
2025-12-22 10:21:50 -08:00
a10f84aa48 complete project using prettier! 2025-12-22 09:45:14 -08:00
Gitea Actions
621d30b84f ci: Bump version to 0.0.3 [skip ci] 2025-12-22 21:54:39 +05:00
ed857f588a more fixin tests
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Has been cancelled
2025-12-22 08:47:18 -08:00
Gitea Actions
fee55b0afd ci: Bump version to 0.0.2 [skip ci] 2025-12-22 12:46:13 +05:00
35538ea011 Merge branches 'main' and 'main' of https://gitea.projectium.com/torbo/flyer-crawler.projectium.com
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 1h34m43s
2025-12-21 23:39:50 -08:00
368b8e704c final ts cleanup? 2025-12-21 23:39:13 -08:00
353 changed files with 20350 additions and 10384 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "flyer-crawler", "name": "flyer-crawler",
"version": "0.0.1", "version": "0.0.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "flyer-crawler", "name": "flyer-crawler",
"version": "0.0.1", "version": "0.0.5",
"dependencies": { "dependencies": {
"@bull-board/api": "^6.14.2", "@bull-board/api": "^6.14.2",
"@bull-board/express": "^6.14.2", "@bull-board/express": "^6.14.2",

View File

@@ -1,7 +1,7 @@
{ {
"name": "flyer-crawler", "name": "flyer-crawler",
"private": true, "private": true,
"version": "0.0.1", "version": "0.0.5",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "concurrently \"npm:start:dev\" \"vite\"", "dev": "concurrently \"npm:start:dev\" \"vite\"",

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,12 @@ describe('AchievementsList', () => {
icon: 'chef-hat', icon: 'chef-hat',
points_value: 25, points_value: 25,
}), }),
createMockUserAchievement({ achievement_id: 2, name: 'List Maker', icon: 'list', points_value: 15 }), createMockUserAchievement({
achievement_id: 2,
name: 'List Maker',
icon: 'list',
points_value: 15,
}),
createMockUserAchievement({ achievement_id: 3, name: 'Unknown Achievement', icon: 'star' }), // This icon is not in the component's map createMockUserAchievement({ achievement_id: 3, name: 'Unknown Achievement', icon: 'star' }), // This icon is not in the component's map
]; ];
@@ -40,6 +45,8 @@ describe('AchievementsList', () => {
it('should render a message when there are no achievements', () => { it('should render a message when there are no achievements', () => {
render(<AchievementsList achievements={[]} />); render(<AchievementsList achievements={[]} />);
expect(screen.getByText('No achievements earned yet. Keep exploring to unlock them!')).toBeInTheDocument(); expect(
screen.getByText('No achievements earned yet. Keep exploring to unlock them!'),
).toBeInTheDocument();
}); });
}); });

View File

@@ -13,8 +13,8 @@ const Icon: React.FC<{ name: string | null | undefined }> = ({ name }) => {
const iconMap: { [key: string]: string } = { const iconMap: { [key: string]: string } = {
'chef-hat': '🧑‍🍳', 'chef-hat': '🧑‍🍳',
'share-2': '🤝', 'share-2': '🤝',
'list': '📋', list: '📋',
'heart': '❤️', heart: '❤️',
'git-fork': '🍴', 'git-fork': '🍴',
'piggy-bank': '🐷', 'piggy-bank': '🐷',
}; };
@@ -32,14 +32,19 @@ export const AchievementsList: React.FC<AchievementsListProps> = ({ achievements
{achievements.length > 0 ? ( {achievements.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{achievements.map((ach) => ( {achievements.map((ach) => (
<div key={ach.achievement_id} className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md flex items-start space-x-4"> <div
key={ach.achievement_id}
className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md flex items-start space-x-4"
>
<div className="shrink-0"> <div className="shrink-0">
<Icon name={ach.icon} /> <Icon name={ach.icon} />
</div> </div>
<div> <div>
<h3 className="font-bold">{ach.name}</h3> <h3 className="font-bold">{ach.name}</h3>
<p className="text-sm text-gray-600 dark:text-gray-300">{ach.description}</p> <p className="text-sm text-gray-600 dark:text-gray-300">{ach.description}</p>
<p className="text-xs text-yellow-500 font-semibold mt-1">+{ach.points_value} Points</p> <p className="text-xs text-yellow-500 font-semibold mt-1">
+{ach.points_value} Points
</p>
</div> </div>
</div> </div>
))} ))}

View File

@@ -22,7 +22,7 @@ const renderWithRouter = (profile: Profile | null, initialPath: string) => {
<Route index element={<AdminContent />} /> <Route index element={<AdminContent />} />
</Route> </Route>
</Routes> </Routes>
</MemoryRouter> </MemoryRouter>,
); );
}; };

View File

@@ -71,7 +71,7 @@ describe('ConfirmationModal (in components)', () => {
confirmButtonText="Yes, Delete" confirmButtonText="Yes, Delete"
cancelButtonText="No, Keep" cancelButtonText="No, Keep"
confirmButtonClass="bg-blue-500" confirmButtonClass="bg-blue-500"
/> />,
); );
const confirmButton = screen.getByRole('button', { name: 'Yes, Delete' }); const confirmButton = screen.getByRole('button', { name: 'Yes, Delete' });
expect(confirmButton).toBeInTheDocument(); expect(confirmButton).toBeInTheDocument();

View File

@@ -35,7 +35,7 @@ export const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
> >
<div <div
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md relative" className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md relative"
onClick={e => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<button <button
onClick={onClose} onClick={onClose}
@@ -47,10 +47,16 @@ export const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
<div className="p-6"> <div className="p-6">
<div className="sm:flex sm:items-start"> <div className="sm:flex sm:items-start">
<div className="mx-auto shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 dark:bg-red-900/30 sm:mx-0 sm:h-10 sm:w-10"> <div className="mx-auto shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 dark:bg-red-900/30 sm:mx-0 sm:h-10 sm:w-10">
<ExclamationTriangleIcon className="h-6 w-6 text-red-600 dark:text-red-400" aria-hidden="true" /> <ExclamationTriangleIcon
className="h-6 w-6 text-red-600 dark:text-red-400"
aria-hidden="true"
/>
</div> </div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> <div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg leading-6 font-medium text-gray-900 dark:text-white" id="modal-title"> <h3
className="text-lg leading-6 font-medium text-gray-900 dark:text-white"
id="modal-title"
>
{title} {title}
</h3> </h3>
<div className="mt-2"> <div className="mt-2">
@@ -60,8 +66,20 @@ export const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
</div> </div>
</div> </div>
<div className="bg-gray-50 dark:bg-gray-800/50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse rounded-b-lg"> <div className="bg-gray-50 dark:bg-gray-800/50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse rounded-b-lg">
<button type="button" className={`w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 sm:ml-3 sm:w-auto sm:text-sm ${confirmButtonClass}`} onClick={onConfirm}>{confirmButtonText}</button> <button
<button type="button" className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-500 shadow-sm px-4 py-2 bg-white dark:bg-gray-700 text-base font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:w-auto sm:text-sm" onClick={onClose}>{cancelButtonText}</button> type="button"
className={`w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 sm:ml-3 sm:w-auto sm:text-sm ${confirmButtonClass}`}
onClick={onConfirm}
>
{confirmButtonText}
</button>
<button
type="button"
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-500 shadow-sm px-4 py-2 bg-white dark:bg-gray-700 text-base font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:w-auto sm:text-sm"
onClick={onClose}
>
{cancelButtonText}
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -10,7 +10,11 @@ interface DarkModeToggleProps {
export const DarkModeToggle: React.FC<DarkModeToggleProps> = ({ isDarkMode, onToggle }) => { export const DarkModeToggle: React.FC<DarkModeToggleProps> = ({ isDarkMode, onToggle }) => {
return ( return (
<label htmlFor="dark-mode-toggle" className="flex items-center cursor-pointer" title={isDarkMode ? 'Switch to Light Mode' : 'Switch to Dark Mode'}> <label
htmlFor="dark-mode-toggle"
className="flex items-center cursor-pointer"
title={isDarkMode ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
>
<div className="relative"> <div className="relative">
<input <input
id="dark-mode-toggle" id="dark-mode-toggle"
@@ -20,8 +24,14 @@ export const DarkModeToggle: React.FC<DarkModeToggleProps> = ({ isDarkMode, onTo
onChange={onToggle} onChange={onToggle}
/> />
<div className="block bg-gray-200 dark:bg-gray-700 w-14 h-8 rounded-full transition-colors"></div> <div className="block bg-gray-200 dark:bg-gray-700 w-14 h-8 rounded-full transition-colors"></div>
<div className={`dot absolute left-1 top-1 bg-white dark:bg-gray-800 border-transparent w-6 h-6 rounded-full transition-transform duration-300 ease-in-out flex items-center justify-center ${isDarkMode ? 'transform translate-x-6' : ''}`}> <div
{isDarkMode ? <MoonIcon className="w-4 h-4 text-yellow-300" /> : <SunIcon className="w-4 h-4 text-yellow-500" />} className={`dot absolute left-1 top-1 bg-white dark:bg-gray-800 border-transparent w-6 h-6 rounded-full transition-transform duration-300 ease-in-out flex items-center justify-center ${isDarkMode ? 'transform translate-x-6' : ''}`}
>
{isDarkMode ? (
<MoonIcon className="w-4 h-4 text-yellow-300" />
) : (
<SunIcon className="w-4 h-4 text-yellow-500" />
)}
</div> </div>
</div> </div>
</label> </label>

View File

@@ -2,16 +2,18 @@
import React from 'react'; import React from 'react';
interface ErrorDisplayProps { interface ErrorDisplayProps {
message: string; message: string;
} }
export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({ message }) => { export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({ message }) => {
if (!message) return null; if (!message) return null;
return ( return (
<div className="bg-red-100 dark:bg-red-900/50 border border-red-400 dark:border-red-600 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg relative" role="alert"> <div
<strong className="font-bold">Error: </strong> className="bg-red-100 dark:bg-red-900/50 border border-red-400 dark:border-red-600 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg relative"
<span className="block sm:inline">{message}</span> role="alert"
</div> >
); <strong className="font-bold">Error: </strong>
<span className="block sm:inline">{message}</span>
</div>
);
}; };

View File

@@ -35,7 +35,7 @@ describe('FlyerCorrectionTool', () => {
// Mock global fetch for fetching the image blob inside the component // Mock global fetch for fetching the image blob inside the component
global.fetch = vi.fn(() => global.fetch = vi.fn(() =>
Promise.resolve(new Response(new Blob(['dummy-image-content'], { type: 'image/jpeg' }))) Promise.resolve(new Response(new Blob(['dummy-image-content'], { type: 'image/jpeg' }))),
) as Mocked<typeof fetch>; ) as Mocked<typeof fetch>;
// Mock canvas methods for jsdom environment // Mock canvas methods for jsdom environment
@@ -109,7 +109,7 @@ describe('FlyerCorrectionTool', () => {
// 1. Create a controllable promise for the mock. // 1. Create a controllable promise for the mock.
console.log('--- [TEST LOG] ---: 1. Setting up controllable promise for rescanImageArea.'); console.log('--- [TEST LOG] ---: 1. Setting up controllable promise for rescanImageArea.');
let resolveRescanPromise: (value: Response | PromiseLike<Response>) => void; let resolveRescanPromise: (value: Response | PromiseLike<Response>) => void;
const rescanPromise = new Promise<Response>(resolve => { const rescanPromise = new Promise<Response>((resolve) => {
resolveRescanPromise = resolve; resolveRescanPromise = resolve;
}); });
mockedAiApiClient.rescanImageArea.mockReturnValue(rescanPromise); mockedAiApiClient.rescanImageArea.mockReturnValue(rescanPromise);
@@ -162,7 +162,7 @@ describe('FlyerCorrectionTool', () => {
expect.any(File), expect.any(File),
// 10*2=20, 10*2=20, (60-10)*2=100, (30-10)*2=40 // 10*2=20, 10*2=20, (60-10)*2=100, (30-10)*2=40
{ x: 20, y: 20, width: 100, height: 40 }, { x: 20, y: 20, width: 100, height: 40 },
'store_name' 'store_name',
); );
}); });
console.log('--- [TEST LOG] ---: 4b. SUCCESS: API call verified.'); console.log('--- [TEST LOG] ---: 4b. SUCCESS: API call verified.');
@@ -178,7 +178,9 @@ describe('FlyerCorrectionTool', () => {
// 6. Assert the final state after the promise has resolved. // 6. Assert the final state after the promise has resolved.
console.log('--- [TEST LOG] ---: 6. Awaiting final state assertions...'); console.log('--- [TEST LOG] ---: 6. Awaiting final state assertions...');
await waitFor(() => { await waitFor(() => {
console.log('--- [TEST LOG] ---: 6a. waitFor check: Verifying notifications and callbacks...'); console.log(
'--- [TEST LOG] ---: 6a. waitFor check: Verifying notifications and callbacks...',
);
expect(mockedNotifySuccess).toHaveBeenCalledWith('Extracted: Super Store'); expect(mockedNotifySuccess).toHaveBeenCalledWith('Extracted: Super Store');
expect(defaultProps.onDataExtracted).toHaveBeenCalledWith('store_name', 'Super Store'); expect(defaultProps.onDataExtracted).toHaveBeenCalledWith('store_name', 'Super Store');
expect(defaultProps.onClose).toHaveBeenCalledTimes(1); expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
@@ -198,7 +200,9 @@ describe('FlyerCorrectionTool', () => {
}); });
it('should show an error if rescan is attempted before image is loaded', async () => { it('should show an error if rescan is attempted before image is loaded', async () => {
console.log('TEST: Starting "should show an error if rescan is attempted before image is loaded"'); console.log(
'TEST: Starting "should show an error if rescan is attempted before image is loaded"',
);
// Override fetch to be pending forever so 'imageFile' remains null // Override fetch to be pending forever so 'imageFile' remains null
// This allows us to test the guard clause inside handleRescan while the button is enabled // This allows us to test the guard clause inside handleRescan while the button is enabled
@@ -240,7 +244,7 @@ describe('FlyerCorrectionTool', () => {
await waitFor(() => expect(global.fetch).toHaveBeenCalled()); await waitFor(() => expect(global.fetch).toHaveBeenCalled());
// Allow the promise chain in useEffect to complete // Allow the promise chain in useEffect to complete
await act(async () => { await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
}); });
const canvas = screen.getByRole('dialog').querySelector('canvas')!; const canvas = screen.getByRole('dialog').querySelector('canvas')!;

View File

@@ -17,7 +17,12 @@ export interface FlyerCorrectionToolProps {
type Rect = { x: number; y: number; width: number; height: number }; type Rect = { x: number; y: number; width: number; height: number };
type ExtractionType = 'store_name' | 'dates'; type ExtractionType = 'store_name' | 'dates';
export const FlyerCorrectionTool: React.FC<FlyerCorrectionToolProps> = ({ isOpen, onClose, imageUrl, onDataExtracted }) => { export const FlyerCorrectionTool: React.FC<FlyerCorrectionToolProps> = ({
isOpen,
onClose,
imageUrl,
onDataExtracted,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const imageRef = useRef<HTMLImageElement>(null); const imageRef = useRef<HTMLImageElement>(null);
const [isDrawing, setIsDrawing] = useState(false); const [isDrawing, setIsDrawing] = useState(false);
@@ -31,13 +36,13 @@ export const FlyerCorrectionTool: React.FC<FlyerCorrectionToolProps> = ({ isOpen
if (isOpen && imageUrl) { if (isOpen && imageUrl) {
console.debug('[DEBUG] FlyerCorrectionTool: isOpen is true, fetching image URL:', imageUrl); console.debug('[DEBUG] FlyerCorrectionTool: isOpen is true, fetching image URL:', imageUrl);
fetch(imageUrl) fetch(imageUrl)
.then(res => res.blob()) .then((res) => res.blob())
.then(blob => { .then((blob) => {
const file = new File([blob], 'flyer-image.jpg', { type: blob.type }); const file = new File([blob], 'flyer-image.jpg', { type: blob.type });
setImageFile(file); setImageFile(file);
console.debug('[DEBUG] FlyerCorrectionTool: Image fetched and stored as File object.'); console.debug('[DEBUG] FlyerCorrectionTool: Image fetched and stored as File object.');
}) })
.catch(err => { .catch((err) => {
console.error('[DEBUG] FlyerCorrectionTool: Failed to fetch image.', { err }); console.error('[DEBUG] FlyerCorrectionTool: Failed to fetch image.', { err });
logger.error('Failed to fetch image for correction tool', { error: err }); logger.error('Failed to fetch image for correction tool', { error: err });
notifyError('Could not load the image for correction.'); notifyError('Could not load the image for correction.');
@@ -74,7 +79,9 @@ export const FlyerCorrectionTool: React.FC<FlyerCorrectionToolProps> = ({ isOpen
return () => window.removeEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize);
}, [draw]); }, [draw]);
const getCanvasCoordinates = (e: React.MouseEvent<HTMLCanvasElement>): { x: number; y: number } => { const getCanvasCoordinates = (
e: React.MouseEvent<HTMLCanvasElement>,
): { x: number; y: number } => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return { x: 0, y: 0 }; if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
@@ -110,7 +117,9 @@ export const FlyerCorrectionTool: React.FC<FlyerCorrectionToolProps> = ({ isOpen
const handleRescan = async (type: ExtractionType) => { const handleRescan = async (type: ExtractionType) => {
console.debug(`[DEBUG] handleRescan triggered for type: ${type}`); console.debug(`[DEBUG] handleRescan triggered for type: ${type}`);
console.debug(`[DEBUG] handleRescan state: selectionRect=${!!selectionRect}, imageRef=${!!imageRef.current}, imageFile=${!!imageFile}`); console.debug(
`[DEBUG] handleRescan state: selectionRect=${!!selectionRect}, imageRef=${!!imageRef.current}, imageFile=${!!imageFile}`,
);
if (!selectionRect || !imageRef.current || !imageFile) { if (!selectionRect || !imageRef.current || !imageFile) {
console.warn('[DEBUG] handleRescan: Guard failed. Missing prerequisites.'); console.warn('[DEBUG] handleRescan: Guard failed. Missing prerequisites.');
@@ -164,16 +173,40 @@ export const FlyerCorrectionTool: React.FC<FlyerCorrectionToolProps> = ({ isOpen
if (!isOpen) return null; if (!isOpen) return null;
console.debug('[DEBUG] FlyerCorrectionTool: Rendering with state:', { isProcessing, hasSelection: !!selectionRect }); console.debug('[DEBUG] FlyerCorrectionTool: Rendering with state:', {
isProcessing,
hasSelection: !!selectionRect,
});
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-75 z-50 flex justify-center items-center p-4" onClick={onClose}> <div
<div role="dialog" className="relative bg-gray-800 rounded-lg shadow-xl w-full max-w-6xl h-[90vh] flex flex-col" onClick={e => e.stopPropagation()}> className="fixed inset-0 bg-black bg-opacity-75 z-50 flex justify-center items-center p-4"
onClick={onClose}
>
<div
role="dialog"
className="relative bg-gray-800 rounded-lg shadow-xl w-full max-w-6xl h-[90vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between items-center p-4 border-b border-gray-700"> <div className="flex justify-between items-center p-4 border-b border-gray-700">
<h2 className="text-lg font-semibold text-white flex items-center"><ScissorsIcon className="w-6 h-6 mr-2" /> Flyer Correction Tool</h2> <h2 className="text-lg font-semibold text-white flex items-center">
<button onClick={onClose} className="text-gray-400 hover:text-white" aria-label="Close correction tool"><XCircleIcon className="w-7 h-7" /></button> <ScissorsIcon className="w-6 h-6 mr-2" /> Flyer Correction Tool
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-white"
aria-label="Close correction tool"
>
<XCircleIcon className="w-7 h-7" />
</button>
</div> </div>
<div className="grow p-4 overflow-auto relative flex justify-center items-center"> <div className="grow p-4 overflow-auto relative flex justify-center items-center">
<img ref={imageRef} src={imageUrl} alt="Flyer for correction" className="max-w-full max-h-full object-contain" onLoad={draw} /> <img
ref={imageRef}
src={imageUrl}
alt="Flyer for correction"
className="max-w-full max-h-full object-contain"
onLoad={draw}
/>
<canvas <canvas
ref={canvasRef} ref={canvasRef}
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 cursor-crosshair" className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 cursor-crosshair"

View File

@@ -38,7 +38,7 @@ const renderWithRouter = (props: Partial<React.ComponentProps<typeof Header>>) =
return render( return render(
<MemoryRouter> <MemoryRouter>
<Header {...defaultProps} {...props} /> <Header {...defaultProps} {...props} />
</MemoryRouter> </MemoryRouter>,
); );
}; };

View File

@@ -19,7 +19,15 @@ export interface HeaderProps {
onSignOut: () => void; onSignOut: () => void;
} }
export const Header: React.FC<HeaderProps> = ({ isDarkMode, unitSystem, authStatus, userProfile, onOpenProfile, onOpenVoiceAssistant, onSignOut }) => { export const Header: React.FC<HeaderProps> = ({
isDarkMode,
unitSystem,
authStatus,
userProfile,
onOpenProfile,
onOpenVoiceAssistant,
onSignOut,
}) => {
// The state and handlers for the old AuthModal and SignUpModal have been removed. // The state and handlers for the old AuthModal and SignUpModal have been removed.
return ( return (
<> <>
@@ -34,14 +42,14 @@ export const Header: React.FC<HeaderProps> = ({ isDarkMode, unitSystem, authStat
</div> </div>
<div className="flex items-center space-x-4 md:space-x-6"> <div className="flex items-center space-x-4 md:space-x-6">
{userProfile && ( {userProfile && (
<button <button
onClick={onOpenVoiceAssistant} onClick={onOpenVoiceAssistant}
className="p-1.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700/50 text-gray-500 dark:text-gray-400 transition-colors" className="p-1.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700/50 text-gray-500 dark:text-gray-400 transition-colors"
aria-label="Open voice assistant" aria-label="Open voice assistant"
title="Voice Assistant" title="Voice Assistant"
> >
<MicrophoneIcon className="w-5 h-5" /> <MicrophoneIcon className="w-5 h-5" />
</button> </button>
)} )}
{/* The toggles have been removed. The display of the current state is now shown textually. */} {/* The toggles have been removed. The display of the current state is now shown textually. */}
<div className="hidden sm:flex items-center space-x-4 text-sm text-gray-500 dark:text-gray-400"> <div className="hidden sm:flex items-center space-x-4 text-sm text-gray-500 dark:text-gray-400">
@@ -54,13 +62,17 @@ export const Header: React.FC<HeaderProps> = ({ isDarkMode, unitSystem, authStat
{userProfile ? ( // This ternary was missing a 'null' or alternative rendering path for when 'user' is not present. {userProfile ? ( // This ternary was missing a 'null' or alternative rendering path for when 'user' is not present.
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="hidden md:flex items-center space-x-2 text-sm"> <div className="hidden md:flex items-center space-x-2 text-sm">
<UserIcon className="w-5 h-5 text-gray-500 dark:text-gray-400" /> <UserIcon className="w-5 h-5 text-gray-500 dark:text-gray-400" />
{authStatus === 'AUTHENTICATED' ? ( {authStatus === 'AUTHENTICATED' ? (
// Use the user object from the new auth system // Use the user object from the new auth system
<span className="font-medium text-gray-700 dark:text-gray-300">{userProfile.user.email}</span> <span className="font-medium text-gray-700 dark:text-gray-300">
) : ( {userProfile.user.email}
<span className="font-medium text-gray-500 dark:text-gray-400 italic">Guest</span> </span>
)} ) : (
<span className="font-medium text-gray-500 dark:text-gray-400 italic">
Guest
</span>
)}
</div> </div>
<button <button
onClick={onOpenProfile} onClick={onOpenProfile}
@@ -71,7 +83,11 @@ export const Header: React.FC<HeaderProps> = ({ isDarkMode, unitSystem, authStat
<Cog8ToothIcon className="w-5 h-5" /> <Cog8ToothIcon className="w-5 h-5" />
</button> </button>
{userProfile?.role === 'admin' && ( {userProfile?.role === 'admin' && (
<Link to="/admin" className="p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700/50 text-gray-500 dark:text-gray-400 transition-colors" title="Admin Area"> <Link
to="/admin"
className="p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700/50 text-gray-500 dark:text-gray-400 transition-colors"
title="Admin Area"
>
<ShieldCheckIcon className="w-5 h-5" /> <ShieldCheckIcon className="w-5 h-5" />
</Link> </Link>
)} )}

View File

@@ -26,7 +26,13 @@ vi.mock('lucide-react', () => ({
const mockLeaderboardData: LeaderboardUser[] = [ const mockLeaderboardData: LeaderboardUser[] = [
createMockLeaderboardUser({ user_id: 'user-1', full_name: 'Alice', points: 1000, rank: '1' }), createMockLeaderboardUser({ user_id: 'user-1', full_name: 'Alice', points: 1000, rank: '1' }),
createMockLeaderboardUser({ user_id: 'user-2', full_name: 'Bob', avatar_url: 'http://example.com/bob.jpg', points: 950, rank: '2' }), createMockLeaderboardUser({
user_id: 'user-2',
full_name: 'Bob',
avatar_url: 'http://example.com/bob.jpg',
points: 950,
rank: '2',
}),
createMockLeaderboardUser({ user_id: 'user-3', full_name: 'Charlie', points: 900, rank: '3' }), createMockLeaderboardUser({ user_id: 'user-3', full_name: 'Charlie', points: 900, rank: '3' }),
createMockLeaderboardUser({ user_id: 'user-4', full_name: 'Diana', points: 850, rank: '4' }), createMockLeaderboardUser({ user_id: 'user-4', full_name: 'Diana', points: 850, rank: '4' }),
]; ];
@@ -69,12 +75,16 @@ describe('Leaderboard', () => {
render(<Leaderboard />); render(<Leaderboard />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('The leaderboard is currently empty. Be the first to earn points!')).toBeInTheDocument(); expect(
screen.getByText('The leaderboard is currently empty. Be the first to earn points!'),
).toBeInTheDocument();
}); });
}); });
it('should render the leaderboard with user data on successful fetch', async () => { it('should render the leaderboard with user data on successful fetch', async () => {
mockedApiClient.fetchLeaderboard.mockResolvedValue(new Response(JSON.stringify(mockLeaderboardData))); mockedApiClient.fetchLeaderboard.mockResolvedValue(
new Response(JSON.stringify(mockLeaderboardData)),
);
render(<Leaderboard />); render(<Leaderboard />);
await waitFor(() => { await waitFor(() => {
@@ -97,7 +107,9 @@ describe('Leaderboard', () => {
}); });
it('should render the correct rank icons', async () => { it('should render the correct rank icons', async () => {
mockedApiClient.fetchLeaderboard.mockResolvedValue(new Response(JSON.stringify(mockLeaderboardData))); mockedApiClient.fetchLeaderboard.mockResolvedValue(
new Response(JSON.stringify(mockLeaderboardData)),
);
render(<Leaderboard />); render(<Leaderboard />);
await waitFor(() => { await waitFor(() => {
@@ -114,7 +126,9 @@ describe('Leaderboard', () => {
const dataWithMissingNames: LeaderboardUser[] = [ const dataWithMissingNames: LeaderboardUser[] = [
createMockLeaderboardUser({ user_id: 'user-anon', full_name: null, points: 500, rank: '5' }), createMockLeaderboardUser({ user_id: 'user-anon', full_name: null, points: 500, rank: '5' }),
]; ];
mockedApiClient.fetchLeaderboard.mockResolvedValue(new Response(JSON.stringify(dataWithMissingNames))); mockedApiClient.fetchLeaderboard.mockResolvedValue(
new Response(JSON.stringify(dataWithMissingNames)),
);
render(<Leaderboard />); render(<Leaderboard />);
await waitFor(() => { await waitFor(() => {

View File

@@ -51,7 +51,10 @@ export const Leaderboard: React.FC = () => {
if (error) { if (error) {
return ( return (
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 rounded-md" role="alert"> <div
className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 rounded-md"
role="alert"
>
<div className="flex items-center"> <div className="flex items-center">
<ShieldAlert className="h-6 w-6 mr-3" /> <ShieldAlert className="h-6 w-6 mr-3" />
<p className="font-bold">Error: {error}</p> <p className="font-bold">Error: {error}</p>
@@ -67,21 +70,29 @@ export const Leaderboard: React.FC = () => {
Top Users Top Users
</h2> </h2>
{leaderboard.length === 0 ? ( {leaderboard.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400">The leaderboard is currently empty. Be the first to earn points!</p> <p className="text-gray-500 dark:text-gray-400">
The leaderboard is currently empty. Be the first to earn points!
</p>
) : ( ) : (
<ol className="space-y-4"> <ol className="space-y-4">
{leaderboard.map((user) => ( {leaderboard.map((user) => (
<li key={user.user_id} className="flex items-center space-x-4 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg transition hover:bg-gray-100 dark:hover:bg-gray-600"> <li
<div className="shrink-0 w-8 text-center"> key={user.user_id}
{getRankIcon(user.rank)} className="flex items-center space-x-4 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg transition hover:bg-gray-100 dark:hover:bg-gray-600"
</div> >
<div className="shrink-0 w-8 text-center">{getRankIcon(user.rank)}</div>
<img <img
src={user.avatar_url || `https://api.dicebear.com/8.x/initials/svg?seed=${user.full_name || user.user_id}`} src={
user.avatar_url ||
`https://api.dicebear.com/8.x/initials/svg?seed=${user.full_name || user.user_id}`
}
alt={user.full_name || 'User Avatar'} alt={user.full_name || 'User Avatar'}
className="w-12 h-12 rounded-full object-cover" className="w-12 h-12 rounded-full object-cover"
/> />
<div className="flex-1"> <div className="flex-1">
<p className="font-semibold text-gray-800 dark:text-gray-100">{user.full_name || 'Anonymous User'}</p> <p className="font-semibold text-gray-800 dark:text-gray-100">
{user.full_name || 'Anonymous User'}
</p>
</div> </div>
<div className="text-lg font-bold text-blue-600 dark:text-blue-400"> <div className="text-lg font-bold text-blue-600 dark:text-blue-400">
{user.points} pts {user.points} pts

View File

@@ -2,8 +2,24 @@
import React from 'react'; import React from 'react';
export const LoadingSpinner: React.FC = () => ( export const LoadingSpinner: React.FC = () => (
<svg className="animate-spin h-full w-full text-current" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <svg
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> className="animate-spin h-full w-full text-current"
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg> </svg>
); );

View File

@@ -20,14 +20,14 @@ vi.mock('../config', () => ({
version: 'test', version: 'test',
commitMessage: 'test', commitMessage: 'test',
commitUrl: 'test', commitUrl: 'test',
} },
}, },
})); }));
describe('MapView', () => { describe('MapView', () => {
const defaultProps = { const defaultProps = {
latitude: 40.7128, latitude: 40.7128,
longitude: -74.0060, longitude: -74.006,
}; };
beforeEach(() => { beforeEach(() => {
@@ -41,7 +41,9 @@ describe('MapView', () => {
describe('when API key is not configured', () => { describe('when API key is not configured', () => {
it('should render a disabled message', () => { it('should render a disabled message', () => {
render(<MapView {...defaultProps} />); render(<MapView {...defaultProps} />);
expect(screen.getByText('Map view is disabled: API key is not configured.')).toBeInTheDocument(); expect(
screen.getByText('Map view is disabled: API key is not configured.'),
).toBeInTheDocument();
}); });
it('should not render the iframe', () => { it('should not render the iframe', () => {

View File

@@ -12,7 +12,9 @@ export const MapView: React.FC<MapViewProps> = ({ latitude, longitude }) => {
const apiKey = config.google.mapsEmbedApiKey; const apiKey = config.google.mapsEmbedApiKey;
if (!apiKey) { if (!apiKey) {
return <div className="text-sm text-red-500">Map view is disabled: API key is not configured.</div>; return (
<div className="text-sm text-red-500">Map view is disabled: API key is not configured.</div>
);
} }
const mapSrc = `https://www.google.com/maps/embed/v1/view?key=${apiKey}&center=${latitude},${longitude}&zoom=14`; const mapSrc = `https://www.google.com/maps/embed/v1/view?key=${apiKey}&center=${latitude},${longitude}&zoom=14`;

View File

@@ -11,10 +11,15 @@ export const UnitSystemToggle: React.FC<UnitSystemToggleProps> = ({ currentSyste
return ( return (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span className={`text-sm font-medium ${isImperial ? 'text-gray-400 dark:text-gray-500' : 'text-gray-700 dark:text-gray-200'}`}> <span
className={`text-sm font-medium ${isImperial ? 'text-gray-400 dark:text-gray-500' : 'text-gray-700 dark:text-gray-200'}`}
>
Metric Metric
</span> </span>
<label htmlFor="unit-system-toggle" className="relative inline-flex items-center cursor-pointer"> <label
htmlFor="unit-system-toggle"
className="relative inline-flex items-center cursor-pointer"
>
<input <input
type="checkbox" type="checkbox"
id="unit-system-toggle" id="unit-system-toggle"
@@ -24,7 +29,9 @@ export const UnitSystemToggle: React.FC<UnitSystemToggleProps> = ({ currentSyste
/> />
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-brand-primary/50 dark:peer-focus:ring-brand-secondary rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5] after:left-0.52px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-brand-primary"></div> <div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-brand-primary/50 dark:peer-focus:ring-brand-secondary rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5] after:left-0.52px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-brand-primary"></div>
</label> </label>
<span className={`text-sm font-medium ${isImperial ? 'text-gray-700 dark:text-gray-200' : 'text-gray-400 dark:text-gray-500'}`}> <span
className={`text-sm font-medium ${isImperial ? 'text-gray-700 dark:text-gray-200' : 'text-gray-400 dark:text-gray-500'}`}
>
Imperial Imperial
</span> </span>
</div> </div>

View File

@@ -22,11 +22,15 @@ describe('UserMenuSkeleton', () => {
it('should render a rectangular placeholder with correct styles', () => { it('should render a rectangular placeholder with correct styles', () => {
const { container } = render(<UserMenuSkeleton />); const { container } = render(<UserMenuSkeleton />);
expect(container.querySelector('.rounded-md')).toHaveClass('h-8 w-24 bg-gray-200 dark:bg-gray-700'); expect(container.querySelector('.rounded-md')).toHaveClass(
'h-8 w-24 bg-gray-200 dark:bg-gray-700',
);
}); });
it('should render a circular placeholder with correct styles', () => { it('should render a circular placeholder with correct styles', () => {
const { container } = render(<UserMenuSkeleton />); const { container } = render(<UserMenuSkeleton />);
expect(container.querySelector('.rounded-full')).toHaveClass('h-10 w-10 bg-gray-200 dark:bg-gray-700'); expect(container.querySelector('.rounded-full')).toHaveClass(
'h-10 w-10 bg-gray-200 dark:bg-gray-700',
);
}); });
}); });

View File

@@ -10,11 +10,19 @@ export interface WhatsNewModalProps {
commitMessage: string; commitMessage: string;
} }
export const WhatsNewModal: React.FC<WhatsNewModalProps> = ({ isOpen, onClose, version, commitMessage }) => { export const WhatsNewModal: React.FC<WhatsNewModalProps> = ({
isOpen,
onClose,
version,
commitMessage,
}) => {
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-60 z-50 flex justify-center items-center p-4" onClick={onClose}> <div
className="fixed inset-0 bg-black bg-opacity-60 z-50 flex justify-center items-center p-4"
onClick={onClose}
>
<div <div
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
@@ -28,13 +36,17 @@ export const WhatsNewModal: React.FC<WhatsNewModalProps> = ({ isOpen, onClose, v
<GiftIcon className="w-6 h-6 text-brand-primary" /> <GiftIcon className="w-6 h-6 text-brand-primary" />
</div> </div>
<div> <div>
<h2 id="whats-new-title" className="text-xl font-bold text-gray-900 dark:text-white">What's New?</h2> <h2 id="whats-new-title" className="text-xl font-bold text-gray-900 dark:text-white">
What's New?
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400">Version: {version}</p> <p className="text-xs text-gray-500 dark:text-gray-400">Version: {version}</p>
</div> </div>
</div> </div>
<div className="bg-gray-100 dark:bg-gray-700 p-4 rounded-lg"> <div className="bg-gray-100 dark:bg-gray-700 p-4 rounded-lg">
<p className="text-base font-medium text-gray-800 dark:text-gray-200">{commitMessage}</p> <p className="text-base font-medium text-gray-800 dark:text-gray-200">
{commitMessage}
</p>
</div> </div>
<div className="mt-6 flex justify-end"> <div className="mt-6 flex justify-end">

View File

@@ -1,14 +1,19 @@
// src/components/icons/ArrowPathIcon.tsx
import React from 'react'; import React from 'react';
export const ArrowPathIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const ArrowPathIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
strokeWidth={1.5} strokeWidth={1.5}
stroke="currentColor" stroke="currentColor"
{...props} {...props}
> >
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 11.667 0l3.181-3.183m-4.991-2.691V5.25a2.25 2.25 0 0 0-2.25-2.25h-6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25h6.75a2.25 2.25 0 0 0 2.25-2.25Z" /> <path
</svg> strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 11.667 0l3.181-3.183m-4.991-2.691V5.25a2.25 2.25 0 0 0-2.25-2.25h-6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25h6.75a2.25 2.25 0 0 0 2.25-2.25Z"
/>
</svg>
); );

View File

@@ -1,7 +1,18 @@
import React from 'react'; import React from 'react';
export const BeakerIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const BeakerIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.038-.502.097-.752.172m.752-.172a24.283 24.283 0 0 0-4.085 1.572M9.75 3.104a24.283 24.283 0 0 1 4.085 1.572m-4.085-1.572c.251.038.502.097.752.172m0 0v5.714a2.25 2.25 0 0 0 .659 1.591L19 14.5M9.75 9.104a2.25 2.25 0 0 0-1.5 2.086v.208a2.25 2.25 0 0 0 1.5 2.086m-1.5-4.172c-.251.038-.502.097-.752.172m.752-.172a24.283 24.283 0 0 0-4.085 1.572M5.25 14.5c.251.038.502.097.752.172m-1.5-2.086a2.25 2.25 0 0 1 1.5-2.086m-1.5 2.086v.208a2.25 2.25 0 0 1-1.5 2.086M5.25 14.5a24.283 24.283 0 0 1-4.085-1.572M18.75 14.5c-.251.038-.502.097-.752.172m1.5-2.086a2.25 2.25 0 0 0-1.5-2.086m1.5 2.086v.208a2.25 2.25 0 0 0 1.5 2.086M18.75 14.5a24.283 24.283 0 0 0 4.085-1.572M5.25 14.5L9 18.25m9.75-3.75L15 18.25m-1.5-3.75v3.75m0 0a2.25 2.25 0 0 1-4.5 0m4.5 0a2.25 2.25 0 0 0-4.5 0m4.5 0v3.75m-4.5-3.75v3.75" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.038-.502.097-.752.172m.752-.172a24.283 24.283 0 0 0-4.085 1.572M9.75 3.104a24.283 24.283 0 0 1 4.085 1.572m-4.085-1.572c.251.038.502.097.752.172m0 0v5.714a2.25 2.25 0 0 0 .659 1.591L19 14.5M9.75 9.104a2.25 2.25 0 0 0-1.5 2.086v.208a2.25 2.25 0 0 0 1.5 2.086m-1.5-4.172c-.251.038-.502.097-.752.172m.752-.172a24.283 24.283 0 0 0-4.085 1.572M5.25 14.5c.251.038.502.097.752.172m-1.5-2.086a2.25 2.25 0 0 1 1.5-2.086m-1.5 2.086v.208a2.25 2.25 0 0 1-1.5 2.086M5.25 14.5a24.283 24.283 0 0 1-4.085-1.572M18.75 14.5c-.251.038-.502.097-.752.172m1.5-2.086a2.25 2.25 0 0 0-1.5-2.086m1.5 2.086v.208a2.25 2.25 0 0 0 1.5 2.086M18.75 14.5a24.283 24.283 0 0 0 4.085-1.572M5.25 14.5L9 18.25m9.75-3.75L15 18.25m-1.5-3.75v3.75m0 0a2.25 2.25 0 0 1-4.5 0m4.5 0a2.25 2.25 0 0 0-4.5 0m4.5 0v3.75m-4.5-3.75v3.75"
/>
</svg> </svg>
); );

View File

@@ -1,14 +1,18 @@
import React from 'react'; import React from 'react';
export const BellAlertIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const BellAlertIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
strokeWidth={1.5} strokeWidth={1.5}
stroke="currentColor" stroke="currentColor"
{...props} {...props}
> >
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0M3.124 7.5A8.969 8.969 0 015.292 3m13.416 0a8.969 8.969 0 012.168 4.5" /> <path
</svg> strokeLinecap="round"
strokeLinejoin="round"
d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0M3.124 7.5A8.969 8.969 0 015.292 3m13.416 0a8.969 8.969 0 012.168 4.5"
/>
</svg>
); );

View File

@@ -1,9 +1,18 @@
import React from 'react'; import React from 'react';
export const BrainIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const BrainIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L21.75 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L21.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09L15.75 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L9 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L18.25 12Z" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L21.75 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L21.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09L15.75 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L9 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L18.25 12Z"
/>
</svg> </svg>
); );

View File

@@ -1,14 +1,18 @@
import React from 'react'; import React from 'react';
export const BuildingStorefrontIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const BuildingStorefrontIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
strokeWidth={1.5} strokeWidth={1.5}
stroke="currentColor" stroke="currentColor"
{...props} {...props}
> >
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 21v-7.5A2.25 2.25 0 0115.75 11.25h.5a2.25 2.25 0 012.25 2.25V21M3 16.5v-7.5A2.25 2.25 0 015.25 6.75h13.5A2.25 2.25 0 0121 9v7.5M3 16.5h18M3 16.5v4.5A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75v-4.5M16.5 4.5a3 3 0 11-6 0 3 3 0 016 0z" /> <path
</svg> strokeLinecap="round"
strokeLinejoin="round"
d="M13.5 21v-7.5A2.25 2.25 0 0115.75 11.25h.5a2.25 2.25 0 012.25 2.25V21M3 16.5v-7.5A2.25 2.25 0 015.25 6.75h13.5A2.25 2.25 0 0121 9v7.5M3 16.5h18M3 16.5v4.5A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75v-4.5M16.5 4.5a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
); );

View File

@@ -1,14 +1,18 @@
import React from 'react'; import React from 'react';
export const ChartBarIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const ChartBarIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
strokeWidth={1.5} strokeWidth={1.5}
stroke="currentColor" stroke="currentColor"
{...props} {...props}
> >
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" /> <path
</svg> strokeLinecap="round"
strokeLinejoin="round"
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
/>
</svg>
); );

View File

@@ -5,12 +5,19 @@ interface CheckCircleIconProps extends React.SVGProps<SVGSVGElement> {
} }
export const CheckCircleIcon: React.FC<CheckCircleIconProps> = ({ title, ...props }) => ( export const CheckCircleIcon: React.FC<CheckCircleIconProps> = ({ title, ...props }) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
{title && <title>{title}</title>} xmlns="http://www.w3.org/2000/svg"
<path fill="none"
strokeLinecap="round" viewBox="0 0 24 24"
strokeLinejoin="round" strokeWidth={1.5}
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" stroke="currentColor"
/> {...props}
</svg> >
{title && <title>{title}</title>}
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
); );

View File

@@ -1,5 +1,14 @@
import React from 'react'; import React from 'react';
export const CheckIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const CheckIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}><path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" /></svg> <svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
); );

View File

@@ -1,5 +1,19 @@
import React from 'react'; import React from 'react';
export const Cog8ToothIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const Cog8ToothIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}><path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.24-.438.613-.43.992a6.759 6.759 0 0 1 0 1.905c-.008.379.137.752.43.992l1.003.827c.424.35.534.954.26 1.431l-1.296 2.247a1.125 1.125 0 0 1-1.37.49l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.127c-.331.183-.581.495-.644.87l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.063-.374-.313-.686-.645-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.075-.124l-1.217.456a1.125 1.125 0 0 1-1.37-.49l-1.296-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.759 6.759 0 0 1 0-1.905c.008-.379-.137-.752-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.431l1.296-2.247a1.125 1.125 0 0 1 1.37-.49l1.217.456c.355.133.75.072 1.076-.124.072-.044.146-.087.22-.127.332-.183.582-.495.644-.87l.213-1.281Z" /><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg> <svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.24-.438.613-.43.992a6.759 6.759 0 0 1 0 1.905c-.008.379.137.752.43.992l1.003.827c.424.35.534.954.26 1.431l-1.296 2.247a1.125 1.125 0 0 1-1.37.49l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.127c-.331.183-.581.495-.644.87l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.063-.374-.313-.686-.645-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.075-.124l-1.217.456a1.125 1.125 0 0 1-1.37-.49l-1.296-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.759 6.759 0 0 1 0-1.905c.008-.379-.137-.752-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.431l1.296-2.247a1.125 1.125 0 0 1 1.37-.49l1.217.456c.355.133.75.072 1.076-.124.072-.044.146-.087.22-.127.332-.183.582-.495.644-.87l.213-1.281Z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
); );

View File

@@ -1,7 +1,18 @@
import React from 'react'; import React from 'react';
export const CogIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const CogIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-1.008 1.11-1.212l.978-.488c.55-.274 1.192.164 1.192.793v.498c0 .266.105.52.293.708l.38.38c.37.37.884.586 1.414.586h.498c.63 0 1.067.641.793 1.192l-.488.978c-.204.449-.67.92-1.212 1.11l-.978.488c-.55.274-1.192-.164-1.192-.793v-.498c0-.266-.105-.52-.293-.708l-.38-.38c-.37-.37-.884-.586-1.414-.586h-.498c-.63 0-1.067-.641-.793-1.192l.488-.978Zm7.406 16.12c.09.542.56 1.008 1.11 1.212l.978.488c.55.274 1.192-.164 1.192-.793v-.498c0-.266-.105-.52-.293-.708l-.38-.38c-.37-.37-.884-.586-1.414-.586h-.498c-.63 0-1.067.641-.793-1.192l.488-.978c.204-.449.67-.92 1.212-1.11l.978-.488c.55-.274 1.192.164 1.192.793v.498c0 .266.105.52.293.708l.38.38c.37.37.884.586 1.414.586h.498c.63 0 1.067-.641.793-1.192l-.488-.978c-.204-.449-.67-.92-1.212-1.11l-.978-.488c-.55-.274-1.192.164-1.192.793v-.498c0-.266-.105-.52-.293-.708l-.38-.38c-.37-.37-.884-.586-1.414-.586h-.498c-.63 0-1.067.641-.793-1.192l.488-.978ZM12 8.25a3.75 3.75 0 1 0 0 7.5 3.75 3.75 0 0 0 0-7.5Z" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.594 3.94c.09-.542.56-1.008 1.11-1.212l.978-.488c.55-.274 1.192.164 1.192.793v.498c0 .266.105.52.293.708l.38.38c.37.37.884.586 1.414.586h.498c.63 0 1.067.641.793 1.192l-.488.978c-.204.449-.67.92-1.212 1.11l-.978.488c-.55.274-1.192-.164-1.192-.793v-.498c0-.266-.105-.52-.293-.708l-.38-.38c-.37-.37-.884-.586-1.414-.586h-.498c-.63 0-1.067-.641-.793-1.192l.488-.978Zm7.406 16.12c.09.542.56 1.008 1.11 1.212l.978.488c.55.274 1.192-.164 1.192-.793v-.498c0-.266-.105-.52-.293-.708l-.38-.38c-.37-.37-.884-.586-1.414-.586h-.498c-.63 0-1.067.641-.793-1.192l.488-.978c.204-.449.67-.92 1.212-1.11l.978-.488c.55-.274 1.192.164 1.192.793v.498c0 .266.105.52.293.708l.38.38c.37.37.884.586 1.414.586h.498c.63 0 1.067-.641.793-1.192l-.488-.978c-.204-.449-.67-.92-1.212-1.11l-.978-.488c-.55-.274-1.192.164-1.192.793v-.498c0-.266-.105-.52-.293-.708l-.38-.38c-.37-.37-.884-.586-1.414-.586h-.498c-.63 0-1.067.641-.793-1.192l.488-.978ZM12 8.25a3.75 3.75 0 1 0 0 7.5 3.75 3.75 0 0 0 0-7.5Z"
/>
</svg> </svg>
); );

View File

@@ -1,8 +1,18 @@
import React from 'react'; import React from 'react';
export const DatabaseIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const DatabaseIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 15.353 16.556 17.25 12 17.25s-8.25-1.897-8.25-4.125V10.125" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 15.353 16.556 17.25 12 17.25s-8.25-1.897-8.25-4.125V10.125"
/>
</svg> </svg>
); );

View File

@@ -1,14 +1,18 @@
import React from 'react'; import React from 'react';
export const DocumentDuplicateIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const DocumentDuplicateIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
strokeWidth={1.5} strokeWidth={1.5}
stroke="currentColor" stroke="currentColor"
{...props} {...props}
> >
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75" /> <path
</svg> strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75"
/>
</svg>
); );

View File

@@ -1,7 +1,18 @@
import React from 'react'; import React from 'react';
export const DocumentTextIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const DocumentTextIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
/>
</svg> </svg>
); );

View File

@@ -1,14 +1,18 @@
import React from 'react'; import React from 'react';
export const ExclamationTriangleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const ExclamationTriangleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
strokeWidth={1.5} strokeWidth={1.5}
stroke="currentColor" stroke="currentColor"
{...props} {...props}
> >
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" /> <path
</svg> strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
); );

View File

@@ -1,8 +1,19 @@
import React from 'react'; import React from 'react';
export const EyeIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const EyeIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639l4.443-5.332a1.012 1.012 0 011.536 0l4.443 5.332a1.012 1.012 0 010 .639l-4.443 5.332a1.012 1.012 0 01-1.536 0l-4.443-5.332z" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.036 12.322a1.012 1.012 0 010-.639l4.443-5.332a1.012 1.012 0 011.536 0l4.443 5.332a1.012 1.012 0 010 .639l-4.443 5.332a1.012 1.012 0 01-1.536 0l-4.443-5.332z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg> </svg>
); );

View File

@@ -1,8 +1,19 @@
import React from 'react'; import React from 'react';
export const EyeSlashIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const EyeSlashIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.524M2.036 12.322a1.012 1.012 0 010-.639l4.443-5.332a1.012 1.012 0 011.536 0l4.443 5.332a1.012 1.012 0 010 .639l-4.443 5.332a1.012 1.012 0 01-1.536 0l-4.443-5.332z" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.524M2.036 12.322a1.012 1.012 0 010-.639l4.443-5.332a1.012 1.012 0 011.536 0l4.443 5.332a1.012 1.012 0 010 .639l-4.443 5.332a1.012 1.012 0 01-1.536 0l-4.443-5.332z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg> </svg>
); );

View File

@@ -1,7 +1,18 @@
import React from 'react'; import React from 'react';
export const GiftIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const GiftIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H7.5a1.5 1.5 0 01-1.5-1.5v-8.25M12 1.5v10.5m0 0l3-3m-3 3l-3-3m3 3V3.75M21 11.25H3" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H7.5a1.5 1.5 0 01-1.5-1.5v-8.25M12 1.5v10.5m0 0l3-3m-3 3l-3-3m3 3V3.75M21 11.25H3"
/>
</svg> </svg>
); );

View File

@@ -2,6 +2,10 @@ import React from 'react';
export const GithubIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const GithubIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg viewBox="0 0 24 24" fill="currentColor" {...props}> <svg viewBox="0 0 24 24" fill="currentColor" {...props}>
<path fillRule="evenodd" d="M12 2C6.477 2 2 6.477 2 12c0 4.418 2.865 8.168 6.839 9.492.5.092.682-.217.682-.482 0-.237-.009-.868-.014-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.031-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.03 1.595 1.03 2.688 0 3.848-2.338 4.695-4.566 4.942.359.308.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.001 10.001 0 0022 12c0-5.523-4.477-10-10-10z" clipRule="evenodd" /> <path
fillRule="evenodd"
d="M12 2C6.477 2 2 6.477 2 12c0 4.418 2.865 8.168 6.839 9.492.5.092.682-.217.682-.482 0-.237-.009-.868-.014-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.031-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.03 1.595 1.03 2.688 0 3.848-2.338 4.695-4.566 4.942.359.308.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.001 10.001 0 0022 12c0-5.523-4.477-10-10-10z"
clipRule="evenodd"
/>
</svg> </svg>
); );

View File

@@ -2,9 +2,21 @@ import React from 'react';
export const GoogleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const GoogleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg viewBox="0 0 48 48" {...props}> <svg viewBox="0 0 48 48" {...props}>
<path fill="#FFC107" d="M43.611 20.083H42V20H24v8h11.303c-1.649 4.657-6.08 8-11.303 8c-6.627 0-12-5.373-12-12s5.373-12 12-12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4C12.955 4 4 12.955 4 24s8.955 20 20 20s20-8.955 20-20c0-1.341-.138-2.65-.389-3.917z"></path> <path
<path fill="#FF3D00" d="M6.306 14.691l6.571 4.819C14.655 15.108 18.961 12 24 12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4C16.318 4 9.656 8.337 6.306 14.691z"></path> fill="#FFC107"
<path fill="#4CAF50" d="M24 44c5.166 0 9.86-1.977 13.409-5.192l-6.19-5.238A11.91 11.91 0 0 1 24 36c-5.202 0-9.619-3.317-11.283-7.946l-6.522 5.025C9.505 39.556 16.227 44 24 44z"></path> d="M43.611 20.083H42V20H24v8h11.303c-1.649 4.657-6.08 8-11.303 8c-6.627 0-12-5.373-12-12s5.373-12 12-12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4C12.955 4 4 12.955 4 24s8.955 20 20 20s20-8.955 20-20c0-1.341-.138-2.65-.389-3.917z"
<path fill="#1976D2" d="M43.611 20.083H42V20H24v8h11.303c-.792 2.237-2.231 4.166-4.087 5.571l6.19 5.238C42.011 35.638 44 30.138 44 24c0-1.341-.138-2.65-.389-3.917z"></path> ></path>
<path
fill="#FF3D00"
d="M6.306 14.691l6.571 4.819C14.655 15.108 18.961 12 24 12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4C16.318 4 9.656 8.337 6.306 14.691z"
></path>
<path
fill="#4CAF50"
d="M24 44c5.166 0 9.86-1.977 13.409-5.192l-6.19-5.238A11.91 11.91 0 0 1 24 36c-5.202 0-9.619-3.317-11.283-7.946l-6.522 5.025C9.505 39.556 16.227 44 24 44z"
></path>
<path
fill="#1976D2"
d="M43.611 20.083H42V20H24v8h11.303c-.792 2.237-2.231 4.166-4.087 5.571l6.19 5.238C42.011 35.638 44 30.138 44 24c0-1.341-.138-2.65-.389-3.917z"
></path>
</svg> </svg>
); );

View File

@@ -1,7 +1,18 @@
import React from 'react'; import React from 'react';
export const InformationCircleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const InformationCircleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z"
/>
</svg> </svg>
); );

View File

@@ -1,9 +1,18 @@
import React from 'react'; import React from 'react';
export const LightbulbIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const LightbulbIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 0 0 1.5-1.555c1.424-1.423 2.1-3.393 1.83-5.252A7.488 7.488 0 0 0 12 3a7.488 7.488 0 0 0-5.33 2.143c-.27 2.03.506 3.99 1.83 5.252a6.01 6.01 0 0 0 1.5 1.555Zm-1.5 3.75a.75.75 0 0 0 3 0" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 18v-5.25m0 0a6.01 6.01 0 0 0 1.5-1.555c1.424-1.423 2.1-3.393 1.83-5.252A7.488 7.488 0 0 0 12 3a7.488 7.488 0 0 0-5.33 2.143c-.27 2.03.506 3.99 1.83 5.252a6.01 6.01 0 0 0 1.5 1.555Zm-1.5 3.75a.75.75 0 0 0 3 0"
/>
</svg> </svg>
); );

View File

@@ -1,7 +1,18 @@
import React from 'react'; import React from 'react';
export const ListBulletIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const ListBulletIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM3.75 12h.007v.008H3.75V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.375 5.25h.007v.008H3.75v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM3.75 12h.007v.008H3.75V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.375 5.25h.007v.008H3.75v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"
/>
</svg> </svg>
); );

View File

@@ -1,8 +1,19 @@
import React from 'react'; import React from 'react';
export const MapPinIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const MapPinIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0Z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0Z"
/>
</svg> </svg>
); );

View File

@@ -1,7 +1,18 @@
import React from 'react'; import React from 'react';
export const MicrophoneIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const MicrophoneIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m12 0v-1.5a6 6 0 0 0-6-6v0a6 6 0 0 0-6 6v1.5m6 7.5v3.75m-3.75-3.75h7.5" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m12 0v-1.5a6 6 0 0 0-6-6v0a6 6 0 0 0-6 6v1.5m6 7.5v3.75m-3.75-3.75h7.5"
/>
</svg> </svg>
); );

View File

@@ -1,8 +1,18 @@
import React from 'react'; import React from 'react';
export const MoonIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const MoonIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25c0 5.385 4.365 9.75 9.75 9.75 2.572 0 4.921-.994 6.752-2.648Z" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25c0 5.385 4.365 9.75 9.75 9.75 2.572 0 4.921-.994 6.752-2.648Z"
/>
</svg> </svg>
); );

View File

@@ -1,8 +1,18 @@
import React from 'react'; import React from 'react';
export const PdfIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const PdfIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
/>
</svg> </svg>
); );

View File

@@ -1,14 +1,18 @@
import React from 'react'; import React from 'react';
export const PencilIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const PencilIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
strokeWidth={1.5} strokeWidth={1.5}
stroke="currentColor" stroke="currentColor"
{...props} {...props}
> >
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" /> <path
</svg> strokeLinecap="round"
strokeLinejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
); );

View File

@@ -1,7 +1,18 @@
import React from 'react'; import React from 'react';
export const PhotoIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const PhotoIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"
/>
</svg> </svg>
); );

View File

@@ -1,8 +1,23 @@
import React from 'react'; import React from 'react';
export const PlugIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const PlugIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5M19.5 8.25h-1.5m-15 3.75h1.5m15 0h1.5m-15 3.75h1.5m15 0h1.5" /> xmlns="http://www.w3.org/2000/svg"
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.75A5.25 5.25 0 0 0 6.75 12a5.25 5.25 0 0 0 5.25 5.25a5.25 5.25 0 0 0 5.25-5.25A5.25 5.25 0 0 0 12 6.75Z" /> fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5M19.5 8.25h-1.5m-15 3.75h1.5m15 0h1.5m-15 3.75h1.5m15 0h1.5"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6.75A5.25 5.25 0 0 0 6.75 12a5.25 5.25 0 0 0 5.25 5.25a5.25 5.25 0 0 0 5.25-5.25A5.25 5.25 0 0 0 12 6.75Z"
/>
</svg> </svg>
); );

View File

@@ -1,7 +1,18 @@
import React from 'react'; import React from 'react';
export const PlusCircleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const PlusCircleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v6m3-3H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v6m3-3H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg> </svg>
); );

View File

@@ -1,7 +1,18 @@
import React from 'react'; import React from 'react';
export const QuestionMarkCircleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const QuestionMarkCircleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
/>
</svg> </svg>
); );

View File

@@ -2,5 +2,18 @@
import React from 'react'; import React from 'react';
export const RefreshCwIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const RefreshCwIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}><path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 11.667 0l3.181-3.183m-4.991-2.696v4.992h-4.992m0 0-3.181-3.183a8.25 8.25 0 0 1 11.667 0l3.181 3.183" /></svg> <svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 11.667 0l3.181-3.183m-4.991-2.696v4.992h-4.992m0 0-3.181-3.183a8.25 8.25 0 0 1 11.667 0l3.181 3.183"
/>
</svg>
); );

View File

@@ -2,7 +2,18 @@
import React from 'react'; import React from 'react';
export const ScaleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const ScaleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v17.25m0 0c-1.472 0-2.882.265-4.185.75M12 20.25c1.472 0 2.882.265 4.185.75M18.75 4.97A48.416 48.416 0 0 0 12 4.5c-2.291 0-4.545.16-6.75.47m13.5 0c-1.472 0-2.882.265-4.185.75M5.25 4.97C3.947 5.235 2.538 5.5 1.25 5.5m1.5 14.55A48.348 48.348 0 0 0 12 20.25a48.348 48.348 0 0 0 9.25-1.2M1.25 5.5a48.348 48.348 0 0 1 9.25-1.2m0 0c1.472 0 2.882.265 4.185.75" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3v17.25m0 0c-1.472 0-2.882.265-4.185.75M12 20.25c1.472 0 2.882.265 4.185.75M18.75 4.97A48.416 48.416 0 0 0 12 4.5c-2.291 0-4.545.16-6.75.47m13.5 0c-1.472 0-2.882.265-4.185.75M5.25 4.97C3.947 5.235 2.538 5.5 1.25 5.5m1.5 14.55A48.348 48.348 0 0 0 12 20.25a48.348 48.348 0 0 0 9.25-1.2M1.25 5.5a48.348 48.348 0 0 1 9.25-1.2m0 0c1.472 0 2.882.265 4.185.75"
/>
</svg> </svg>
); );

View File

@@ -2,5 +2,18 @@
import React from 'react'; import React from 'react';
export const ScanIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const ScanIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}><path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.5v15h15V4.5h-15Zm-1.5-1.5h18a1.5 1.5 0 0 1 1.5 1.5v18a1.5 1.5 0 0 1-1.5 1.5h-18a1.5 1.5 0 0 1-1.5-1.5v-18A1.5 1.5 0 0 1 2.25 3ZM12 8.25v7.5" /></svg> <svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 4.5v15h15V4.5h-15Zm-1.5-1.5h18a1.5 1.5 0 0 1 1.5 1.5v18a1.5 1.5 0 0 1-1.5 1.5h-18a1.5 1.5 0 0 1-1.5-1.5v-18A1.5 1.5 0 0 1 2.25 3ZM12 8.25v7.5"
/>
</svg>
); );

View File

@@ -2,5 +2,18 @@
import React from 'react'; import React from 'react';
export const ScissorsIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const ScissorsIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}><path strokeLinecap="round" strokeLinejoin="round" d="M7.5 7.5h-.75A2.25 2.25 0 0 0 4.5 9.75v.75c0 .414.336.75.75.75h.75m0-1.5h.375c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125h-.375m0-1.5H7.5m9-6h.75a2.25 2.25 0 0 1 2.25 2.25v.75c0 .414-.336.75-.75.75h-.75m0-1.5h-.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125h.375m0-1.5H16.5m-9 3.75h1.5a.75.75 0 0 1 .75.75v.75c0 .414-.336.75-.75.75h-1.5a.75.75 0 0 1-.75-.75v-.75a.75.75 0 0 1 .75-.75Zm9 3.75h-1.5a.75.75 0 0 0-.75.75v.75c0 .414.336.75.75.75h1.5a.75.75 0 0 0 .75-.75v-.75a.75.75 0 0 0-.75-.75Z" /></svg> <svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M7.5 7.5h-.75A2.25 2.25 0 0 0 4.5 9.75v.75c0 .414.336.75.75.75h.75m0-1.5h.375c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125h-.375m0-1.5H7.5m9-6h.75a2.25 2.25 0 0 1 2.25 2.25v.75c0 .414-.336.75-.75.75h-.75m0-1.5h-.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125h.375m0-1.5H16.5m-9 3.75h1.5a.75.75 0 0 1 .75.75v.75c0 .414-.336.75-.75.75h-1.5a.75.75 0 0 1-.75-.75v-.75a.75.75 0 0 1 .75-.75Zm9 3.75h-1.5a.75.75 0 0 0-.75.75v.75c0 .414.336.75.75.75h1.5a.75.75 0 0 0 .75-.75v-.75a.75.75 0 0 0-.75-.75Z"
/>
</svg>
); );

View File

@@ -1,9 +1,18 @@
import React from 'react'; import React from 'react';
export const SearchIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const SearchIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
/>
</svg> </svg>
); );

View File

@@ -1,7 +1,18 @@
import React from 'react'; import React from 'react';
export const ServerIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const ServerIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 17.25v-.228a4.5 4.5 0 0 0-.12-1.03l-2.268-9.64a3.375 3.375 0 0 0-3.285-2.602H7.923a3.375 3.375 0 0 0-3.285 2.602l-2.268 9.64a4.5 4.5 0 0 0-.12 1.03v.228m15.459 0a2.25 2.25 0 0 1-2.25 2.25h-10.5a2.25 2.25 0 0 1-2.25-2.25m15 0c0-1.657-1.343-3-3-3s-3 1.343-3 3m0 0c0 1.657 1.343 3 3 3s3-1.343 3-3m-9.75 0c0-1.657-1.343-3-3-3s-3 1.343-3 3m0 0c0 1.657 1.343 3 3 3s3-1.343 3-3m9.75 0c0-1.657-1.343-3-3-3s-3 1.343-3 3m0 0c0 1.657 1.343 3 3 3s3-1.343 3-3" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.75 17.25v-.228a4.5 4.5 0 0 0-.12-1.03l-2.268-9.64a3.375 3.375 0 0 0-3.285-2.602H7.923a3.375 3.375 0 0 0-3.285 2.602l-2.268 9.64a4.5 4.5 0 0 0-.12 1.03v.228m15.459 0a2.25 2.25 0 0 1-2.25 2.25h-10.5a2.25 2.25 0 0 1-2.25-2.25m15 0c0-1.657-1.343-3-3-3s-3 1.343-3 3m0 0c0 1.657 1.343 3 3 3s3-1.343 3-3m-9.75 0c0-1.657-1.343-3-3-3s-3 1.343-3 3m0 0c0 1.657 1.343 3 3 3s3-1.343 3-3m9.75 0c0-1.657-1.343-3-3-3s-3 1.343-3 3m0 0c0 1.657 1.343 3 3 3s3-1.343 3-3"
/>
</svg> </svg>
); );

View File

@@ -1,7 +1,18 @@
import React from 'react'; import React from 'react';
export const ShieldCheckIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const ShieldCheckIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.286Zm0 13.036h.008v.008h-.008v-.008Z" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.286Zm0 13.036h.008v.008h-.008v-.008Z"
/>
</svg> </svg>
); );

View File

@@ -1,5 +1,18 @@
import React from 'react'; import React from 'react';
export const ShieldExclamationIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const ShieldExclamationIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}><path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" /></svg> <svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
/>
</svg>
); );

View File

@@ -1,9 +1,18 @@
import React from 'react'; import React from 'react';
export const ShoppingCartIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const ShoppingCartIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c.51 0 .962-.343 1.087-.835l1.823-6.831a.75.75 0 0 0-.54-1.022l-13.5-4.5a.75.75 0 0 0-.916.606Z" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c.51 0 .962-.343 1.087-.835l1.823-6.831a.75.75 0 0 0-.54-1.022l-13.5-4.5a.75.75 0 0 0-.916.606Z"
/>
</svg> </svg>
); );

View File

@@ -1,7 +1,18 @@
import React from 'react'; import React from 'react';
export const SortAscIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const SortAscIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M3 4.5h14.25M3 9h9.75M3 13.5h5.25m5.25-.75L17.25 9m0 0L21 12.75M17.25 9v12" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 4.5h14.25M3 9h9.75M3 13.5h5.25m5.25-.75L17.25 9m0 0L21 12.75M17.25 9v12"
/>
</svg> </svg>
); );

View File

@@ -1,7 +1,18 @@
import React from 'react'; import React from 'react';
export const SortDescIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const SortDescIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M3 4.5h14.25M3 9h9.75M3 13.5h5.25m5.25 6.75L17.25 15m0 0L21 18m-3.75-3v12" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 4.5h14.25M3 9h9.75M3 13.5h5.25m5.25 6.75L17.25 15m0 0L21 18m-3.75-3v12"
/>
</svg> </svg>
); );

View File

@@ -1,7 +1,18 @@
import React from 'react'; import React from 'react';
export const SparklesIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const SparklesIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L21.75 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L21.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09L15.75 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L9 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L18.25 12Z" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L21.75 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L21.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09L15.75 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L9 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L18.25 12Z"
/>
</svg> </svg>
); );

View File

@@ -1,7 +1,18 @@
import React from 'react'; import React from 'react';
export const SpeakerWaveIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const SpeakerWaveIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z"
/>
</svg> </svg>
); );

View File

@@ -1,8 +1,18 @@
import React from 'react'; import React from 'react';
export const SunIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const SunIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z"
/>
</svg> </svg>
); );

View File

@@ -1,9 +1,19 @@
import React from 'react'; import React from 'react';
export const TagIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const TagIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6h.008v.008H6V6Z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 6h.008v.008H6V6Z" />
</svg> </svg>
); );

View File

@@ -1,7 +1,18 @@
import React from 'react'; import React from 'react';
export const TrashIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const TrashIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.134-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.067-2.09 1.02-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.134-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.067-2.09 1.02-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg> </svg>
); );

View File

@@ -1,8 +1,23 @@
import React from 'react'; import React from 'react';
export const TrophyIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const TrophyIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 18.75h-9a9.75 9.75 0 0 1-4.873-1.465l-1.127-.655a.75.75 0 0 1-.298-1.033l.97-1.68a.75.75 0 0 1 1.033-.298l1.127.655A8.25 8.25 0 0 0 9.75 16.5h4.5a8.25 8.25 0 0 0 4.22-1.192l1.127-.655a.75.75 0 0 1 1.033.298l.97 1.68a.75.75 0 0 1-.298 1.033l-1.127.655A9.75 9.75 0 0 1 16.5 18.75Zm-9-12.75h9A.75.75 0 0 0 17.25 6H6.75A.75.75 0 0 0 6 6.75Z" /> xmlns="http://www.w3.org/2000/svg"
<path strokeLinecap="round" strokeLinejoin="round" d="M9.75 16.5a.75.75 0 0 0 .75.75h3a.75.75 0 0 0 .75-.75V13.5a.75.75 0 0 0-.75-.75h-3a.75.75 0 0 0-.75.75v3ZM9 6.75h6V6a3 3 0 0 0-6 0v.75Z" /> fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.5 18.75h-9a9.75 9.75 0 0 1-4.873-1.465l-1.127-.655a.75.75 0 0 1-.298-1.033l.97-1.68a.75.75 0 0 1 1.033-.298l1.127.655A8.25 8.25 0 0 0 9.75 16.5h4.5a8.25 8.25 0 0 0 4.22-1.192l1.127-.655a.75.75 0 0 1 1.033.298l.97 1.68a.75.75 0 0 1-.298 1.033l-1.127.655A9.75 9.75 0 0 1 16.5 18.75Zm-9-12.75h9A.75.75 0 0 0 17.25 6H6.75A.75.75 0 0 0 6 6.75Z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.75 16.5a.75.75 0 0 0 .75.75h3a.75.75 0 0 0 .75-.75V13.5a.75.75 0 0 0-.75-.75h-3a.75.75 0 0 0-.75.75v3ZM9 6.75h6V6a3 3 0 0 0-6 0v.75Z"
/>
</svg> </svg>
); );

View File

@@ -1,9 +1,18 @@
import React from 'react'; import React from 'react';
export const UploadIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const UploadIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5"
/>
</svg> </svg>
); );

View File

@@ -1,8 +1,18 @@
import React from 'react'; import React from 'react';
export const UserIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const UserIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"
/>
</svg> </svg>
); );

View File

@@ -1,14 +1,18 @@
import React from 'react'; import React from 'react';
export const UsersIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const UsersIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
strokeWidth={1.5} strokeWidth={1.5}
stroke="currentColor" stroke="currentColor"
{...props} {...props}
> >
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m-7.5-2.962c.57-.063 1.14-.094 1.722-.094s1.152.031 1.722.094m-7.5 2.962l-1.622.163a2.25 2.25 0 01-2.15-2.15l.163-1.622m0 0l2.15-2.15a6.75 6.75 0 019.546 0l2.15 2.15m-9.546 0a6.75 6.75 0 00-9.546 0m9.546 0L10.5 12.562m-4.5 4.5L10.5 12.562" /> <path
</svg> strokeLinecap="round"
strokeLinejoin="round"
d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m-7.5-2.962c.57-.063 1.14-.094 1.722-.094s1.152.031 1.722.094m-7.5 2.962l-1.622.163a2.25 2.25 0 01-2.15-2.15l.163-1.622m0 0l2.15-2.15a6.75 6.75 0 019.546 0l2.15 2.15m-9.546 0a6.75 6.75 0 00-9.546 0m9.546 0L10.5 12.562m-4.5 4.5L10.5 12.562"
/>
</svg>
); );

View File

@@ -1,7 +1,18 @@
import React from 'react'; import React from 'react';
export const XCircleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const XCircleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /> xmlns="http://www.w3.org/2000/svg"
</svg> fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
); );

View File

@@ -1,8 +1,14 @@
import React from 'react'; import React from 'react';
export const XMarkIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => ( export const XMarkIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> <svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg> </svg>
); );

View File

@@ -50,21 +50,21 @@ const pool = new Pool({
* Tables with direct user_id foreign keys come first. * Tables with direct user_id foreign keys come first.
*/ */
const USER_DATA_TABLES: Record<string, string> = { const USER_DATA_TABLES: Record<string, string> = {
'users': 'user_id', users: 'user_id',
'profiles': 'user_id', profiles: 'user_id',
'pantry_locations': 'user_id', pantry_locations: 'user_id',
'shopping_lists': 'user_id', shopping_lists: 'user_id',
'recipes': 'user_id', recipes: 'user_id',
'menu_plans': 'user_id', menu_plans: 'user_id',
'recipe_collections': 'user_id', recipe_collections: 'user_id',
'user_item_aliases': 'user_id', user_item_aliases: 'user_id',
'user_appliances': 'user_id', user_appliances: 'user_id',
'user_dietary_restrictions': 'user_id', user_dietary_restrictions: 'user_id',
'favorite_stores': 'user_id', favorite_stores: 'user_id',
'favorite_recipes': 'user_id', favorite_recipes: 'user_id',
'user_watched_items': 'user_id', user_watched_items: 'user_id',
'receipts': 'user_id', receipts: 'user_id',
'shopping_trips': 'user_id', shopping_trips: 'user_id',
}; };
type DBValue = string | number | boolean | null | Date | object; type DBValue = string | number | boolean | null | Date | object;
@@ -76,89 +76,104 @@ type DBValue = string | number | boolean | null | Date | object;
* @returns A formatted SQL INSERT statement string. * @returns A formatted SQL INSERT statement string.
*/ */
function generateInsertStatement(table: string, row: Record<string, DBValue>): string { function generateInsertStatement(table: string, row: Record<string, DBValue>): string {
const columns = Object.keys(row).map(col => `"${col}"`).join(', '); const columns = Object.keys(row)
const values = Object.values(row).map(val => { .map((col) => `"${col}"`)
if (val === null) return 'NULL'; .join(', ');
if (val instanceof Date) return `'${val.toISOString()}'`; // Handle Date objects const values = Object.values(row)
if (typeof val === 'string') return `'${val.replace(/'/g, "''")}'`; // Escape single quotes .map((val) => {
if (typeof val === 'object') return `'${JSON.stringify(val).replace(/'/g, "''")}'`; // Handle JSONB if (val === null) return 'NULL';
return val; if (val instanceof Date) return `'${val.toISOString()}'`; // Handle Date objects
}).join(', '); if (typeof val === 'string') return `'${val.replace(/'/g, "''")}'`; // Escape single quotes
if (typeof val === 'object') return `'${JSON.stringify(val).replace(/'/g, "''")}'`; // Handle JSONB
return val;
})
.join(', ');
return `INSERT INTO public.${table} (${columns}) VALUES (${values});\n`; return `INSERT INTO public.${table} (${columns}) VALUES (${values});\n`;
} }
async function main() { async function main() {
const args = process.argv.slice(2); const args = process.argv.slice(2);
const emailIndex = args.indexOf('--email'); const emailIndex = args.indexOf('--email');
const outputIndex = args.indexOf('--output'); const outputIndex = args.indexOf('--output');
if (emailIndex === -1 || outputIndex === -1) { if (emailIndex === -1 || outputIndex === -1) {
console.error('Usage: tsx src/db/backup_user.ts --email <user_email> --output <output_file.sql>'); console.error(
process.exit(1); 'Usage: tsx src/db/backup_user.ts --email <user_email> --output <output_file.sql>',
);
process.exit(1);
}
const userEmail = args[emailIndex + 1];
const outputFile = args[outputIndex + 1];
let client: PoolClient | null = null;
try {
client = await pool.connect();
logger.info(`Connected to database. Backing up user: ${userEmail}`);
// 1. Find the user ID
const userRes = await client.query('SELECT user_id FROM public.users WHERE email = $1', [
userEmail,
]);
if (userRes.rows.length === 0) {
logger.warn(`User with email ${userEmail} not found. No backup will be created.`);
return;
}
const userId = userRes.rows[0].user_id;
logger.info(`Found user ID: ${userId}`);
let backupSql = '-- User Data Backup\n';
backupSql += `-- Generated at: ${new Date().toISOString()}\n`;
backupSql += `-- User Email: ${userEmail}\n\n`;
// 2. Backup data from tables with a direct user_id link
for (const [table, column] of Object.entries(USER_DATA_TABLES)) {
const res = await client.query(`SELECT * FROM public.${table} WHERE ${column} = $1`, [
userId,
]);
if (res.rows.length > 0) {
backupSql += `-- Data for table: ${table}\n`;
for (const row of res.rows) {
backupSql += generateInsertStatement(table, row);
}
backupSql += '\n';
}
} }
const userEmail = args[emailIndex + 1]; // 3. Backup data from indirectly related tables (e.g., shopping_list_items)
const outputFile = args[outputIndex + 1]; const shoppingListsRes = await client.query(
let client: PoolClient | null = null; 'SELECT shopping_list_id FROM public.shopping_lists WHERE user_id = $1',
[userId],
);
const shoppingListIds = shoppingListsRes.rows.map((r) => r.shopping_list_id);
try { if (shoppingListIds.length > 0) {
client = await pool.connect(); const itemsRes = await client.query(
logger.info(`Connected to database. Backing up user: ${userEmail}`); 'SELECT * FROM public.shopping_list_items WHERE shopping_list_id = ANY($1)',
[shoppingListIds],
// 1. Find the user ID );
const userRes = await client.query('SELECT user_id FROM public.users WHERE email = $1', [userEmail]); if (itemsRes.rows.length > 0) {
if (userRes.rows.length === 0) { backupSql += '-- Data for table: shopping_list_items\n';
logger.warn(`User with email ${userEmail} not found. No backup will be created.`); for (const row of itemsRes.rows) {
return; backupSql += generateInsertStatement('shopping_list_items', row);
} }
const userId = userRes.rows[0].user_id; backupSql += '\n';
logger.info(`Found user ID: ${userId}`); }
let backupSql = '-- User Data Backup\n';
backupSql += `-- Generated at: ${new Date().toISOString()}\n`;
backupSql += `-- User Email: ${userEmail}\n\n`;
// 2. Backup data from tables with a direct user_id link
for (const [table, column] of Object.entries(USER_DATA_TABLES)) {
const res = await client.query(`SELECT * FROM public.${table} WHERE ${column} = $1`, [userId]);
if (res.rows.length > 0) {
backupSql += `-- Data for table: ${table}\n`;
for (const row of res.rows) {
backupSql += generateInsertStatement(table, row);
}
backupSql += '\n';
}
}
// 3. Backup data from indirectly related tables (e.g., shopping_list_items)
const shoppingListsRes = await client.query('SELECT shopping_list_id FROM public.shopping_lists WHERE user_id = $1', [userId]);
const shoppingListIds = shoppingListsRes.rows.map(r => r.shopping_list_id);
if (shoppingListIds.length > 0) {
const itemsRes = await client.query('SELECT * FROM public.shopping_list_items WHERE shopping_list_id = ANY($1)', [shoppingListIds]);
if (itemsRes.rows.length > 0) {
backupSql += '-- Data for table: shopping_list_items\n';
for (const row of itemsRes.rows) {
backupSql += generateInsertStatement('shopping_list_items', row);
}
backupSql += '\n';
}
}
// (Add similar logic for other indirectly related tables like recipe_ingredients, planned_meals, etc.)
// 4. Write the final SQL to the output file
await fs.writeFile(outputFile, backupSql);
logger.info(`✅ Successfully created backup for user ${userEmail} at ${outputFile}`);
} catch (error) {
logger.error({ error }, 'Failed to create user backup.');
process.exit(1);
} finally {
client?.release();
await pool.end();
} }
// (Add similar logic for other indirectly related tables like recipe_ingredients, planned_meals, etc.)
// 4. Write the final SQL to the output file
await fs.writeFile(outputFile, backupSql);
logger.info(`✅ Successfully created backup for user ${userEmail} at ${outputFile}`);
} catch (error) {
logger.error({ error }, 'Failed to create user backup.');
process.exit(1);
} finally {
client?.release();
await pool.end();
}
} }
main(); main();

View File

@@ -39,49 +39,59 @@ async function main() {
-- Exclude PostGIS system tables from truncation to avoid permission errors. -- Exclude PostGIS system tables from truncation to avoid permission errors.
AND tablename NOT IN ('spatial_ref_sys', 'geometry_columns') AND tablename NOT IN ('spatial_ref_sys', 'geometry_columns')
`); `);
const tables = tablesRes.rows.map(row => `"${row.tablename}"`).join(', '); const tables = tablesRes.rows.map((row) => `"${row.tablename}"`).join(', ');
if (tables) { if (tables) {
await client.query(`TRUNCATE ${tables} RESTART IDENTITY CASCADE`); await client.query(`TRUNCATE ${tables} RESTART IDENTITY CASCADE`);
logger.info('All tables in public schema have been truncated.'); logger.info('All tables in public schema have been truncated.');
} }
// 2. Seed Categories // 2. Seed Categories
logger.info('--- Seeding Categories... ---'); logger.info('--- Seeding Categories... ---');
const categoryQuery = `INSERT INTO public.categories (name) VALUES ${CATEGORIES.map((_, i) => `($${i + 1})`).join(', ')} RETURNING category_id, name`; const categoryQuery = `INSERT INTO public.categories (name) VALUES ${CATEGORIES.map((_, i) => `($${i + 1})`).join(', ')} RETURNING category_id, name`;
const seededCategories = (await client.query<{category_id: number, name: string}>(categoryQuery, CATEGORIES)).rows; const seededCategories = (
const categoryMap = new Map(seededCategories.map(c => [c.name, c.category_id])); await client.query<{ category_id: number; name: string }>(categoryQuery, CATEGORIES)
).rows;
const categoryMap = new Map(seededCategories.map((c) => [c.name, c.category_id]));
logger.info(`Seeded ${seededCategories.length} categories.`); logger.info(`Seeded ${seededCategories.length} categories.`);
// 3. Seed Stores // 3. Seed Stores
logger.info('--- Seeding Stores... ---'); logger.info('--- Seeding Stores... ---');
const stores = ['Safeway', 'No Frills', 'Costco', 'Superstore']; const stores = ['Safeway', 'No Frills', 'Costco', 'Superstore'];
const storeQuery = `INSERT INTO public.stores (name) VALUES ${stores.map((_, i) => `($${i + 1})`).join(', ')} RETURNING store_id, name`; const storeQuery = `INSERT INTO public.stores (name) VALUES ${stores.map((_, i) => `($${i + 1})`).join(', ')} RETURNING store_id, name`;
const seededStores = (await client.query<{store_id: number, name: string}>(storeQuery, stores)).rows; const seededStores = (
const storeMap = new Map(seededStores.map(s => [s.name, s.store_id])); await client.query<{ store_id: number; name: string }>(storeQuery, stores)
).rows;
const storeMap = new Map(seededStores.map((s) => [s.name, s.store_id]));
logger.info(`Seeded ${seededStores.length} stores.`); logger.info(`Seeded ${seededStores.length} stores.`);
// 4. Seed Master Grocery Items // 4. Seed Master Grocery Items
logger.info('--- Seeding Master Grocery Items... ---'); logger.info('--- Seeding Master Grocery Items... ---');
const masterItems = [ const masterItems = [
{ name: 'Chicken Breast, Boneless Skinless', category: 'Meat & Seafood' }, { name: 'Chicken Breast, Boneless Skinless', category: 'Meat & Seafood' },
{ name: 'Ground Beef, Lean', category: 'Meat & Seafood' }, { name: 'Ground Beef, Lean', category: 'Meat & Seafood' },
{ name: 'Avocado', category: 'Fruits & Vegetables' }, { name: 'Avocado', category: 'Fruits & Vegetables' },
{ name: 'Bananas', category: 'Fruits & Vegetables' }, { name: 'Bananas', category: 'Fruits & Vegetables' },
{ name: 'Broccoli', category: 'Fruits & Vegetables' }, { name: 'Broccoli', category: 'Fruits & Vegetables' },
{ name: 'Cheddar Cheese, Block', category: 'Dairy & Eggs' }, { name: 'Cheddar Cheese, Block', category: 'Dairy & Eggs' },
{ name: 'Milk, 2%', category: 'Dairy & Eggs' }, { name: 'Milk, 2%', category: 'Dairy & Eggs' },
{ name: 'Eggs, Large', category: 'Dairy & Eggs' }, { name: 'Eggs, Large', category: 'Dairy & Eggs' },
{ name: 'Whole Wheat Bread', category: 'Bakery & Bread' }, { name: 'Whole Wheat Bread', category: 'Bakery & Bread' },
{ name: 'Pasta, Spaghetti', category: 'Pantry & Dry Goods' }, { name: 'Pasta, Spaghetti', category: 'Pantry & Dry Goods' },
{ name: 'Canned Tomatoes, Diced', category: 'Canned Goods' }, { name: 'Canned Tomatoes, Diced', category: 'Canned Goods' },
{ name: 'Coca-Cola, 12-pack', category: 'Beverages' }, { name: 'Coca-Cola, 12-pack', category: 'Beverages' },
{ name: 'Frozen Pizza', category: 'Frozen Foods' }, { name: 'Frozen Pizza', category: 'Frozen Foods' },
{ name: 'Paper Towels', category: 'Household & Cleaning' }, { name: 'Paper Towels', category: 'Household & Cleaning' },
]; ];
const masterItemValues = masterItems.map(item => `('${item.name.replace(/'/g, "''")}', ${categoryMap.get(item.category)})`).join(', '); const masterItemValues = masterItems
.map((item) => `('${item.name.replace(/'/g, "''")}', ${categoryMap.get(item.category)})`)
.join(', ');
const masterItemQuery = `INSERT INTO public.master_grocery_items (name, category_id) VALUES ${masterItemValues} RETURNING master_grocery_item_id, name`; const masterItemQuery = `INSERT INTO public.master_grocery_items (name, category_id) VALUES ${masterItemValues} RETURNING master_grocery_item_id, name`;
const seededMasterItems = (await client.query<{master_grocery_item_id: number, name: string}>(masterItemQuery)).rows; const seededMasterItems = (
const masterItemMap = new Map(seededMasterItems.map(item => [item.name, item.master_grocery_item_id])); await client.query<{ master_grocery_item_id: number; name: string }>(masterItemQuery)
).rows;
const masterItemMap = new Map(
seededMasterItems.map((item) => [item.name, item.master_grocery_item_id]),
);
logger.info(`Seeded ${seededMasterItems.length} master grocery items.`); logger.info(`Seeded ${seededMasterItems.length} master grocery items.`);
// 5. Seed Users & Profiles // 5. Seed Users & Profiles
@@ -91,9 +101,14 @@ async function main() {
const userPassHash = await bcrypt.hash('userpass', saltRounds); const userPassHash = await bcrypt.hash('userpass', saltRounds);
// Admin User // Admin User
await client.query("SELECT set_config('my_app.user_metadata', $1, true)", [JSON.stringify({ full_name: 'Admin User', role: 'admin' })]); await client.query("SELECT set_config('my_app.user_metadata', $1, true)", [
JSON.stringify({ full_name: 'Admin User', role: 'admin' }),
]);
// The trigger will create a profile with the 'user' role. We capture the ID to update it. // The trigger will create a profile with the 'user' role. We capture the ID to update it.
const adminRes = await client.query('INSERT INTO public.users (email, password_hash) VALUES ($1, $2) RETURNING user_id', ['admin@example.com', adminPassHash]); const adminRes = await client.query(
'INSERT INTO public.users (email, password_hash) VALUES ($1, $2) RETURNING user_id',
['admin@example.com', adminPassHash],
);
const adminId = adminRes.rows[0].user_id; const adminId = adminRes.rows[0].user_id;
// Explicitly update the role to 'admin' for the newly created user. // Explicitly update the role to 'admin' for the newly created user.
await client.query("UPDATE public.profiles SET role = 'admin' WHERE user_id = $1", [adminId]); await client.query("UPDATE public.profiles SET role = 'admin' WHERE user_id = $1", [adminId]);
@@ -101,8 +116,13 @@ async function main() {
logger.info(`> Role for ${adminId} set to 'admin'.`); logger.info(`> Role for ${adminId} set to 'admin'.`);
// Regular User // Regular User
await client.query("SELECT set_config('my_app.user_metadata', $1, true)", [JSON.stringify({ full_name: 'Test User' })]); await client.query("SELECT set_config('my_app.user_metadata', $1, true)", [
const userRes = await client.query('INSERT INTO public.users (email, password_hash) VALUES ($1, $2) RETURNING user_id', ['user@example.com', userPassHash]); JSON.stringify({ full_name: 'Test User' }),
]);
const userRes = await client.query(
'INSERT INTO public.users (email, password_hash) VALUES ($1, $2) RETURNING user_id',
['user@example.com', userPassHash],
);
const userId = userRes.rows[0].user_id; const userId = userRes.rows[0].user_id;
logger.info('Seeded regular user (user@example.com / userpass)'); logger.info('Seeded regular user (user@example.com / userpass)');
@@ -119,64 +139,112 @@ async function main() {
VALUES ('safeway-flyer.jpg', '/sample-assets/safeway-flyer.jpg', 'sample-checksum-123', ${storeMap.get('Safeway')}, $1, $2) VALUES ('safeway-flyer.jpg', '/sample-assets/safeway-flyer.jpg', 'sample-checksum-123', ${storeMap.get('Safeway')}, $1, $2)
RETURNING flyer_id; RETURNING flyer_id;
`; `;
const flyerRes = await client.query<{flyer_id: number}>(flyerQuery, [validFrom.toISOString().split('T')[0], validTo.toISOString().split('T')[0]]); const flyerRes = await client.query<{ flyer_id: number }>(flyerQuery, [
validFrom.toISOString().split('T')[0],
validTo.toISOString().split('T')[0],
]);
const flyerId = flyerRes.rows[0].flyer_id; const flyerId = flyerRes.rows[0].flyer_id;
logger.info(`Seeded flyer for Safeway (ID: ${flyerId}).`); logger.info(`Seeded flyer for Safeway (ID: ${flyerId}).`);
// 7. Seed Flyer Items // 7. Seed Flyer Items
logger.info('--- Seeding Flyer Items... ---'); logger.info('--- Seeding Flyer Items... ---');
const flyerItems = [ const flyerItems = [
{ name: 'Chicken Breast, Boneless Skinless', price_display: '$3.99 /lb', price_in_cents: 399, quantity: 'per lb', master_item_id: masterItemMap.get('Chicken Breast, Boneless Skinless') }, {
{ name: 'Avocado', price_display: '2 for $5.00', price_in_cents: 250, quantity: 'each', master_item_id: masterItemMap.get('Avocado') }, name: 'Chicken Breast, Boneless Skinless',
{ name: 'Coca-Cola 12-pack', price_display: '$6.99', price_in_cents: 699, quantity: '12x355ml', master_item_id: masterItemMap.get('Coca-Cola, 12-pack') }, price_display: '$3.99 /lb',
{ name: 'Unmatched Sample Item', price_display: '$1.23', price_in_cents: 123, quantity: 'each', master_item_id: null }, price_in_cents: 399,
quantity: 'per lb',
master_item_id: masterItemMap.get('Chicken Breast, Boneless Skinless'),
},
{
name: 'Avocado',
price_display: '2 for $5.00',
price_in_cents: 250,
quantity: 'each',
master_item_id: masterItemMap.get('Avocado'),
},
{
name: 'Coca-Cola 12-pack',
price_display: '$6.99',
price_in_cents: 699,
quantity: '12x355ml',
master_item_id: masterItemMap.get('Coca-Cola, 12-pack'),
},
{
name: 'Unmatched Sample Item',
price_display: '$1.23',
price_in_cents: 123,
quantity: 'each',
master_item_id: null,
},
]; ];
for (const item of flyerItems) { for (const item of flyerItems) {
await client.query( await client.query(
`INSERT INTO public.flyer_items (flyer_id, item, price_display, price_in_cents, quantity, master_item_id) VALUES ($1, $2, $3, $4, $5, $6)`, `INSERT INTO public.flyer_items (flyer_id, item, price_display, price_in_cents, quantity, master_item_id) VALUES ($1, $2, $3, $4, $5, $6)`,
[flyerId, item.name, item.price_display, item.price_in_cents, item.quantity, item.master_item_id] [
); flyerId,
item.name,
item.price_display,
item.price_in_cents,
item.quantity,
item.master_item_id,
],
);
} }
logger.info(`Seeded ${flyerItems.length} items for the Safeway flyer.`); logger.info(`Seeded ${flyerItems.length} items for the Safeway flyer.`);
// 8. Seed Watched Items for the user // 8. Seed Watched Items for the user
logger.info('--- Seeding Watched Items... ---'); logger.info('--- Seeding Watched Items... ---');
const watchedItemIds = [ const watchedItemIds = [
masterItemMap.get('Chicken Breast, Boneless Skinless'), masterItemMap.get('Chicken Breast, Boneless Skinless'),
masterItemMap.get('Avocado'), masterItemMap.get('Avocado'),
masterItemMap.get('Ground Beef, Lean'), masterItemMap.get('Ground Beef, Lean'),
]; ];
for (const itemId of watchedItemIds) { for (const itemId of watchedItemIds) {
if (itemId) { if (itemId) {
await client.query('INSERT INTO public.user_watched_items (user_id, master_item_id) VALUES ($1, $2)', [userId, itemId]); await client.query(
} 'INSERT INTO public.user_watched_items (user_id, master_item_id) VALUES ($1, $2)',
[userId, itemId],
);
}
} }
logger.info(`Seeded ${watchedItemIds.length} watched items for Test User.`); logger.info(`Seeded ${watchedItemIds.length} watched items for Test User.`);
// 9. Seed a Shopping List // 9. Seed a Shopping List
logger.info('--- Seeding a Shopping List... ---'); logger.info('--- Seeding a Shopping List... ---');
const listRes = await client.query<{shopping_list_id: number}>('INSERT INTO public.shopping_lists (user_id, name) VALUES ($1, $2) RETURNING shopping_list_id', [userId, 'Weekly Groceries']); const listRes = await client.query<{ shopping_list_id: number }>(
'INSERT INTO public.shopping_lists (user_id, name) VALUES ($1, $2) RETURNING shopping_list_id',
[userId, 'Weekly Groceries'],
);
const listId = listRes.rows[0].shopping_list_id; const listId = listRes.rows[0].shopping_list_id;
const shoppingListItems = [ const shoppingListItems = [
{ master_item_id: masterItemMap.get('Milk, 2%'), quantity: 1 }, { master_item_id: masterItemMap.get('Milk, 2%'), quantity: 1 },
{ master_item_id: masterItemMap.get('Eggs, Large'), quantity: 1 }, { master_item_id: masterItemMap.get('Eggs, Large'), quantity: 1 },
{ custom_item_name: 'Specialty Hot Sauce', quantity: 1 }, { custom_item_name: 'Specialty Hot Sauce', quantity: 1 },
]; ];
for (const item of shoppingListItems) { for (const item of shoppingListItems) {
await client.query( await client.query(
'INSERT INTO public.shopping_list_items (shopping_list_id, master_item_id, custom_item_name, quantity) VALUES ($1, $2, $3, $4)', 'INSERT INTO public.shopping_list_items (shopping_list_id, master_item_id, custom_item_name, quantity) VALUES ($1, $2, $3, $4)',
[listId, item.master_item_id, item.custom_item_name, item.quantity] [listId, item.master_item_id, item.custom_item_name, item.quantity],
); );
} }
logger.info(`Seeded shopping list "Weekly Groceries" with ${shoppingListItems.length} items for Test User.`); logger.info(
`Seeded shopping list "Weekly Groceries" with ${shoppingListItems.length} items for Test User.`,
);
// 10. Seed Brands // 10. Seed Brands
logger.info('--- Seeding Brands... ---'); logger.info('--- Seeding Brands... ---');
const brands = ['Coca-Cola', 'Kraft', 'Maple Leaf', 'Dempster\'s', 'No Name', 'President\'s Choice']; const brands = [
'Coca-Cola',
'Kraft',
'Maple Leaf',
"Dempster's",
'No Name',
"President's Choice",
];
const brandQuery = `INSERT INTO public.brands (name) VALUES ${brands.map((_, i) => `($${i + 1})`).join(', ')} ON CONFLICT (name) DO NOTHING`; const brandQuery = `INSERT INTO public.brands (name) VALUES ${brands.map((_, i) => `($${i + 1})`).join(', ')} ON CONFLICT (name) DO NOTHING`;
await client.query(brandQuery, brands); await client.query(brandQuery, brands);
logger.info(`Seeded ${brands.length} brands.`); logger.info(`Seeded ${brands.length} brands.`);
@@ -184,56 +252,91 @@ async function main() {
// Link store-specific brands // Link store-specific brands
const loblawsId = storeMap.get('Loblaws'); const loblawsId = storeMap.get('Loblaws');
if (loblawsId) { if (loblawsId) {
await client.query('UPDATE public.brands SET store_id = $1 WHERE name = $2 OR name = $3', [loblawsId, 'No Name', 'President\'s Choice']); await client.query('UPDATE public.brands SET store_id = $1 WHERE name = $2 OR name = $3', [
logger.info('Linked store brands to Loblaws.'); loblawsId,
'No Name',
"President's Choice",
]);
logger.info('Linked store brands to Loblaws.');
} }
// 11. Seed Recipes // 11. Seed Recipes
logger.info('--- Seeding Recipes... ---'); logger.info('--- Seeding Recipes... ---');
const recipes = [ const recipes = [
{ name: 'Simple Chicken and Rice', description: 'A quick and healthy weeknight meal.', instructions: '1. Cook rice. 2. Cook chicken. 3. Combine.', prep: 10, cook: 20, servings: 4 }, {
{ name: 'Classic Spaghetti Bolognese', description: 'A rich and hearty meat sauce.', instructions: '1. Brown beef. 2. Add sauce. 3. Simmer.', prep: 15, cook: 45, servings: 6 }, name: 'Simple Chicken and Rice',
{ name: 'Vegetable Stir-fry', description: 'A fast and flavorful vegetarian meal.', instructions: '1. Chop veggies. 2. Stir-fry. 3. Add sauce.', prep: 10, cook: 10, servings: 3 }, description: 'A quick and healthy weeknight meal.',
instructions: '1. Cook rice. 2. Cook chicken. 3. Combine.',
prep: 10,
cook: 20,
servings: 4,
},
{
name: 'Classic Spaghetti Bolognese',
description: 'A rich and hearty meat sauce.',
instructions: '1. Brown beef. 2. Add sauce. 3. Simmer.',
prep: 15,
cook: 45,
servings: 6,
},
{
name: 'Vegetable Stir-fry',
description: 'A fast and flavorful vegetarian meal.',
instructions: '1. Chop veggies. 2. Stir-fry. 3. Add sauce.',
prep: 10,
cook: 10,
servings: 3,
},
]; ];
for (const recipe of recipes) { for (const recipe of recipes) {
await client.query( await client.query(
`INSERT INTO public.recipes (name, description, instructions, prep_time_minutes, cook_time_minutes, servings, status) `INSERT INTO public.recipes (name, description, instructions, prep_time_minutes, cook_time_minutes, servings, status)
VALUES ($1, $2, $3, $4, $5, $6, 'public') ON CONFLICT (name) WHERE user_id IS NULL DO NOTHING`, VALUES ($1, $2, $3, $4, $5, $6, 'public') ON CONFLICT (name) WHERE user_id IS NULL DO NOTHING`,
[recipe.name, recipe.description, recipe.instructions, recipe.prep, recipe.cook, recipe.servings] [
); recipe.name,
recipe.description,
recipe.instructions,
recipe.prep,
recipe.cook,
recipe.servings,
],
);
} }
logger.info(`Seeded ${recipes.length} recipes.`); logger.info(`Seeded ${recipes.length} recipes.`);
// --- SEED SCRIPT DEBUG LOGGING --- // --- SEED SCRIPT DEBUG LOGGING ---
// Corrected the query to be unambiguous by specifying the table alias for each column. // Corrected the query to be unambiguous by specifying the table alias for each column.
// `id` and `email` come from the `users` table (u), and `role` comes from the `profiles` table (p). // `id` and `email` come from the `users` table (u), and `role` comes from the `profiles` table (p).
const allUsersInDb = await client.query('SELECT u.user_id, u.email, p.role FROM public.users u JOIN public.profiles p ON u.user_id = p.user_id'); const allUsersInDb = await client.query(
'SELECT u.user_id, u.email, p.role FROM public.users u JOIN public.profiles p ON u.user_id = p.user_id',
);
logger.debug('[SEED SCRIPT] Final state of users table after seeding:'); logger.debug('[SEED SCRIPT] Final state of users table after seeding:');
console.table(allUsersInDb.rows); console.table(allUsersInDb.rows);
// --- END DEBUG LOGGING --- // --- END DEBUG LOGGING ---
await client.query('COMMIT'); await client.query('COMMIT');
logger.info('✅ Database seeding completed successfully!'); logger.info('✅ Database seeding completed successfully!');
} catch (error) { } catch (error) {
// Check if the error is a detailed PostgreSQL error object. // Check if the error is a detailed PostgreSQL error object.
if (error && typeof error === 'object' && 'code' in error && 'message' in error) { if (error && typeof error === 'object' && 'code' in error && 'message' in error) {
const dbError = error as { code: string; message: string; detail?: string; table?: string }; const dbError = error as { code: string; message: string; detail?: string; table?: string };
logger.error({ logger.error(
code: dbError.code, {
message: dbError.message, code: dbError.code,
detail: dbError.detail, message: dbError.message,
table: dbError.table, detail: dbError.detail,
}, '🔴 A database error occurred during seeding.'); table: dbError.table,
},
'🔴 A database error occurred during seeding.',
);
} else { } else {
// Log a generic error if it's not a standard DB error (e.g., connection failed). // Log a generic error if it's not a standard DB error (e.g., connection failed).
logger.error({ error }, '🔴 An unexpected error occurred during seeding.'); logger.error({ error }, '🔴 An unexpected error occurred during seeding.');
} }
if (client) { if (client) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
logger.warn('Database transaction rolled back.'); logger.warn('Database transaction rolled back.');
} }
process.exit(1); // Exit with an error code process.exit(1); // Exit with an error code

View File

@@ -19,14 +19,19 @@ async function seedAdminUser() {
try { try {
// Check if the admin user already exists // Check if the admin user already exists
const existingUserRes = await client.query('SELECT user_id FROM public.users WHERE email = $1', [ADMIN_EMAIL]); const existingUserRes = await client.query(
'SELECT user_id FROM public.users WHERE email = $1',
[ADMIN_EMAIL],
);
if (existingUserRes.rows.length > 0) { if (existingUserRes.rows.length > 0) {
const userId = existingUserRes.rows[0].user_id; const userId = existingUserRes.rows[0].user_id;
console.log(`Admin user '${ADMIN_EMAIL}' already exists with ID: ${userId}.`); console.log(`Admin user '${ADMIN_EMAIL}' already exists with ID: ${userId}.`);
// Ensure the user has the 'admin' role // Ensure the user has the 'admin' role
const profileRes = await client.query("SELECT role FROM public.profiles WHERE user_id = $1", [userId]); const profileRes = await client.query('SELECT role FROM public.profiles WHERE user_id = $1', [
userId,
]);
if (profileRes.rows.length === 0 || profileRes.rows[0].role !== 'admin') { if (profileRes.rows.length === 0 || profileRes.rows[0].role !== 'admin') {
await client.query("UPDATE public.profiles SET role = 'admin' WHERE id = $1", [userId]); await client.query("UPDATE public.profiles SET role = 'admin' WHERE id = $1", [userId]);
console.log(`Updated role to 'admin' for user ${userId}.`); console.log(`Updated role to 'admin' for user ${userId}.`);
@@ -44,21 +49,17 @@ async function seedAdminUser() {
// Insert into the users table. The `handle_new_user` trigger will create the profile. // Insert into the users table. The `handle_new_user` trigger will create the profile.
const newUserRes = await client.query( const newUserRes = await client.query(
'INSERT INTO public.users (email, password_hash) VALUES ($1, $2) RETURNING user_id', 'INSERT INTO public.users (email, password_hash) VALUES ($1, $2) RETURNING user_id',
[ADMIN_EMAIL, hashedPassword] [ADMIN_EMAIL, hashedPassword],
); );
const newUserId = newUserRes.rows[0].user_id; const newUserId = newUserRes.rows[0].user_id;
console.log(`Successfully created user with ID: ${newUserId}.`); console.log(`Successfully created user with ID: ${newUserId}.`);
// The trigger creates a profile with the 'user' role. We now update it to 'admin'. // The trigger creates a profile with the 'user' role. We now update it to 'admin'.
await client.query( await client.query("UPDATE public.profiles SET role = 'admin' WHERE user_id = $1", [newUserId]);
"UPDATE public.profiles SET role = 'admin' WHERE user_id = $1",
[newUserId]
);
console.log(`Successfully set role to 'admin' for user ${newUserId}.`); console.log(`Successfully set role to 'admin' for user ${newUserId}.`);
console.log('Admin user seeding complete!'); console.log('Admin user seeding complete!');
} catch (error) { } catch (error) {
console.error('Error during admin user seeding:', error); console.error('Error during admin user seeding:', error);
} finally { } finally {

View File

@@ -18,11 +18,11 @@ export const PriceChart: React.FC<PriceChartProps> = ({ unitSystem, user }) => {
if (!user) { if (!user) {
return ( return (
<div className="flex flex-col items-center justify-center h-full min-h-[150px] text-center"> <div className="flex flex-col items-center justify-center h-full min-h-[150px] text-center">
<UserIcon className="w-10 h-10 text-gray-400 mb-3" /> <UserIcon className="w-10 h-10 text-gray-400 mb-3" />
<h4 className="font-semibold text-gray-700 dark:text-gray-300">Personalized Deals</h4> <h4 className="font-semibold text-gray-700 dark:text-gray-300">Personalized Deals</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1"> <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Log in to see active deals for items on your watchlist. Log in to see active deals for items on your watchlist.
</p> </p>
</div> </div>
); );
} }
@@ -30,7 +30,12 @@ export const PriceChart: React.FC<PriceChartProps> = ({ unitSystem, user }) => {
if (isLoading) { if (isLoading) {
return ( return (
<div role="status" className="flex justify-center items-center h-full min-h-[100px]"> <div role="status" className="flex justify-center items-center h-full min-h-[100px]">
<div className="w-6 h-6 text-brand-primary"><LoadingSpinner /></div> <span className="ml-2 text-sm text-gray-500 dark:text-gray-400">Finding active deals...</span> <div className="w-6 h-6 text-brand-primary">
<LoadingSpinner />
</div>{' '}
<span className="ml-2 text-sm text-gray-500 dark:text-gray-400">
Finding active deals...
</span>
</div> </div>
); );
} }
@@ -44,7 +49,11 @@ export const PriceChart: React.FC<PriceChartProps> = ({ unitSystem, user }) => {
} }
if (deals.length === 0) { if (deals.length === 0) {
return <p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">No deals for your watched items found in any currently valid flyers.</p>; return (
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
No deals for your watched items found in any currently valid flyers.
</p>
);
} }
return ( return (
@@ -52,41 +61,59 @@ export const PriceChart: React.FC<PriceChartProps> = ({ unitSystem, user }) => {
<table className="min-w-full text-sm"> <table className="min-w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-800 sticky top-0 z-10"> <thead className="bg-gray-50 dark:bg-gray-800 sticky top-0 z-10">
<tr> <tr>
<th className="px-4 py-2 text-left font-medium text-gray-600 dark:text-gray-300">Item</th> <th className="px-4 py-2 text-left font-medium text-gray-600 dark:text-gray-300">
<th className="px-4 py-2 text-left font-medium text-gray-600 dark:text-gray-300">Store</th> Item
<th className="px-4 py-2 text-right font-medium text-gray-600 dark:text-gray-300">Price</th> </th>
<th className="px-4 py-2 text-right font-medium text-gray-600 dark:text-gray-300">Unit Price</th> <th className="px-4 py-2 text-left font-medium text-gray-600 dark:text-gray-300">
Store
</th>
<th className="px-4 py-2 text-right font-medium text-gray-600 dark:text-gray-300">
Price
</th>
<th className="px-4 py-2 text-right font-medium text-gray-600 dark:text-gray-300">
Unit Price
</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700"> <tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{deals.map((deal, index) => { {deals.map((deal, index) => {
// The formatUnitPrice function returns an object { price: string, unit: string }. // The formatUnitPrice function returns an object { price: string, unit: string }.
// We need to combine these into a single string for rendering and to match the test expectation. // We need to combine these into a single string for rendering and to match the test expectation.
const unitPriceData = deal.unit_price ? formatUnitPrice(deal.unit_price, unitSystem) : null; const unitPriceData = deal.unit_price
? formatUnitPrice(deal.unit_price, unitSystem)
: null;
const formattedUnitPriceString = unitPriceData const formattedUnitPriceString = unitPriceData
? `${unitPriceData.price}${unitPriceData.unit}` ? `${unitPriceData.price}${unitPriceData.unit}`
: 'N/A'; : 'N/A';
return ( return (
<tr key={`${deal.item}-${deal.storeName}-${index}`} className="hover:bg-gray-50 dark:hover:bg-gray-800/50"> <tr
key={`${deal.item}-${deal.storeName}-${index}`}
className="hover:bg-gray-50 dark:hover:bg-gray-800/50"
>
<td className="px-4 py-2 font-semibold text-gray-900 dark:text-white"> <td className="px-4 py-2 font-semibold text-gray-900 dark:text-white">
<div className="flex justify-between items-baseline"> <div className="flex justify-between items-baseline">
<span>{deal.item}</span> <span>{deal.item}</span>
{deal.master_item_name && deal.master_item_name.toLowerCase() !== deal.item.toLowerCase() && ( {deal.master_item_name &&
<span className="ml-2 text-xs font-normal italic text-gray-500 dark:text-gray-400 whitespace-nowrap"> deal.master_item_name.toLowerCase() !== deal.item.toLowerCase() && (
({deal.master_item_name}) <span className="ml-2 text-xs font-normal italic text-gray-500 dark:text-gray-400 whitespace-nowrap">
</span> ({deal.master_item_name})
)} </span>
)}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 font-normal">
{deal.quantity}
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400 font-normal">{deal.quantity}</div>
</td> </td>
<td className="px-4 py-2 text-left text-gray-700 dark:text-gray-200">{deal.storeName}</td> <td className="px-4 py-2 text-left text-gray-700 dark:text-gray-200">
<td className="px-4 py-2 text-right text-gray-700 dark:text-gray-200">{deal.price_display}</td> {deal.storeName}
<td className="px-4 py-2 text-right">
{formattedUnitPriceString}
</td> </td>
<td className="px-4 py-2 text-right text-gray-700 dark:text-gray-200">
{deal.price_display}
</td>
<td className="px-4 py-2 text-right">{formattedUnitPriceString}</td>
</tr> </tr>
) );
})} })}
</tbody> </tbody>
</table> </table>

View File

@@ -6,7 +6,10 @@ import { PriceHistoryChart } from './PriceHistoryChart';
import { useUserData } from '../../hooks/useUserData'; import { useUserData } from '../../hooks/useUserData';
import * as apiClient from '../../services/apiClient'; import * as apiClient from '../../services/apiClient';
import type { MasterGroceryItem, HistoricalPriceDataPoint } from '../../types'; import type { MasterGroceryItem, HistoricalPriceDataPoint } from '../../types';
import { createMockMasterGroceryItem, createMockHistoricalPriceDataPoint } from '../../tests/utils/mockFactories'; import {
createMockMasterGroceryItem,
createMockHistoricalPriceDataPoint,
} from '../../tests/utils/mockFactories';
// Mock the apiClient // Mock the apiClient
vi.mock('../../services/apiClient'); vi.mock('../../services/apiClient');
@@ -24,19 +27,24 @@ vi.mock('../../services/logger', () => ({
// Mock the recharts library to prevent rendering complex SVGs in jsdom // Mock the recharts library to prevent rendering complex SVGs in jsdom
vi.mock('recharts', () => ({ vi.mock('recharts', () => ({
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => <div data-testid="responsive-container">{children}</div>, ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
<div data-testid="responsive-container">{children}</div>
),
// Expose the data prop for testing data transformations // Expose the data prop for testing data transformations
LineChart: ({ children, data }: { children: React.ReactNode; data: any[] }) => ( LineChart: ({ children, data }: { children: React.ReactNode; data: any[] }) => (
<div data-testid="line-chart" data-chartdata={JSON.stringify(data)}> <div data-testid="line-chart" data-chartdata={JSON.stringify(data)}>
{children} {children}
</div>), </div>
),
CartesianGrid: () => <div data-testid="cartesian-grid" />, CartesianGrid: () => <div data-testid="cartesian-grid" />,
XAxis: () => <div data-testid="x-axis" />, XAxis: () => <div data-testid="x-axis" />,
YAxis: () => <div data-testid="y-axis" />, YAxis: () => <div data-testid="y-axis" />,
Tooltip: () => <div data-testid="tooltip" />, Tooltip: () => <div data-testid="tooltip" />,
Legend: () => <div data-testid="legend" />, Legend: () => <div data-testid="legend" />,
// Fix: Use dataKey if name is not explicitly provided, as the component relies on dataKey // Fix: Use dataKey if name is not explicitly provided, as the component relies on dataKey
Line: ({ name, dataKey }: { name?: string; dataKey?: string }) => <div data-testid={`line-${name || dataKey}`} />, Line: ({ name, dataKey }: { name?: string; dataKey?: string }) => (
<div data-testid={`line-${name || dataKey}`} />
),
})); }));
const mockWatchedItems: MasterGroceryItem[] = [ const mockWatchedItems: MasterGroceryItem[] = [
@@ -45,10 +53,26 @@ const mockWatchedItems: MasterGroceryItem[] = [
]; ];
const mockPriceHistory: HistoricalPriceDataPoint[] = [ const mockPriceHistory: HistoricalPriceDataPoint[] = [
createMockHistoricalPriceDataPoint({ master_item_id: 1, summary_date: '2024-10-01', avg_price_in_cents: 110 }), createMockHistoricalPriceDataPoint({
createMockHistoricalPriceDataPoint({ master_item_id: 1, summary_date: '2024-10-08', avg_price_in_cents: 99 }), master_item_id: 1,
createMockHistoricalPriceDataPoint({ master_item_id: 2, summary_date: '2024-10-01', avg_price_in_cents: 350 }), summary_date: '2024-10-01',
createMockHistoricalPriceDataPoint({ master_item_id: 2, summary_date: '2024-10-08', avg_price_in_cents: 349 }), avg_price_in_cents: 110,
}),
createMockHistoricalPriceDataPoint({
master_item_id: 1,
summary_date: '2024-10-08',
avg_price_in_cents: 99,
}),
createMockHistoricalPriceDataPoint({
master_item_id: 2,
summary_date: '2024-10-01',
avg_price_in_cents: 350,
}),
createMockHistoricalPriceDataPoint({
master_item_id: 2,
summary_date: '2024-10-08',
avg_price_in_cents: 349,
}),
]; ];
describe('PriceHistoryChart', () => { describe('PriceHistoryChart', () => {
@@ -75,7 +99,9 @@ describe('PriceHistoryChart', () => {
error: null, error: null,
}); });
render(<PriceHistoryChart />); render(<PriceHistoryChart />);
expect(screen.getByText('Add items to your watchlist to see their price trends over time.')).toBeInTheDocument(); expect(
screen.getByText('Add items to your watchlist to see their price trends over time.'),
).toBeInTheDocument();
}); });
it('should display a loading state while fetching data', () => { it('should display a loading state while fetching data', () => {
@@ -95,16 +121,24 @@ describe('PriceHistoryChart', () => {
}); });
it('should display a message if no historical data is returned', async () => { it('should display a message if no historical data is returned', async () => {
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(new Response(JSON.stringify([]))); vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(
new Response(JSON.stringify([])),
);
render(<PriceHistoryChart />); render(<PriceHistoryChart />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Not enough historical data for your watched items. Process more flyers to build a trend.')).toBeInTheDocument(); expect(
screen.getByText(
'Not enough historical data for your watched items. Process more flyers to build a trend.',
),
).toBeInTheDocument();
}); });
}); });
it('should render the chart with data on successful fetch', async () => { it('should render the chart with data on successful fetch', async () => {
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(new Response(JSON.stringify(mockPriceHistory))); vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(
new Response(JSON.stringify(mockPriceHistory)),
);
render(<PriceHistoryChart />); render(<PriceHistoryChart />);
await waitFor(() => { await waitFor(() => {
@@ -139,7 +173,9 @@ describe('PriceHistoryChart', () => {
}); });
it('should clear the chart when the watchlist becomes empty', async () => { it('should clear the chart when the watchlist becomes empty', async () => {
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(new Response(JSON.stringify(mockPriceHistory))); vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(
new Response(JSON.stringify(mockPriceHistory)),
);
const { rerender } = render(<PriceHistoryChart />); const { rerender } = render(<PriceHistoryChart />);
// Initial render with items // Initial render with items
@@ -160,18 +196,34 @@ describe('PriceHistoryChart', () => {
// Chart should be gone, placeholder should appear // Chart should be gone, placeholder should appear
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Add items to your watchlist to see their price trends over time.')).toBeInTheDocument(); expect(
screen.getByText('Add items to your watchlist to see their price trends over time.'),
).toBeInTheDocument();
expect(screen.queryByTestId('line-chart')).not.toBeInTheDocument(); expect(screen.queryByTestId('line-chart')).not.toBeInTheDocument();
}); });
}); });
it('should filter out items with only one data point', async () => { it('should filter out items with only one data point', async () => {
const dataWithSinglePoint: HistoricalPriceDataPoint[] = [ const dataWithSinglePoint: HistoricalPriceDataPoint[] = [
createMockHistoricalPriceDataPoint({ master_item_id: 1, summary_date: '2024-10-01', avg_price_in_cents: 110 }), createMockHistoricalPriceDataPoint({
createMockHistoricalPriceDataPoint({ master_item_id: 1, summary_date: '2024-10-08', avg_price_in_cents: 99 }), master_item_id: 1,
createMockHistoricalPriceDataPoint({ master_item_id: 2, summary_date: '2024-10-01', avg_price_in_cents: 350 }), // Almond Milk only has one point summary_date: '2024-10-01',
avg_price_in_cents: 110,
}),
createMockHistoricalPriceDataPoint({
master_item_id: 1,
summary_date: '2024-10-08',
avg_price_in_cents: 99,
}),
createMockHistoricalPriceDataPoint({
master_item_id: 2,
summary_date: '2024-10-01',
avg_price_in_cents: 350,
}), // Almond Milk only has one point
]; ];
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(new Response(JSON.stringify(dataWithSinglePoint))); vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(
new Response(JSON.stringify(dataWithSinglePoint)),
);
render(<PriceHistoryChart />); render(<PriceHistoryChart />);
await waitFor(() => { await waitFor(() => {
@@ -182,11 +234,25 @@ describe('PriceHistoryChart', () => {
it('should process data to only keep the lowest price for a given day', async () => { it('should process data to only keep the lowest price for a given day', async () => {
const dataWithDuplicateDate: HistoricalPriceDataPoint[] = [ const dataWithDuplicateDate: HistoricalPriceDataPoint[] = [
createMockHistoricalPriceDataPoint({ master_item_id: 1, summary_date: '2024-10-01', avg_price_in_cents: 110 }), createMockHistoricalPriceDataPoint({
createMockHistoricalPriceDataPoint({ master_item_id: 1, summary_date: '2024-10-01', avg_price_in_cents: 105 }), // Lower price master_item_id: 1,
createMockHistoricalPriceDataPoint({ master_item_id: 1, summary_date: '2024-10-08', avg_price_in_cents: 99 }), summary_date: '2024-10-01',
avg_price_in_cents: 110,
}),
createMockHistoricalPriceDataPoint({
master_item_id: 1,
summary_date: '2024-10-01',
avg_price_in_cents: 105,
}), // Lower price
createMockHistoricalPriceDataPoint({
master_item_id: 1,
summary_date: '2024-10-08',
avg_price_in_cents: 99,
}),
]; ];
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(new Response(JSON.stringify(dataWithDuplicateDate))); vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(
new Response(JSON.stringify(dataWithDuplicateDate)),
);
render(<PriceHistoryChart />); render(<PriceHistoryChart />);
await waitFor(() => { await waitFor(() => {
@@ -202,11 +268,25 @@ describe('PriceHistoryChart', () => {
it('should filter out data points with a price of zero', async () => { it('should filter out data points with a price of zero', async () => {
const dataWithZeroPrice: HistoricalPriceDataPoint[] = [ const dataWithZeroPrice: HistoricalPriceDataPoint[] = [
createMockHistoricalPriceDataPoint({ master_item_id: 1, summary_date: '2024-10-01', avg_price_in_cents: 110 }), createMockHistoricalPriceDataPoint({
createMockHistoricalPriceDataPoint({ master_item_id: 1, summary_date: '2024-10-08', avg_price_in_cents: 0 }), // Zero price should be filtered master_item_id: 1,
createMockHistoricalPriceDataPoint({ master_item_id: 1, summary_date: '2024-10-15', avg_price_in_cents: 105 }), summary_date: '2024-10-01',
avg_price_in_cents: 110,
}),
createMockHistoricalPriceDataPoint({
master_item_id: 1,
summary_date: '2024-10-08',
avg_price_in_cents: 0,
}), // Zero price should be filtered
createMockHistoricalPriceDataPoint({
master_item_id: 1,
summary_date: '2024-10-15',
avg_price_in_cents: 105,
}),
]; ];
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(new Response(JSON.stringify(dataWithZeroPrice))); vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(
new Response(JSON.stringify(dataWithZeroPrice)),
);
render(<PriceHistoryChart />); render(<PriceHistoryChart />);
await waitFor(() => { await waitFor(() => {

View File

@@ -1,6 +1,15 @@
// src/components/PriceHistoryChart.tsx // src/components/PriceHistoryChart.tsx
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import * as apiClient from '../../services/apiClient'; import * as apiClient from '../../services/apiClient';
import { LoadingSpinner } from '../../components/LoadingSpinner'; // This path is correct import { LoadingSpinner } from '../../components/LoadingSpinner'; // This path is correct
import { useUserData } from '../../hooks/useUserData'; import { useUserData } from '../../hooks/useUserData';
@@ -17,62 +26,83 @@ export const PriceHistoryChart: React.FC = () => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const watchedItemsMap = useMemo(() => new Map(watchedItems.map(item => [item.master_grocery_item_id, item.name])), [watchedItems]); const watchedItemsMap = useMemo(
() => new Map(watchedItems.map((item) => [item.master_grocery_item_id, item.name])),
[watchedItems],
);
useEffect(() => { useEffect(() => {
if (watchedItems.length === 0) { if (watchedItems.length === 0) {
setIsLoading(false); setIsLoading(false);
setHistoricalData({}); // Clear data if watchlist becomes empty setHistoricalData({}); // Clear data if watchlist becomes empty
return; return;
} }
const fetchData = async () => { const fetchData = async () => {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { try {
const watchedItemIds = watchedItems.map(item => item.master_grocery_item_id).filter((id): id is number => id !== undefined); // Ensure only numbers are passed const watchedItemIds = watchedItems
.map((item) => item.master_grocery_item_id)
.filter((id): id is number => id !== undefined); // Ensure only numbers are passed
const response = await apiClient.fetchHistoricalPriceData(watchedItemIds); const response = await apiClient.fetchHistoricalPriceData(watchedItemIds);
const rawData: HistoricalPriceDataPoint[] = await response.json(); const rawData: HistoricalPriceDataPoint[] = await response.json();
if (rawData.length === 0) { if (rawData.length === 0) {
setHistoricalData({}); setHistoricalData({});
return; return;
} }
const processedData = rawData.reduce<HistoricalData>((acc, record: HistoricalPriceDataPoint) => { const processedData = rawData.reduce<HistoricalData>(
if (!record.master_item_id || record.avg_price_in_cents === null || !record.summary_date) return acc; (acc, record: HistoricalPriceDataPoint) => {
if (
!record.master_item_id ||
record.avg_price_in_cents === null ||
!record.summary_date
)
return acc;
const itemName = watchedItemsMap.get(record.master_item_id); const itemName = watchedItemsMap.get(record.master_item_id);
if (!itemName) return acc; if (!itemName) return acc;
const priceInCents = record.avg_price_in_cents; const priceInCents = record.avg_price_in_cents;
const date = new Date(`${record.summary_date}T00:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); const date = new Date(`${record.summary_date}T00:00:00`).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
if(priceInCents === 0) return acc; if (priceInCents === 0) return acc;
if (!acc[itemName]) { if (!acc[itemName]) {
acc[itemName] = []; acc[itemName] = [];
}
// Ensure we only store the LOWEST price for a given day
const existingEntryIndex = acc[itemName].findIndex(entry => entry.date === date);
if (existingEntryIndex > -1) {
if (priceInCents < acc[itemName][existingEntryIndex].price) {
acc[itemName][existingEntryIndex].price = priceInCents;
} }
} else {
acc[itemName].push({ date, price: priceInCents });
}
return acc; // Ensure we only store the LOWEST price for a given day
}, {}); const existingEntryIndex = acc[itemName].findIndex((entry) => entry.date === date);
if (existingEntryIndex > -1) {
if (priceInCents < acc[itemName][existingEntryIndex].price) {
acc[itemName][existingEntryIndex].price = priceInCents;
}
} else {
acc[itemName].push({ date, price: priceInCents });
}
return acc;
},
{},
);
// Filter out items that only have one data point for a meaningful trend line // Filter out items that only have one data point for a meaningful trend line
const filteredData = Object.entries(processedData).reduce<HistoricalData>((acc, [key, value]) => { const filteredData = Object.entries(processedData).reduce<HistoricalData>(
if(value.length > 1){ (acc, [key, value]) => {
acc[key] = value.sort((a,b) => new Date(a.date).getTime() - new Date(b.date).getTime()); if (value.length > 1) {
acc[key] = value.sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
);
} }
return acc; return acc;
}, {}); },
{},
);
setHistoricalData(filteredData); setHistoricalData(filteredData);
} catch (e) { } catch (e) {
@@ -92,7 +122,7 @@ export const PriceHistoryChart: React.FC = () => {
const dateMap: Map<string, ChartData> = new Map(); const dateMap: Map<string, ChartData> = new Map();
availableItems.forEach(itemName => { availableItems.forEach((itemName) => {
historicalData[itemName]?.forEach(({ date, price }) => { historicalData[itemName]?.forEach(({ date, price }) => {
if (!dateMap.has(date)) { if (!dateMap.has(date)) {
dateMap.set(date, { date }); dateMap.set(date, { date });
@@ -102,89 +132,97 @@ export const PriceHistoryChart: React.FC = () => {
}); });
}); });
return Array.from(dateMap.values()).sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); return Array.from(dateMap.values()).sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
);
}, [historicalData]); }, [historicalData]);
const availableItems = Object.keys(historicalData); const availableItems = Object.keys(historicalData);
const renderContent = () => { const renderContent = () => {
if (isLoading || isLoadingUserData) { if (isLoading || isLoadingUserData) {
return ( return (
<div role="status" className="flex justify-center items-center h-full min-h-[200px]"> <div role="status" className="flex justify-center items-center h-full min-h-[200px]">
<LoadingSpinner /> <span className="ml-2">Loading Price History...</span> <LoadingSpinner /> <span className="ml-2">Loading Price History...</span>
</div> </div>
); );
} }
if (error) { if (error) {
return ( return (
<div className="bg-red-100 dark:bg-red-900/50 border border-red-400 dark:border-red-600 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg relative h-full flex items-center justify-center" role="alert"> <div
<p><strong>Error:</strong> {error}</p> className="bg-red-100 dark:bg-red-900/50 border border-red-400 dark:border-red-600 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg relative h-full flex items-center justify-center"
</div> role="alert"
); >
<p>
<strong>Error:</strong> {error}
</p>
</div>
);
} }
if (watchedItems.length === 0) { if (watchedItems.length === 0) {
return ( return (
<div className="text-center py-8 h-full flex flex-col justify-center"> <div className="text-center py-8 h-full flex flex-col justify-center">
<p className="text-gray-500 dark:text-gray-400">Add items to your watchlist to see their price trends over time.</p> <p className="text-gray-500 dark:text-gray-400">
</div> Add items to your watchlist to see their price trends over time.
); </p>
</div>
);
} }
if (availableItems.length === 0) { if (availableItems.length === 0) {
return ( return (
<div className="text-center py-8 h-full flex flex-col justify-center"> <div className="text-center py-8 h-full flex flex-col justify-center">
<p className="text-gray-500 dark:text-gray-400">Not enough historical data for your watched items. Process more flyers to build a trend.</p> <p className="text-gray-500 dark:text-gray-400">
</div> Not enough historical data for your watched items. Process more flyers to build a trend.
); </p>
</div>
);
} }
return ( return (
<ResponsiveContainer> <ResponsiveContainer>
<LineChart <LineChart data={chartData} margin={{ top: 5, right: 20, left: -10, bottom: 5 }}>
data={chartData} <CartesianGrid strokeDasharray="3 3" stroke="rgba(128, 128, 128, 0.2)" />
margin={{ top: 5, right: 20, left: -10, bottom: 5 }} <XAxis dataKey="date" tick={{ fill: '#9CA3AF', fontSize: 12 }} />
> <YAxis
<CartesianGrid strokeDasharray="3 3" stroke="rgba(128, 128, 128, 0.2)" /> tick={{ fill: '#9CA3AF', fontSize: 12 }}
<XAxis dataKey="date" tick={{ fill: '#9CA3AF', fontSize: 12 }} /> tickFormatter={(value: number) => `$${(value / 100).toFixed(2)}`}
<YAxis domain={[(a: number) => Math.floor(a * 0.95), (b: number) => Math.ceil(b * 1.05)]}
tick={{ fill: '#9CA3AF', fontSize: 12 }} />
tickFormatter={(value: number) => `$${(value / 100).toFixed(2)}`} <Tooltip
domain={[(a: number) => Math.floor(a * 0.95), (b: number) => Math.ceil(b * 1.05)]} contentStyle={{
backgroundColor: 'rgba(31, 41, 55, 0.9)',
border: '1px solid #4B5563',
borderRadius: '0.5rem',
}}
labelStyle={{ color: '#F9FAFB' }}
formatter={(value: number) => `$${(value / 100).toFixed(2)}`}
/>
<Legend wrapperStyle={{ fontSize: '12px' }} />
{availableItems.map((item, index) => (
<Line
key={item}
type="monotone"
dataKey={item}
stroke={COLORS[index % COLORS.length]}
strokeWidth={2}
dot={{ r: 4 }}
connectNulls
/> />
<Tooltip ))}
contentStyle={{
backgroundColor: 'rgba(31, 41, 55, 0.9)',
border: '1px solid #4B5563',
borderRadius: '0.5rem',
}}
labelStyle={{ color: '#F9FAFB' }}
formatter={(value: number) => `$${(value / 100).toFixed(2)}`}
/>
<Legend wrapperStyle={{fontSize: "12px"}} />
{availableItems.map((item, index) => (
<Line
key={item}
type="monotone"
dataKey={item}
stroke={COLORS[index % COLORS.length]}
strokeWidth={2}
dot={{ r: 4 }}
connectNulls
/>
))}
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
) );
} };
return ( return (
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-4"> <div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-semibold mb-4 text-gray-800 dark:text-white">Historical Price Trends</h3> <h3 className="text-lg font-semibold mb-4 text-gray-800 dark:text-white">
<div style={{ width: '100%', height: 300 }}> Historical Price Trends
{renderContent()} </h3>
</div> <div style={{ width: '100%', height: 300 }}>{renderContent()}</div>
</div> </div>
); );
}; };

View File

@@ -8,21 +8,171 @@ import { createMockFlyerItem } from '../../tests/utils/mockFactories';
describe('TopDeals', () => { describe('TopDeals', () => {
const mockFlyerItems: FlyerItem[] = [ const mockFlyerItems: FlyerItem[] = [
createMockFlyerItem({ flyer_item_id: 1, item: 'Apples', price_display: '$1.00', price_in_cents: 100, quantity: '1 lb', category_name: 'Produce', flyer_id: 1, master_item_id: 1, unit_price: { value: 100, unit: 'lb' } }), createMockFlyerItem({
createMockFlyerItem({ flyer_item_id: 2, item: 'Milk', price_display: '$2.50', price_in_cents: 250, quantity: '1L', category_name: 'Dairy', flyer_id: 1, master_item_id: 2, unit_price: { value: 250, unit: 'L' } }), flyer_item_id: 1,
createMockFlyerItem({ flyer_item_id: 3, item: 'Bread', price_display: '$2.00', price_in_cents: 200, quantity: '1 loaf', category_name: 'Bakery', flyer_id: 1, master_item_id: 3, unit_price: { value: 200, unit: 'count' } }), item: 'Apples',
createMockFlyerItem({ flyer_item_id: 4, item: 'Eggs', price_display: '$3.00', price_in_cents: 300, quantity: '1 dozen', category_name: 'Dairy', flyer_id: 1, master_item_id: 4, unit_price: { value: 25, unit: 'count' } }), price_display: '$1.00',
createMockFlyerItem({ flyer_item_id: 5, item: 'Cheese', price_display: '$4.00', price_in_cents: 400, quantity: '200g', category_name: 'Dairy', flyer_id: 1, master_item_id: 5, unit_price: { value: 200, unit: '100g' } }), price_in_cents: 100,
createMockFlyerItem({ flyer_item_id: 6, item: 'Yogurt', price_display: '$1.50', price_in_cents: 150, quantity: '500g', category_name: 'Dairy', flyer_id: 1, master_item_id: 6, unit_price: { value: 30, unit: '100g' } }), quantity: '1 lb',
createMockFlyerItem({ flyer_item_id: 7, item: 'Oranges', price_display: '$1.20', price_in_cents: 120, quantity: '1 lb', category_name: 'Produce', flyer_id: 1, master_item_id: 7, unit_price: { value: 120, unit: 'lb' } }), category_name: 'Produce',
createMockFlyerItem({ flyer_item_id: 8, item: 'Cereal', price_display: '$3.50', price_in_cents: 350, quantity: '300g', category_name: 'Breakfast', flyer_id: 1, master_item_id: 8, unit_price: { value: 117, unit: '100g' } }), flyer_id: 1,
createMockFlyerItem({ flyer_item_id: 9, item: 'Coffee', price_display: '$5.00', price_in_cents: 500, quantity: '250g', category_name: 'Beverages', flyer_id: 1, master_item_id: 9, unit_price: { value: 200, unit: '100g' } }), master_item_id: 1,
createMockFlyerItem({ flyer_item_id: 10, item: 'Tea', price_display: '$2.20', price_in_cents: 220, quantity: '20 bags', category_name: 'Beverages', flyer_id: 1, master_item_id: 10, unit_price: { value: 11, unit: 'count' } }), unit_price: { value: 100, unit: 'lb' },
createMockFlyerItem({ flyer_item_id: 11, item: 'Pasta', price_display: '$1.80', price_in_cents: 180, quantity: '500g', category_name: 'Pantry', flyer_id: 1, master_item_id: 11, unit_price: { value: 36, unit: '100g' } }), }),
createMockFlyerItem({ flyer_item_id: 12, item: 'Water', price_display: '$0.99', price_in_cents: 99, quantity: '1L', category_name: 'Beverages', flyer_id: 1, master_item_id: 12, unit_price: { value: 99, unit: 'L' } }), createMockFlyerItem({
createMockFlyerItem({ flyer_item_id: 13, item: 'Soda', price_display: '$0.75', price_in_cents: 75, quantity: '355ml', category_name: 'Beverages', flyer_id: 1, master_item_id: 13, unit_price: { value: 21, unit: '100ml' } }), flyer_item_id: 2,
createMockFlyerItem({ flyer_item_id: 14, item: 'Chips', price_display: '$2.10', price_in_cents: 210, quantity: '150g', category_name: 'Snacks', flyer_id: 1, master_item_id: 14, unit_price: { value: 140, unit: '100g' } }), item: 'Milk',
createMockFlyerItem({ flyer_item_id: 15, item: 'Candy', price_display: '$0.50', price_in_cents: 50, quantity: '50g', category_name: 'Snacks', flyer_id: 1, master_item_id: 15, unit_price: { value: 100, unit: '100g' } }), price_display: '$2.50',
price_in_cents: 250,
quantity: '1L',
category_name: 'Dairy',
flyer_id: 1,
master_item_id: 2,
unit_price: { value: 250, unit: 'L' },
}),
createMockFlyerItem({
flyer_item_id: 3,
item: 'Bread',
price_display: '$2.00',
price_in_cents: 200,
quantity: '1 loaf',
category_name: 'Bakery',
flyer_id: 1,
master_item_id: 3,
unit_price: { value: 200, unit: 'count' },
}),
createMockFlyerItem({
flyer_item_id: 4,
item: 'Eggs',
price_display: '$3.00',
price_in_cents: 300,
quantity: '1 dozen',
category_name: 'Dairy',
flyer_id: 1,
master_item_id: 4,
unit_price: { value: 25, unit: 'count' },
}),
createMockFlyerItem({
flyer_item_id: 5,
item: 'Cheese',
price_display: '$4.00',
price_in_cents: 400,
quantity: '200g',
category_name: 'Dairy',
flyer_id: 1,
master_item_id: 5,
unit_price: { value: 200, unit: '100g' },
}),
createMockFlyerItem({
flyer_item_id: 6,
item: 'Yogurt',
price_display: '$1.50',
price_in_cents: 150,
quantity: '500g',
category_name: 'Dairy',
flyer_id: 1,
master_item_id: 6,
unit_price: { value: 30, unit: '100g' },
}),
createMockFlyerItem({
flyer_item_id: 7,
item: 'Oranges',
price_display: '$1.20',
price_in_cents: 120,
quantity: '1 lb',
category_name: 'Produce',
flyer_id: 1,
master_item_id: 7,
unit_price: { value: 120, unit: 'lb' },
}),
createMockFlyerItem({
flyer_item_id: 8,
item: 'Cereal',
price_display: '$3.50',
price_in_cents: 350,
quantity: '300g',
category_name: 'Breakfast',
flyer_id: 1,
master_item_id: 8,
unit_price: { value: 117, unit: '100g' },
}),
createMockFlyerItem({
flyer_item_id: 9,
item: 'Coffee',
price_display: '$5.00',
price_in_cents: 500,
quantity: '250g',
category_name: 'Beverages',
flyer_id: 1,
master_item_id: 9,
unit_price: { value: 200, unit: '100g' },
}),
createMockFlyerItem({
flyer_item_id: 10,
item: 'Tea',
price_display: '$2.20',
price_in_cents: 220,
quantity: '20 bags',
category_name: 'Beverages',
flyer_id: 1,
master_item_id: 10,
unit_price: { value: 11, unit: 'count' },
}),
createMockFlyerItem({
flyer_item_id: 11,
item: 'Pasta',
price_display: '$1.80',
price_in_cents: 180,
quantity: '500g',
category_name: 'Pantry',
flyer_id: 1,
master_item_id: 11,
unit_price: { value: 36, unit: '100g' },
}),
createMockFlyerItem({
flyer_item_id: 12,
item: 'Water',
price_display: '$0.99',
price_in_cents: 99,
quantity: '1L',
category_name: 'Beverages',
flyer_id: 1,
master_item_id: 12,
unit_price: { value: 99, unit: 'L' },
}),
createMockFlyerItem({
flyer_item_id: 13,
item: 'Soda',
price_display: '$0.75',
price_in_cents: 75,
quantity: '355ml',
category_name: 'Beverages',
flyer_id: 1,
master_item_id: 13,
unit_price: { value: 21, unit: '100ml' },
}),
createMockFlyerItem({
flyer_item_id: 14,
item: 'Chips',
price_display: '$2.10',
price_in_cents: 210,
quantity: '150g',
category_name: 'Snacks',
flyer_id: 1,
master_item_id: 14,
unit_price: { value: 140, unit: '100g' },
}),
createMockFlyerItem({
flyer_item_id: 15,
item: 'Candy',
price_display: '$0.50',
price_in_cents: 50,
quantity: '50g',
category_name: 'Snacks',
flyer_id: 1,
master_item_id: 15,
unit_price: { value: 100, unit: '100g' },
}),
]; ];
it('should not render if the items array is empty', () => { it('should not render if the items array is empty', () => {
@@ -32,8 +182,18 @@ describe('TopDeals', () => {
it('should not render if no items have price_in_cents', () => { it('should not render if no items have price_in_cents', () => {
const itemsWithoutPrices: FlyerItem[] = [ const itemsWithoutPrices: FlyerItem[] = [
createMockFlyerItem({ flyer_item_id: 1, item: 'Free Sample', price_display: 'FREE', price_in_cents: null }), createMockFlyerItem({
createMockFlyerItem({ flyer_item_id: 2, item: 'Info Brochure', price_display: '', price_in_cents: null }), flyer_item_id: 1,
item: 'Free Sample',
price_display: 'FREE',
price_in_cents: null,
}),
createMockFlyerItem({
flyer_item_id: 2,
item: 'Info Brochure',
price_display: '',
price_in_cents: null,
}),
]; ];
const { container } = render(<TopDeals items={itemsWithoutPrices} />); const { container } = render(<TopDeals items={itemsWithoutPrices} />);
expect(container.firstChild).toBeNull(); expect(container.firstChild).toBeNull();
@@ -41,7 +201,9 @@ describe('TopDeals', () => {
it('should render the correct heading', () => { it('should render the correct heading', () => {
render(<TopDeals items={mockFlyerItems.slice(0, 5)} />); render(<TopDeals items={mockFlyerItems.slice(0, 5)} />);
expect(screen.getByRole('heading', { name: /top 10 deals across all flyers/i })).toBeInTheDocument(); expect(
screen.getByRole('heading', { name: /top 10 deals across all flyers/i }),
).toBeInTheDocument();
}); });
it('should display up to 10 items, sorted by price_in_cents ascending', () => { it('should display up to 10 items, sorted by price_in_cents ascending', () => {

View File

@@ -7,10 +7,9 @@ interface TopDealsProps {
} }
export const TopDeals: React.FC<TopDealsProps> = ({ items }) => { export const TopDeals: React.FC<TopDealsProps> = ({ items }) => {
const topDeals = useMemo(() => { const topDeals = useMemo(() => {
return [...items] return [...items]
.filter(item => item.price_in_cents !== null) // Only include items with a parseable price .filter((item) => item.price_in_cents !== null) // Only include items with a parseable price
.sort((a, b) => (a.price_in_cents ?? Infinity) - (b.price_in_cents ?? Infinity)) .sort((a, b) => (a.price_in_cents ?? Infinity) - (b.price_in_cents ?? Infinity))
.slice(0, 10); .slice(0, 10);
}, [items]); }, [items]);
@@ -25,8 +24,13 @@ export const TopDeals: React.FC<TopDealsProps> = ({ items }) => {
</h3> </h3>
<ul className="space-y-2"> <ul className="space-y-2">
{topDeals.map((item, index) => ( {topDeals.map((item, index) => (
<li key={index} className="grid grid-cols-3 gap-2 items-center text-sm bg-white dark:bg-gray-800 p-2 rounded"> <li
<span className="font-semibold text-gray-800 dark:text-gray-200 col-span-2 truncate">{item.item}</span> key={index}
className="grid grid-cols-3 gap-2 items-center text-sm bg-white dark:bg-gray-800 p-2 rounded"
>
<span className="font-semibold text-gray-800 dark:text-gray-200 col-span-2 truncate">
{item.item}
</span>
<span className="font-bold text-brand-primary text-right">{item.price_display}</span> <span className="font-bold text-brand-primary text-right">{item.price_display}</span>
<span className="text-xs text-gray-500 dark:text-gray-400 col-span-3 truncate italic"> <span className="text-xs text-gray-500 dark:text-gray-400 col-span-3 truncate italic">
(Qty: {item.quantity}) (Qty: {item.quantity})

View File

@@ -7,7 +7,12 @@ import { useFlyerItems } from '../../hooks/useFlyerItems';
import type { Flyer, FlyerItem, Store, MasterGroceryItem } from '../../types'; import type { Flyer, FlyerItem, Store, MasterGroceryItem } from '../../types';
import { useUserData } from '../../hooks/useUserData'; import { useUserData } from '../../hooks/useUserData';
import { useAiAnalysis } from '../../hooks/useAiAnalysis'; import { useAiAnalysis } from '../../hooks/useAiAnalysis';
import { createMockFlyer, createMockFlyerItem, createMockMasterGroceryItem, createMockStore } from '../../tests/utils/mockFactories'; import {
createMockFlyer,
createMockFlyerItem,
createMockMasterGroceryItem,
createMockStore,
} from '../../tests/utils/mockFactories';
// Mock the logger // Mock the logger
vi.mock('../../services/logger.client', () => ({ vi.mock('../../services/logger.client', () => ({
@@ -35,13 +40,24 @@ vi.mock('../../hooks/useAiAnalysis');
const mockedUseAiAnalysis = useAiAnalysis as Mock; const mockedUseAiAnalysis = useAiAnalysis as Mock;
// Mock the new icon // Mock the new icon
vi.mock('../../components/icons/ScaleIcon', () => ({ ScaleIcon: () => <div data-testid="scale-icon" /> })); vi.mock('../../components/icons/ScaleIcon', () => ({
ScaleIcon: () => <div data-testid="scale-icon" />,
}));
// Mock functions to be returned by the useAiAnalysis hook // Mock functions to be returned by the useAiAnalysis hook
const mockRunAnalysis = vi.fn(); const mockRunAnalysis = vi.fn();
const mockGenerateImage = vi.fn(); const mockGenerateImage = vi.fn();
const mockFlyerItems: FlyerItem[] = [createMockFlyerItem({ flyer_item_id: 1, item: 'Apples', price_display: '$1.99', price_in_cents: 199, quantity: '1lb', flyer_id: 1 })]; const mockFlyerItems: FlyerItem[] = [
createMockFlyerItem({
flyer_item_id: 1,
item: 'Apples',
price_display: '$1.99',
price_in_cents: 199,
quantity: '1lb',
flyer_id: 1,
}),
];
const mockWatchedItems: MasterGroceryItem[] = [ const mockWatchedItems: MasterGroceryItem[] = [
createMockMasterGroceryItem({ master_grocery_item_id: 101, name: 'Bananas' }), createMockMasterGroceryItem({ master_grocery_item_id: 101, name: 'Bananas' }),
@@ -51,8 +67,8 @@ const mockWatchedItems: MasterGroceryItem[] = [
const mockStore: Store = createMockStore({ store_id: 1, name: 'SuperMart' }); const mockStore: Store = createMockStore({ store_id: 1, name: 'SuperMart' });
const mockFlyer: Flyer = createMockFlyer({ const mockFlyer: Flyer = createMockFlyer({
flyer_id: 1, flyer_id: 1,
store: mockStore, store: mockStore,
}); });
describe('AnalysisPanel', () => { describe('AnalysisPanel', () => {

View File

@@ -26,26 +26,27 @@ interface AnalysisPanelProps {
export type AnalysisTabType = Exclude<AnalysisType, AnalysisType.GENERATE_IMAGE>; export type AnalysisTabType = Exclude<AnalysisType, AnalysisType.GENERATE_IMAGE>;
interface TabButtonProps { interface TabButtonProps {
label: string; label: string;
icon: React.ReactNode; icon: React.ReactNode;
isActive: boolean; isActive: boolean;
onClick: () => void; onClick: () => void;
} }
const TabButton: React.FC<TabButtonProps> = ({ label, icon, isActive, onClick }) => { const TabButton: React.FC<TabButtonProps> = ({ label, icon, isActive, onClick }) => {
const activeClasses = 'bg-brand-primary text-white'; const activeClasses = 'bg-brand-primary text-white';
const inactiveClasses = 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'; const inactiveClasses =
return ( 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600';
<button return (
// Add role="tab" for accessibility, which fixes the test query. <button
role="tab" // Add role="tab" for accessibility, which fixes the test query.
onClick={onClick} role="tab"
className={`flex-1 flex items-center justify-center space-x-2 px-4 py-2.5 text-sm font-medium rounded-md transition-colors duration-200 ${isActive ? activeClasses : inactiveClasses}`} onClick={onClick}
> className={`flex-1 flex items-center justify-center space-x-2 px-4 py-2.5 text-sm font-medium rounded-md transition-colors duration-200 ${isActive ? activeClasses : inactiveClasses}`}
{icon} >
<span>{label}</span> {icon}
</button> <span>{label}</span>
); </button>
);
}; };
export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ selectedFlyer }) => { export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ selectedFlyer }) => {
@@ -89,79 +90,124 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ selectedFlyer }) =
// Show item fetching error // Show item fetching error
if (itemsError) { if (itemsError) {
return <p className="text-red-500 text-center">Could not load flyer items: {itemsError.message}</p>; return (
<p className="text-red-500 text-center">Could not load flyer items: {itemsError.message}</p>
);
} }
const resultText = results[activeTab]; const resultText = results[activeTab];
const sourceList = sources[activeTab] || []; const sourceList = sources[activeTab] || [];
if (resultText) { if (resultText) {
const isSearchType = activeTab === AnalysisType.WEB_SEARCH || activeTab === AnalysisType.PLAN_TRIP || activeTab === AnalysisType.COMPARE_PRICES; const isSearchType =
return ( activeTab === AnalysisType.WEB_SEARCH ||
<div className="prose prose-sm dark:prose-invert max-w-none whitespace-pre-wrap"> activeTab === AnalysisType.PLAN_TRIP ||
{resultText} activeTab === AnalysisType.COMPARE_PRICES;
{isSearchType && sourceList.length > 0 && ( return (
<div className="mt-4"> <div className="prose prose-sm dark:prose-invert max-w-none whitespace-pre-wrap">
<h4 className="font-semibold">Sources:</h4> {resultText}
<ul className="list-disc pl-5"> {isSearchType && sourceList.length > 0 && (
{sourceList.map((source) => { <div className="mt-4">
if (!source.uri) return null; <h4 className="font-semibold">Sources:</h4>
return ( <ul className="list-disc pl-5">
<li key={source.uri}> {sourceList.map((source) => {
<a href={source.uri} target="_blank" rel="noopener noreferrer" className="text-brand-primary hover:underline"> if (!source.uri) return null;
{source.title} return (
</a> <li key={source.uri}>
</li> <a
) href={source.uri}
})} target="_blank"
</ul> rel="noopener noreferrer"
</div> className="text-brand-primary hover:underline"
)} >
{activeTab === AnalysisType.DEEP_DIVE && ( {source.title}
<div className="mt-6 text-center"> </a>
{generatedImageUrl ? ( </li>
<img src={generatedImageUrl} alt="AI generated meal plan" className="rounded-lg shadow-md mx-auto" /> );
) : ( })}
<button </ul>
onClick={generateImage}
disabled={loadingAnalysis === AnalysisType.GENERATE_IMAGE}
className="inline-flex items-center justify-center bg-indigo-500 hover:bg-indigo-600 disabled:bg-indigo-300 text-white font-bold py-2 px-4 rounded-lg"
>
{loadingAnalysis === AnalysisType.GENERATE_IMAGE ? <><LoadingSpinner /> <span className="ml-2">Generating...</span></> : <><ImageIcon className="w-4 h-4 mr-2"/> Generate an image for this meal plan</>}
</button>
)}
</div>
)}
</div> </div>
); )}
{activeTab === AnalysisType.DEEP_DIVE && (
<div className="mt-6 text-center">
{generatedImageUrl ? (
<img
src={generatedImageUrl}
alt="AI generated meal plan"
className="rounded-lg shadow-md mx-auto"
/>
) : (
<button
onClick={generateImage}
disabled={loadingAnalysis === AnalysisType.GENERATE_IMAGE}
className="inline-flex items-center justify-center bg-indigo-500 hover:bg-indigo-600 disabled:bg-indigo-300 text-white font-bold py-2 px-4 rounded-lg"
>
{loadingAnalysis === AnalysisType.GENERATE_IMAGE ? (
<>
<LoadingSpinner /> <span className="ml-2">Generating...</span>
</>
) : (
<>
<ImageIcon className="w-4 h-4 mr-2" /> Generate an image for this meal plan
</>
)}
</button>
)}
</div>
)}
</div>
);
} }
return ( return (
<div className="text-center py-10"> <div className="text-center py-10">
<p className="text-gray-500 mb-4">Click below to generate AI-powered insights.</p> <p className="text-gray-500 mb-4">Click below to generate AI-powered insights.</p>
<button <button
onClick={() => runAnalysis(activeTab)} onClick={() => runAnalysis(activeTab)}
className="bg-brand-secondary hover:bg-brand-dark text-white font-bold py-2 px-4 rounded-lg transition-colors duration-300" className="bg-brand-secondary hover:bg-brand-dark text-white font-bold py-2 px-4 rounded-lg transition-colors duration-300"
> >
Generate {activeTab.replace('_', ' ')} Generate {activeTab.replace('_', ' ')}
</button> </button>
</div> </div>
); );
}; };
return ( return (
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-4"> <div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="flex space-x-2 mb-4"> <div className="flex space-x-2 mb-4">
<TabButton label="Quick Insights" icon={<LightbulbIcon className="w-4 h-4" />} isActive={activeTab === AnalysisType.QUICK_INSIGHTS} onClick={() => setActiveTab(AnalysisType.QUICK_INSIGHTS)} /> <TabButton
<TabButton label="Deep Dive" icon={<BrainIcon className="w-4 h-4" />} isActive={activeTab === AnalysisType.DEEP_DIVE} onClick={() => setActiveTab(AnalysisType.DEEP_DIVE)} /> label="Quick Insights"
<TabButton label="Web Search" icon={<SearchIcon className="w-4 h-4" />} isActive={activeTab === AnalysisType.WEB_SEARCH} onClick={() => setActiveTab(AnalysisType.WEB_SEARCH)} /> icon={<LightbulbIcon className="w-4 h-4" />}
<TabButton label="Plan Trip" icon={<MapPinIcon className="w-4 h-4" />} isActive={activeTab === AnalysisType.PLAN_TRIP} onClick={() => setActiveTab(AnalysisType.PLAN_TRIP)} /> isActive={activeTab === AnalysisType.QUICK_INSIGHTS}
<TabButton label="Compare Prices" icon={<ScaleIcon className="w-4 h-4" />} isActive={activeTab === AnalysisType.COMPARE_PRICES} onClick={() => setActiveTab(AnalysisType.COMPARE_PRICES)} /> onClick={() => setActiveTab(AnalysisType.QUICK_INSIGHTS)}
/>
<TabButton
label="Deep Dive"
icon={<BrainIcon className="w-4 h-4" />}
isActive={activeTab === AnalysisType.DEEP_DIVE}
onClick={() => setActiveTab(AnalysisType.DEEP_DIVE)}
/>
<TabButton
label="Web Search"
icon={<SearchIcon className="w-4 h-4" />}
isActive={activeTab === AnalysisType.WEB_SEARCH}
onClick={() => setActiveTab(AnalysisType.WEB_SEARCH)}
/>
<TabButton
label="Plan Trip"
icon={<MapPinIcon className="w-4 h-4" />}
isActive={activeTab === AnalysisType.PLAN_TRIP}
onClick={() => setActiveTab(AnalysisType.PLAN_TRIP)}
/>
<TabButton
label="Compare Prices"
icon={<ScaleIcon className="w-4 h-4" />}
isActive={activeTab === AnalysisType.COMPARE_PRICES}
onClick={() => setActiveTab(AnalysisType.COMPARE_PRICES)}
/>
</div> </div>
<div className="bg-gray-50 dark:bg-gray-800 rounded-md p-4 min-h-[200px] overflow-y-auto"> <div className="bg-gray-50 dark:bg-gray-800 rounded-md p-4 min-h-[200px] overflow-y-auto">
{/* Conditionally render error OR content, not both. */} {/* Conditionally render error OR content, not both. */}
{error ? {error ? <p className="text-red-500 text-center">{error}</p> : renderContent()}
<p className="text-red-500 text-center">{error}</p> :
renderContent()}
</div> </div>
</div> </div>
); );

View File

@@ -14,72 +14,81 @@ interface BulkImportSummaryProps {
} }
export const BulkImportSummary: React.FC<BulkImportSummaryProps> = ({ summary, onDismiss }) => { export const BulkImportSummary: React.FC<BulkImportSummaryProps> = ({ summary, onDismiss }) => {
const hasContent = summary.processed.length > 0 || summary.skipped.length > 0 || summary.errors.length > 0; const hasContent =
summary.processed.length > 0 || summary.skipped.length > 0 || summary.errors.length > 0;
return ( return (
<div className="p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 min-h-[400px] flex flex-col"> <div className="p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 min-h-[400px] flex flex-col">
<div className="flex justify-between items-start mb-4"> <div className="flex justify-between items-start mb-4">
<div> <div>
<h2 className="text-xl font-bold text-gray-800 dark:text-white">Bulk Import Report</h2> <h2 className="text-xl font-bold text-gray-800 dark:text-white">Bulk Import Report</h2>
<p className="text-sm text-gray-500 dark:text-gray-400"> <p className="text-sm text-gray-500 dark:text-gray-400">
{`Processed: ${summary.processed.length}, Skipped: ${summary.skipped.length}, Errors: ${summary.errors.length}`} {`Processed: ${summary.processed.length}, Skipped: ${summary.skipped.length}, Errors: ${summary.errors.length}`}
</p> </p>
</div>
<button
onClick={onDismiss}
className="text-sm bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-md px-3 py-1"
aria-label="Dismiss summary"
>
Close
</button>
</div> </div>
<button
onClick={onDismiss}
className="text-sm bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-md px-3 py-1"
aria-label="Dismiss summary"
>
Close
</button>
</div>
{hasContent ? ( {hasContent ? (
<div className="space-y-4 grow overflow-y-auto"> <div className="space-y-4 grow overflow-y-auto">
{summary.processed.length > 0 && ( {summary.processed.length > 0 && (
<div> <div>
<h4 className="text-md font-semibold flex items-center mb-2 text-green-700 dark:text-green-400"> <h4 className="text-md font-semibold flex items-center mb-2 text-green-700 dark:text-green-400">
<CheckCircleIcon className="w-5 h-5 mr-2" /> <CheckCircleIcon className="w-5 h-5 mr-2" />
Successfully Processed ({summary.processed.length}) Successfully Processed ({summary.processed.length})
</h4> </h4>
<ul className="text-sm list-disc pl-6 space-y-1 bg-gray-50 dark:bg-gray-800/50 p-3 rounded-md"> <ul className="text-sm list-disc pl-6 space-y-1 bg-gray-50 dark:bg-gray-800/50 p-3 rounded-md">
{summary.processed.map((item, index) => <li key={index} className="text-gray-700 dark:text-gray-300">{item}</li>)} {summary.processed.map((item, index) => (
</ul> <li key={index} className="text-gray-700 dark:text-gray-300">
</div> {item}
)} </li>
{summary.skipped.length > 0 && ( ))}
<div> </ul>
<h4 className="text-md font-semibold flex items-center mb-2 text-blue-700 dark:text-blue-400">
<InformationCircleIcon className="w-5 h-5 mr-2" />
Skipped Duplicates ({summary.skipped.length})
</h4>
<ul className="text-sm list-disc pl-6 space-y-1 bg-gray-50 dark:bg-gray-800/50 p-3 rounded-md">
{summary.skipped.map((item, index) => <li key={index} className="text-gray-700 dark:text-gray-300">{item}</li>)}
</ul>
</div>
)}
{summary.errors.length > 0 && (
<div>
<h4 className="text-md font-semibold flex items-center mb-2 text-red-700 dark:text-red-400">
<ExclamationTriangleIcon className="w-5 h-5 mr-2" />
Errors ({summary.errors.length})
</h4>
<ul className="text-sm list-disc pl-6 space-y-2 bg-gray-50 dark:bg-gray-800/50 p-3 rounded-md">
{summary.errors.map((err, index) => (
<li key={index} className="text-red-800 dark:text-red-300">
<strong>{err.fileName}:</strong> {err.message}
</li>
))}
</ul>
</div>
)}
</div> </div>
) : ( )}
<div className="grow flex flex-col justify-center items-center text-center"> {summary.skipped.length > 0 && (
<InformationCircleIcon className="w-12 h-12 text-gray-400 mb-4" /> <div>
<p className="text-gray-600 dark:text-gray-400">No new files were found to process.</p> <h4 className="text-md font-semibold flex items-center mb-2 text-blue-700 dark:text-blue-400">
<InformationCircleIcon className="w-5 h-5 mr-2" />
Skipped Duplicates ({summary.skipped.length})
</h4>
<ul className="text-sm list-disc pl-6 space-y-1 bg-gray-50 dark:bg-gray-800/50 p-3 rounded-md">
{summary.skipped.map((item, index) => (
<li key={index} className="text-gray-700 dark:text-gray-300">
{item}
</li>
))}
</ul>
</div> </div>
)} )}
{summary.errors.length > 0 && (
<div>
<h4 className="text-md font-semibold flex items-center mb-2 text-red-700 dark:text-red-400">
<ExclamationTriangleIcon className="w-5 h-5 mr-2" />
Errors ({summary.errors.length})
</h4>
<ul className="text-sm list-disc pl-6 space-y-2 bg-gray-50 dark:bg-gray-800/50 p-3 rounded-md">
{summary.errors.map((err, index) => (
<li key={index} className="text-red-800 dark:text-red-300">
<strong>{err.fileName}:</strong> {err.message}
</li>
))}
</ul>
</div>
)}
</div>
) : (
<div className="grow flex flex-col justify-center items-center text-center">
<InformationCircleIcon className="w-12 h-12 text-gray-400 mb-4" />
<p className="text-gray-600 dark:text-gray-400">No new files were found to process.</p>
</div>
)}
</div> </div>
); );
}; };

View File

@@ -43,7 +43,7 @@ describe('BulkImporter', () => {
await waitFor(() => await waitFor(() =>
fireEvent.change(input, { fireEvent.change(input, {
target: { files: [file] }, target: { files: [file] },
}) }),
); );
expect(mockOnFilesChange).toHaveBeenCalledTimes(1); expect(mockOnFilesChange).toHaveBeenCalledTimes(1);
@@ -140,7 +140,9 @@ describe('BulkImporter', () => {
expect(imagePreview).toHaveAttribute('src', 'blob:http://localhost/image-guid'); expect(imagePreview).toHaveAttribute('src', 'blob:http://localhost/image-guid');
// Check that a generic document icon is rendered for the PDF // Check that a generic document icon is rendered for the PDF
const pdfPreviewContainer = screen.getByText('document.pdf').closest('div.relative') as HTMLElement; const pdfPreviewContainer = screen
.getByText('document.pdf')
.closest('div.relative') as HTMLElement;
// The icon itself doesn't have a test-id, but we can find it by its role and class. // The icon itself doesn't have a test-id, but we can find it by its role and class.
expect(pdfPreviewContainer.querySelector('svg.w-12.h-12')).toBeInTheDocument(); expect(pdfPreviewContainer.querySelector('svg.w-12.h-12')).toBeInTheDocument();
}); });
@@ -191,7 +193,9 @@ describe('BulkImporter', () => {
it('should revoke object URLs on cleanup', async () => { it('should revoke object URLs on cleanup', async () => {
mockCreateObjectURL.mockReturnValue('blob:http://localhost/test-url'); mockCreateObjectURL.mockReturnValue('blob:http://localhost/test-url');
const { unmount } = render(<BulkImporter onFilesChange={mockOnFilesChange} isProcessing={false} />); const { unmount } = render(
<BulkImporter onFilesChange={mockOnFilesChange} isProcessing={false} />,
);
const input = screen.getByLabelText(/click to upload/i); const input = screen.getByLabelText(/click to upload/i);
await act(async () => { await act(async () => {

View File

@@ -16,34 +16,39 @@ export const BulkImporter: React.FC<BulkImporterProps> = ({ onFilesChange, isPro
// Effect to create and revoke object URLs for image previews // Effect to create and revoke object URLs for image previews
useEffect(() => { useEffect(() => {
const newUrls = selectedFiles.map(file => const newUrls = selectedFiles.map((file) =>
file.type.startsWith('image/') ? URL.createObjectURL(file) : '' file.type.startsWith('image/') ? URL.createObjectURL(file) : '',
); );
setPreviewUrls(newUrls); setPreviewUrls(newUrls);
// Cleanup function to revoke URLs when component unmounts or files change // Cleanup function to revoke URLs when component unmounts or files change
return () => { return () => {
newUrls.forEach(url => { newUrls.forEach((url) => {
if (url) URL.revokeObjectURL(url); if (url) URL.revokeObjectURL(url);
}); });
}; };
}, [selectedFiles]); }, [selectedFiles]);
const handleFiles = useCallback((files: FileList) => { const handleFiles = useCallback(
if (files && files.length > 0 && !isProcessing) { (files: FileList) => {
// Prevent duplicates by checking file names and sizes if (files && files.length > 0 && !isProcessing) {
const newFiles = Array.from(files).filter(newFile => // Prevent duplicates by checking file names and sizes
!selectedFiles.some(existingFile => const newFiles = Array.from(files).filter(
existingFile.name === newFile.name && existingFile.size === newFile.size (newFile) =>
) !selectedFiles.some(
); (existingFile) =>
if (newFiles.length > 0) { existingFile.name === newFile.name && existingFile.size === newFile.size,
const updatedFiles = [...selectedFiles, ...newFiles]; ),
setSelectedFiles(updatedFiles); );
onFilesChange(updatedFiles); // Call parent callback directly if (newFiles.length > 0) {
const updatedFiles = [...selectedFiles, ...newFiles];
setSelectedFiles(updatedFiles);
onFilesChange(updatedFiles); // Call parent callback directly
}
} }
} },
}, [isProcessing, selectedFiles, onFilesChange]); [isProcessing, selectedFiles, onFilesChange],
);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) { if (e.target.files) {
@@ -59,60 +64,83 @@ export const BulkImporter: React.FC<BulkImporterProps> = ({ onFilesChange, isPro
onFilesChange(updatedFiles); // Also notify parent on removal onFilesChange(updatedFiles); // Also notify parent on removal
}; };
const { isDragging, dropzoneProps } = useDragAndDrop<HTMLLabelElement>({ onFilesDropped: handleFiles, disabled: isProcessing }); const { isDragging, dropzoneProps } = useDragAndDrop<HTMLLabelElement>({
onFilesDropped: handleFiles,
disabled: isProcessing,
});
const borderColor = isDragging ? 'border-brand-primary' : 'border-gray-300 dark:border-gray-600'; const borderColor = isDragging ? 'border-brand-primary' : 'border-gray-300 dark:border-gray-600';
const bgColor = isDragging ? 'bg-brand-light/50 dark:bg-brand-dark/20' : 'bg-gray-50 dark:bg-gray-800'; const bgColor = isDragging
? 'bg-brand-light/50 dark:bg-brand-dark/20'
: 'bg-gray-50 dark:bg-gray-800';
return ( return (
<div className="w-full"> <div className="w-full">
<label <label
htmlFor="bulk-file-upload" htmlFor="bulk-file-upload"
{...dropzoneProps} {...dropzoneProps}
className={`flex flex-col items-center justify-center w-full h-48 border-2 ${borderColor} ${bgColor} border-dashed rounded-lg transition-colors duration-300 ${isProcessing ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700'}`} className={`flex flex-col items-center justify-center w-full h-48 border-2 ${borderColor} ${bgColor} border-dashed rounded-lg transition-colors duration-300 ${isProcessing ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700'}`}
> >
<div className="flex flex-col items-center justify-center pt-5 pb-6 text-center"> <div className="flex flex-col items-center justify-center pt-5 pb-6 text-center">
<UploadIcon className="w-10 h-10 mb-3 text-gray-400" /> <UploadIcon className="w-10 h-10 mb-3 text-gray-400" />
{isProcessing ? ( {isProcessing ? (
<p className="mb-2 text-sm text-gray-600 dark:text-gray-300 font-semibold"> <p className="mb-2 text-sm text-gray-600 dark:text-gray-300 font-semibold">
Processing, please wait... Processing, please wait...
</p> </p>
) : ( ) : (
<> <>
<p className="mb-2 text-sm text-gray-500 dark:text-gray-400"> <p className="mb-2 text-sm text-gray-500 dark:text-gray-400">
<span className="font-semibold text-brand-primary">Click to upload</span> or drag and drop <span className="font-semibold text-brand-primary">Click to upload</span> or drag
</p> and drop
<p className="text-xs text-gray-500 dark:text-gray-400">PNG, JPG, WEBP, or PDF</p> </p>
</> <p className="text-xs text-gray-500 dark:text-gray-400">PNG, JPG, WEBP, or PDF</p>
)} </>
</div> )}
<input id="bulk-file-upload" type="file" className="absolute w-px h-px p-0 -m-px overflow-hidden clip-rect-0 whitespace-nowrap border-0" accept="image/png, image/jpeg, image/webp, application/pdf" onChange={handleFileChange} disabled={isProcessing} multiple /> </div>
</label> <input
id="bulk-file-upload"
type="file"
className="absolute w-px h-px p-0 -m-px overflow-hidden clip-rect-0 whitespace-nowrap border-0"
accept="image/png, image/jpeg, image/webp, application/pdf"
onChange={handleFileChange}
disabled={isProcessing}
multiple
/>
</label>
{selectedFiles.length > 0 && ( {selectedFiles.length > 0 && (
<div className="mt-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4"> <div className="mt-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{selectedFiles.map((file, index) => ( {selectedFiles.map((file, index) => (
<div key={index} className="relative group border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"> <div
{previewUrls[index] ? ( key={index}
<img src={previewUrls[index]} alt={file.name} className="h-32 w-full object-cover" /> className="relative group border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"
) : ( >
<div className="h-32 w-full flex items-center justify-center bg-gray-100 dark:bg-gray-700"> {previewUrls[index] ? (
<DocumentTextIcon className="w-12 h-12 text-gray-400" /> <img
</div> src={previewUrls[index]}
)} alt={file.name}
<div className="absolute bottom-0 left-0 right-0 bg-black bg-opacity-50 text-white text-xs p-1 truncate">{file.name}</div> className="h-32 w-full object-cover"
<button />
type="button" ) : (
onClick={() => removeFile(index)} <div className="h-32 w-full flex items-center justify-center bg-gray-100 dark:bg-gray-700">
className="absolute top-1 right-1 bg-red-600 text-white rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-opacity" <DocumentTextIcon className="w-12 h-12 text-gray-400" />
aria-label={`Remove ${file.name}`} </div>
> )}
<XMarkIcon className="w-4 h-4" /> <div className="absolute bottom-0 left-0 right-0 bg-black bg-opacity-50 text-white text-xs p-1 truncate">
</button> {file.name}
</div> </div>
))} <button
type="button"
onClick={() => removeFile(index)}
className="absolute top-1 right-1 bg-red-600 text-white rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={`Remove ${file.name}`}
>
<XMarkIcon className="w-4 h-4" />
</button>
</div> </div>
)} ))}
</div>
)}
</div> </div>
); );
}; };

View File

@@ -5,7 +5,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ExtractedDataTable, ExtractedDataTableProps } from './ExtractedDataTable'; import { ExtractedDataTable, ExtractedDataTableProps } from './ExtractedDataTable';
import type { FlyerItem, MasterGroceryItem, ShoppingList } from '../../types'; import type { FlyerItem, MasterGroceryItem, ShoppingList } from '../../types';
import { useAuth } from '../../hooks/useAuth'; import { useAuth } from '../../hooks/useAuth';
import { createMockFlyerItem, createMockMasterGroceryItem, createMockShoppingList, createMockShoppingListItem, createMockUser, createMockUserProfile } from '../../tests/utils/mockFactories'; import {
createMockFlyerItem,
createMockMasterGroceryItem,
createMockShoppingList,
createMockShoppingListItem,
createMockUser,
createMockUserProfile,
} from '../../tests/utils/mockFactories';
import { useUserData } from '../../hooks/useUserData'; import { useUserData } from '../../hooks/useUserData';
import { useMasterItems } from '../../hooks/useMasterItems'; import { useMasterItems } from '../../hooks/useMasterItems';
import { useWatchedItems } from '../../hooks/useWatchedItems'; import { useWatchedItems } from '../../hooks/useWatchedItems';
@@ -22,23 +29,98 @@ const mockUser = createMockUser({ user_id: 'user-123', email: 'test@example.com'
const mockUserProfile = createMockUserProfile({ user: mockUser }); const mockUserProfile = createMockUserProfile({ user: mockUser });
const mockMasterItems: MasterGroceryItem[] = [ const mockMasterItems: MasterGroceryItem[] = [
createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Apples', category_id: 1, category_name: 'Produce' }), createMockMasterGroceryItem({
createMockMasterGroceryItem({ master_grocery_item_id: 2, name: 'Milk', category_id: 2, category_name: 'Dairy' }), master_grocery_item_id: 1,
createMockMasterGroceryItem({ master_grocery_item_id: 3, name: 'Chicken Breast', category_id: 3, category_name: 'Meat' }), name: 'Apples',
category_id: 1,
category_name: 'Produce',
}),
createMockMasterGroceryItem({
master_grocery_item_id: 2,
name: 'Milk',
category_id: 2,
category_name: 'Dairy',
}),
createMockMasterGroceryItem({
master_grocery_item_id: 3,
name: 'Chicken Breast',
category_id: 3,
category_name: 'Meat',
}),
]; ];
const mockFlyerItems: FlyerItem[] = [ const mockFlyerItems: FlyerItem[] = [
createMockFlyerItem({ flyer_item_id: 101, item: 'Gala Apples', price_display: '$1.99/lb', price_in_cents: 199, quantity: 'per lb', unit_price: { value: 1.99, unit: 'lb' }, master_item_id: 1, category_name: 'Produce', flyer_id: 1 }), createMockFlyerItem({
createMockFlyerItem({ flyer_item_id: 102, item: '2% Milk', price_display: '$4.50', price_in_cents: 450, quantity: '4L', unit_price: { value: 1.125, unit: 'L' }, master_item_id: 2, category_name: 'Dairy', flyer_id: 1 }), flyer_item_id: 101,
createMockFlyerItem({ flyer_item_id: 103, item: 'Boneless Chicken', price_display: '$8.00/kg', price_in_cents: 800, quantity: 'per kg', unit_price: { value: 8.00, unit: 'kg' }, master_item_id: 3, category_name: 'Meat', flyer_id: 1 }), item: 'Gala Apples',
createMockFlyerItem({ flyer_item_id: 104, item: 'Mystery Soda', price_display: '$1.00', price_in_cents: 100, quantity: '1 can', unit_price: { value: 1.00, unit: 'can' }, master_item_id: undefined, category_name: 'Beverages', flyer_id: 1 }), // Unmatched item price_display: '$1.99/lb',
createMockFlyerItem({ flyer_item_id: 105, item: 'Apples', price_display: '$2.50/lb', price_in_cents: 250, quantity: 'per lb', unit_price: { value: 2.50, unit: 'lb' }, master_item_id: 1, category_name: 'Produce', flyer_id: 1 }), // Item name matches canonical name price_in_cents: 199,
quantity: 'per lb',
unit_price: { value: 1.99, unit: 'lb' },
master_item_id: 1,
category_name: 'Produce',
flyer_id: 1,
}),
createMockFlyerItem({
flyer_item_id: 102,
item: '2% Milk',
price_display: '$4.50',
price_in_cents: 450,
quantity: '4L',
unit_price: { value: 1.125, unit: 'L' },
master_item_id: 2,
category_name: 'Dairy',
flyer_id: 1,
}),
createMockFlyerItem({
flyer_item_id: 103,
item: 'Boneless Chicken',
price_display: '$8.00/kg',
price_in_cents: 800,
quantity: 'per kg',
unit_price: { value: 8.0, unit: 'kg' },
master_item_id: 3,
category_name: 'Meat',
flyer_id: 1,
}),
createMockFlyerItem({
flyer_item_id: 104,
item: 'Mystery Soda',
price_display: '$1.00',
price_in_cents: 100,
quantity: '1 can',
unit_price: { value: 1.0, unit: 'can' },
master_item_id: undefined,
category_name: 'Beverages',
flyer_id: 1,
}), // Unmatched item
createMockFlyerItem({
flyer_item_id: 105,
item: 'Apples',
price_display: '$2.50/lb',
price_in_cents: 250,
quantity: 'per lb',
unit_price: { value: 2.5, unit: 'lb' },
master_item_id: 1,
category_name: 'Produce',
flyer_id: 1,
}), // Item name matches canonical name
]; ];
const mockShoppingLists: ShoppingList[] = [ const mockShoppingLists: ShoppingList[] = [
createMockShoppingList({ createMockShoppingList({
shopping_list_id: 1, name: 'My List', user_id: 'user-123', shopping_list_id: 1,
items: [createMockShoppingListItem({ shopping_list_item_id: 1, shopping_list_id: 1, master_item_id: 2, quantity: 1, is_purchased: false })], // Contains Milk name: 'My List',
user_id: 'user-123',
items: [
createMockShoppingListItem({
shopping_list_item_id: 1,
shopping_list_id: 1,
master_item_id: 2,
quantity: 1,
is_purchased: false,
}),
], // Contains Milk
}), }),
]; ];
@@ -100,7 +182,11 @@ describe('ExtractedDataTable', () => {
deleteList: vi.fn(), deleteList: vi.fn(),
updateItemInList: vi.fn(), updateItemInList: vi.fn(),
removeItemFromList: vi.fn(), removeItemFromList: vi.fn(),
isCreatingList: false, isDeletingList: false, isAddingItem: false, isUpdatingItem: false, isRemovingItem: false, isCreatingList: false,
isDeletingList: false,
isAddingItem: false,
isUpdatingItem: false,
isRemovingItem: false,
error: null, error: null,
}); });
}); });
@@ -153,7 +239,9 @@ describe('ExtractedDataTable', () => {
// Find the specific table row for the item first to make the test more specific. // Find the specific table row for the item first to make the test more specific.
const chickenItemRow = screen.getByText('Boneless Chicken').closest('tr')!; const chickenItemRow = screen.getByText('Boneless Chicken').closest('tr')!;
// Now, find the watch button *within* that row. // Now, find the watch button *within* that row.
const watchButton = within(chickenItemRow).getByTitle("Add 'Chicken Breast' to your watchlist"); const watchButton = within(chickenItemRow).getByTitle(
"Add 'Chicken Breast' to your watchlist",
);
expect(watchButton).toBeInTheDocument(); expect(watchButton).toBeInTheDocument();
fireEvent.click(watchButton); fireEvent.click(watchButton);
@@ -178,7 +266,11 @@ describe('ExtractedDataTable', () => {
deleteList: vi.fn(), deleteList: vi.fn(),
updateItemInList: vi.fn(), updateItemInList: vi.fn(),
removeItemFromList: vi.fn(), removeItemFromList: vi.fn(),
isCreatingList: false, isDeletingList: false, isAddingItem: false, isUpdatingItem: false, isRemovingItem: false, isCreatingList: false,
isDeletingList: false,
isAddingItem: false,
isUpdatingItem: false,
isRemovingItem: false,
error: null, error: null,
}); });
render(<ExtractedDataTable {...defaultProps} />); render(<ExtractedDataTable {...defaultProps} />);
@@ -198,13 +290,17 @@ describe('ExtractedDataTable', () => {
deleteList: vi.fn(), deleteList: vi.fn(),
updateItemInList: vi.fn(), updateItemInList: vi.fn(),
removeItemFromList: vi.fn(), removeItemFromList: vi.fn(),
isCreatingList: false, isDeletingList: false, isAddingItem: false, isUpdatingItem: false, isRemovingItem: false, isCreatingList: false,
isDeletingList: false,
isAddingItem: false,
isUpdatingItem: false,
isRemovingItem: false,
error: null, error: null,
}); });
render(<ExtractedDataTable {...defaultProps} />); render(<ExtractedDataTable {...defaultProps} />);
const appleItemRow = screen.getByText('Gala Apples').closest('tr')!; const appleItemRow = screen.getByText('Gala Apples').closest('tr')!;
// Correct the title query to match the actual rendered title. // Correct the title query to match the actual rendered title.
const addToListButton = within(appleItemRow).getByTitle("Add Apples to list"); const addToListButton = within(appleItemRow).getByTitle('Add Apples to list');
expect(addToListButton).toBeInTheDocument(); expect(addToListButton).toBeInTheDocument();
fireEvent.click(addToListButton!); fireEvent.click(addToListButton!);
@@ -221,7 +317,11 @@ describe('ExtractedDataTable', () => {
deleteList: vi.fn(), deleteList: vi.fn(),
updateItemInList: vi.fn(), updateItemInList: vi.fn(),
removeItemFromList: vi.fn(), removeItemFromList: vi.fn(),
isCreatingList: false, isDeletingList: false, isAddingItem: false, isUpdatingItem: false, isRemovingItem: false, isCreatingList: false,
isDeletingList: false,
isAddingItem: false,
isUpdatingItem: false,
isRemovingItem: false,
error: null, error: null,
}); });
render(<ExtractedDataTable {...defaultProps} />); render(<ExtractedDataTable {...defaultProps} />);
@@ -229,7 +329,7 @@ describe('ExtractedDataTable', () => {
const addToListButtons = screen.getAllByTitle('Select a shopping list first'); const addToListButtons = screen.getAllByTitle('Select a shopping list first');
// Assert that at least one such button exists and that they are all disabled. // Assert that at least one such button exists and that they are all disabled.
expect(addToListButtons.length).toBeGreaterThan(0); expect(addToListButtons.length).toBeGreaterThan(0);
addToListButtons.forEach(button => expect(button).toBeDisabled()); addToListButtons.forEach((button) => expect(button).toBeDisabled());
}); });
it('should display the canonical name when it differs from the item name', () => { it('should display the canonical name when it differs from the item name', () => {
@@ -265,16 +365,19 @@ describe('ExtractedDataTable', () => {
// Get all rows from the table body // Get all rows from the table body
const rows = screen.getAllByRole('row'); const rows = screen.getAllByRole('row');
// Extract the primary item name from each row to check the sort order // Extract the primary item name from each row to check the sort order
const itemNamesInOrder = rows.map(row => row.querySelector('div.font-semibold, div.font-bold')?.textContent); const itemNamesInOrder = rows.map(
(row) => row.querySelector('div.font-semibold, div.font-bold')?.textContent,
);
// Assert the order is correct: watched items first, then others. // Assert the order is correct: watched items first, then others.
// 'Gala Apples' (101) and 'Apples' (105) both have master_item_id 1, which is watched. // 'Gala Apples' (101) and 'Apples' (105) both have master_item_id 1, which is watched.
// The implementation sorts watched items to the top, and then sorts alphabetically within each group (watched/unwatched). // The implementation sorts watched items to the top, and then sorts alphabetically within each group (watched/unwatched).
const expectedOrder = [ const expectedOrder = [
'Apples', // Watched 'Apples', // Watched
'Boneless Chicken', // Watched 'Boneless Chicken', // Watched
'Gala Apples', // Watched 'Gala Apples', // Watched
'2% Milk', 'Mystery Soda' // Unwatched '2% Milk',
'Mystery Soda', // Unwatched
]; ];
expect(itemNamesInOrder).toEqual(expectedOrder); expect(itemNamesInOrder).toEqual(expectedOrder);
}); });
@@ -299,7 +402,7 @@ describe('ExtractedDataTable', () => {
}); });
it('should not render the category filter if there is only one category', () => { it('should not render the category filter if there is only one category', () => {
const singleCategoryItems = mockFlyerItems.filter(item => item.category_name === 'Produce'); const singleCategoryItems = mockFlyerItems.filter((item) => item.category_name === 'Produce');
render(<ExtractedDataTable {...defaultProps} items={singleCategoryItems} />); render(<ExtractedDataTable {...defaultProps} items={singleCategoryItems} />);
expect(screen.queryByLabelText('Filter by category')).not.toBeInTheDocument(); expect(screen.queryByLabelText('Filter by category')).not.toBeInTheDocument();
}); });
@@ -317,7 +420,9 @@ describe('ExtractedDataTable', () => {
expect(screen.queryByText(/\(Canonical: .*\)/)).not.toBeInTheDocument(); expect(screen.queryByText(/\(Canonical: .*\)/)).not.toBeInTheDocument();
// If canonical name isn't resolved (because masterItems is empty), the Add to list button should NOT appear // If canonical name isn't resolved (because masterItems is empty), the Add to list button should NOT appear
const appleItemRow = screen.getByText('Gala Apples').closest('tr')!; const appleItemRow = screen.getByText('Gala Apples').closest('tr')!;
expect(within(appleItemRow).queryByTitle('Select a shopping list first')).not.toBeInTheDocument(); expect(
within(appleItemRow).queryByTitle('Select a shopping list first'),
).not.toBeInTheDocument();
}); });
it('should correctly format unit price for metric system', () => { it('should correctly format unit price for metric system', () => {

View File

@@ -10,8 +10,8 @@ import { useWatchedItems } from '../../hooks/useWatchedItems';
import { useShoppingLists } from '../../hooks/useShoppingLists'; import { useShoppingLists } from '../../hooks/useShoppingLists';
export interface ExtractedDataTableProps { export interface ExtractedDataTableProps {
items: FlyerItem[]; items: FlyerItem[];
unitSystem: 'metric' | 'imperial'; unitSystem: 'metric' | 'imperial';
} }
/** /**
@@ -32,54 +32,99 @@ interface ExtractedDataTableRowProps {
* A memoized component that renders a single row in the extracted data table. * A memoized component that renders a single row in the extracted data table.
* Using React.memo prevents this component from re-rendering if its props have not changed. * Using React.memo prevents this component from re-rendering if its props have not changed.
*/ */
const ExtractedDataTableRow: React.FC<ExtractedDataTableRowProps> = memo(({ const ExtractedDataTableRow: React.FC<ExtractedDataTableRowProps> = memo(
item, isWatched, isInList, unitSystem, isAuthenticated, activeListId, onAddItemToList, onAddWatchedItem ({
}) => { item,
const canonicalName = item.resolved_canonical_name; isWatched,
const itemNameClass = isWatched isInList,
? 'text-sm font-bold text-green-600 dark:text-green-400' unitSystem,
: 'text-sm font-semibold text-gray-900 dark:text-white'; isAuthenticated,
activeListId,
onAddItemToList,
onAddWatchedItem,
}) => {
const canonicalName = item.resolved_canonical_name;
const itemNameClass = isWatched
? 'text-sm font-bold text-green-600 dark:text-green-400'
: 'text-sm font-semibold text-gray-900 dark:text-white';
const shouldShowCanonical = canonicalName && canonicalName.toLowerCase() !== item.item.toLowerCase(); const shouldShowCanonical =
const formattedUnitPrice = formatUnitPrice(item.unit_price, unitSystem); canonicalName && canonicalName.toLowerCase() !== item.item.toLowerCase();
const formattedUnitPrice = formatUnitPrice(item.unit_price, unitSystem);
return ( return (
<tr className="group hover:bg-gray-50 dark:hover:bg-gray-800/50"> <tr className="group hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td className="px-6 py-4 whitespace-normal"> <td className="px-6 py-4 whitespace-normal">
<div className="flex justify-between items-center mb-2"> <div className="flex justify-between items-center mb-2">
<div className={itemNameClass}>{item.item}</div> <div className={itemNameClass}>{item.item}</div>
<div className="flex items-center space-x-2 shrink-0 ml-4"> <div className="flex items-center space-x-2 shrink-0 ml-4">
{isAuthenticated && canonicalName && !isInList && ( {isAuthenticated && canonicalName && !isInList && (
<button <button
onClick={() => onAddItemToList(item.master_item_id!)} onClick={() => onAddItemToList(item.master_item_id!)}
disabled={!activeListId} disabled={!activeListId}
className="text-gray-400 hover:text-brand-primary disabled:text-gray-300 disabled:cursor-not-allowed dark:text-gray-500 dark:hover:text-brand-light transition-colors" className="text-gray-400 hover:text-brand-primary disabled:text-gray-300 disabled:cursor-not-allowed dark:text-gray-500 dark:hover:text-brand-light transition-colors"
title={activeListId ? `Add ${canonicalName} to list` : 'Select a shopping list first'} title={
> activeListId ? `Add ${canonicalName} to list` : 'Select a shopping list first'
<PlusCircleIcon className="w-5 h-5" /> }
</button> >
)} <PlusCircleIcon className="w-5 h-5" />
{isAuthenticated && !isWatched && canonicalName && ( </button>
<button )}
onClick={() => onAddWatchedItem(canonicalName, item.category_name || 'Other/Miscellaneous')} {isAuthenticated && !isWatched && canonicalName && (
className="text-xs bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-brand-primary dark:text-brand-light font-semibold py-1 px-2.5 rounded-md transition-colors duration-200" <button
title={`Add '${canonicalName}' to your watchlist`} onClick={() =>
> onAddWatchedItem(canonicalName, item.category_name || 'Other/Miscellaneous')
+ Watch }
</button> className="text-xs bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-brand-primary dark:text-brand-light font-semibold py-1 px-2.5 rounded-md transition-colors duration-200"
title={`Add '${canonicalName}' to your watchlist`}
>
+ Watch
</button>
)}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-x-6 gap-y-1 text-xs text-gray-600 dark:text-gray-400">
<div className="flex items-baseline space-x-2">
<span className="font-medium text-gray-500 w-16 shrink-0">Price:</span>
<span>{item.price_display}</span>
</div>
<div className="flex items-baseline space-x-2">
<span className="font-medium text-gray-500 w-16 shrink-0">Deal:</span>
<div className="flex items-baseline">
<span>{item.quantity}</span>
{item.quantity_num && (
<span className="ml-1.5 text-gray-400 dark:text-gray-500">
({item.quantity_num})
</span>
)} )}
</div> </div>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-x-6 gap-y-1 text-xs text-gray-600 dark:text-gray-400"> <div className="flex items-baseline space-x-2">
<div className="flex items-baseline space-x-2"><span className="font-medium text-gray-500 w-16 shrink-0">Price:</span><span>{item.price_display}</span></div> <span className="font-medium text-gray-500 w-16 shrink-0">Unit Price:</span>
<div className="flex items-baseline space-x-2"><span className="font-medium text-gray-500 w-16 shrink-0">Deal:</span><div className="flex items-baseline"><span>{item.quantity}</span>{item.quantity_num && <span className="ml-1.5 text-gray-400 dark:text-gray-500">({item.quantity_num})</span>}</div></div> <div className="flex items-baseline">
<div className="flex items-baseline space-x-2"><span className="font-medium text-gray-500 w-16 shrink-0">Unit Price:</span><div className="flex items-baseline"><span className="font-semibold text-gray-700 dark:text-gray-300">{formattedUnitPrice.price}</span>{formattedUnitPrice.unit && (<span className="ml-1 text-xs text-gray-500 dark:text-gray-400">{formattedUnitPrice.unit}</span>)}</div></div> <span className="font-semibold text-gray-700 dark:text-gray-300">
<div className="flex items-baseline space-x-2"><span className="font-medium text-gray-500 w-16 shrink-0">Category:</span><span className="italic">{item.category_name}</span>{shouldShowCanonical && (<span className="ml-4 italic text-gray-400">(Canonical: {canonicalName})</span>)}</div> {formattedUnitPrice.price}
</span>
{formattedUnitPrice.unit && (
<span className="ml-1 text-xs text-gray-500 dark:text-gray-400">
{formattedUnitPrice.unit}
</span>
)}
</div>
</div> </div>
<div className="flex items-baseline space-x-2">
<span className="font-medium text-gray-500 w-16 shrink-0">Category:</span>
<span className="italic">{item.category_name}</span>
{shouldShowCanonical && (
<span className="ml-4 italic text-gray-400">(Canonical: {canonicalName})</span>
)}
</div>
</div>
</td> </td>
</tr> </tr>
); );
}); },
);
export const ExtractedDataTable: React.FC<ExtractedDataTableProps> = ({ items, unitSystem }) => { export const ExtractedDataTable: React.FC<ExtractedDataTableProps> = ({ items, unitSystem }) => {
const { userProfile } = useAuth(); const { userProfile } = useAuth();
@@ -89,54 +134,68 @@ export const ExtractedDataTable: React.FC<ExtractedDataTableProps> = ({ items, u
const { activeListId, addItemToList, shoppingLists } = useShoppingLists(); // Get shoppingLists const { activeListId, addItemToList, shoppingLists } = useShoppingLists(); // Get shoppingLists
const [categoryFilter, setCategoryFilter] = useState('all'); const [categoryFilter, setCategoryFilter] = useState('all');
const watchedItemIds = useMemo(() => new Set(watchedItems.map(item => item.master_grocery_item_id)), [watchedItems]); const watchedItemIds = useMemo(
const masterItemsMap = useMemo(() => new Map(masterItems.map(item => [item.master_grocery_item_id, item.name])), [masterItems]); () => new Set(watchedItems.map((item) => item.master_grocery_item_id)),
[watchedItems],
);
const masterItemsMap = useMemo(
() => new Map(masterItems.map((item) => [item.master_grocery_item_id, item.name])),
[masterItems],
);
const activeShoppingListItems = useMemo(() => { const activeShoppingListItems = useMemo(() => {
if (!activeListId) return new Set(); if (!activeListId) return new Set();
const activeList = shoppingLists.find(list => list.shopping_list_id === activeListId); const activeList = shoppingLists.find((list) => list.shopping_list_id === activeListId);
if (!activeList) return new Set(); if (!activeList) return new Set();
return new Set(activeList.items.map((item: ShoppingListItem) => item.master_item_id)); return new Set(activeList.items.map((item: ShoppingListItem) => item.master_item_id));
}, [shoppingLists, activeListId]); }, [shoppingLists, activeListId]);
const handleAddItemToList = useCallback((masterItemId: number) => { const handleAddItemToList = useCallback(
if (!activeListId) return; (masterItemId: number) => {
addItemToList(activeListId, { masterItemId }); if (!activeListId) return;
}, [activeListId, addItemToList]); addItemToList(activeListId, { masterItemId });
},
[activeListId, addItemToList],
);
const handleAddWatchedItem = useCallback((itemName: string, category: string) => { const handleAddWatchedItem = useCallback(
addWatchedItem(itemName, category); (itemName: string, category: string) => {
}, [addWatchedItem]); addWatchedItem(itemName, category);
},
[addWatchedItem],
);
const availableCategories = useMemo(() => { const availableCategories = useMemo(() => {
const cats = new Set(items.map(i => i.category_name).filter((c): c is string => !!c)); const cats = new Set(items.map((i) => i.category_name).filter((c): c is string => !!c));
return Array.from(cats).sort(); return Array.from(cats).sort();
}, [items]); }, [items]);
const itemsWithCanonicalNames = useMemo(() => { const itemsWithCanonicalNames = useMemo(() => {
return items.map(item => ({ return items.map((item) => ({
...item, ...item,
resolved_canonical_name: item.master_item_id ? (masterItemsMap.get(item.master_item_id) ?? null) : null, resolved_canonical_name: item.master_item_id
? (masterItemsMap.get(item.master_item_id) ?? null)
: null,
})); }));
}, [items, masterItemsMap]); }, [items, masterItemsMap]);
const sortedItems = useMemo(() => { const sortedItems = useMemo(() => {
const filtered = categoryFilter === 'all' const filtered =
categoryFilter === 'all'
? itemsWithCanonicalNames ? itemsWithCanonicalNames
: itemsWithCanonicalNames.filter(item => item.category_name === categoryFilter); : itemsWithCanonicalNames.filter((item) => item.category_name === categoryFilter);
// Sort the array: watched items first, then alphabetically by item name. // Sort the array: watched items first, then alphabetically by item name.
// This is more efficient than creating two separate arrays and merging them. // This is more efficient than creating two separate arrays and merging them.
return [...filtered].sort((a, b) => { return [...filtered].sort((a, b) => {
const aIsWatched = a.master_item_id ? watchedItemIds.has(a.master_item_id) : false; const aIsWatched = a.master_item_id ? watchedItemIds.has(a.master_item_id) : false;
const bIsWatched = b.master_item_id ? watchedItemIds.has(b.master_item_id) : false; const bIsWatched = b.master_item_id ? watchedItemIds.has(b.master_item_id) : false;
if (aIsWatched && !bIsWatched) return -1; // a comes first if (aIsWatched && !bIsWatched) return -1; // a comes first
if (!aIsWatched && bIsWatched) return 1; // b comes first if (!aIsWatched && bIsWatched) return 1; // b comes first
// If both are watched or both are not, sort alphabetically by item name. // If both are watched or both are not, sort alphabetically by item name.
return a.item.localeCompare(b.item); return a.item.localeCompare(b.item);
}); });
}, [itemsWithCanonicalNames, categoryFilter, watchedItemIds]); }, [itemsWithCanonicalNames, categoryFilter, watchedItemIds]);
@@ -152,53 +211,59 @@ export const ExtractedDataTable: React.FC<ExtractedDataTableProps> = ({ items, u
return ( return (
<div className="overflow-hidden bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm"> <div className="overflow-hidden bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm">
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex flex-wrap items-center justify-between gap-x-4 gap-y-2"> <div className="p-4 border-b border-gray-200 dark:border-gray-700 flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<h3 className="text-lg font-semibold text-gray-800 dark:text-white"> <h3 className="text-lg font-semibold text-gray-800 dark:text-white">{title}</h3>
{title} {availableCategories.length > 1 && (
</h3> <select
{availableCategories.length > 1 && ( value={categoryFilter}
<select onChange={(e) => setCategoryFilter(e.target.value)}
value={categoryFilter} className="block pl-3 pr-8 py-1.5 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-brand-primary focus:border-brand-primary"
onChange={(e) => setCategoryFilter(e.target.value)} aria-label="Filter by category"
className="block pl-3 pr-8 py-1.5 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-brand-primary focus:border-brand-primary" >
aria-label="Filter by category" <option value="all">All Categories</option>
> {availableCategories.map((cat) => (
<option value="all">All Categories</option> <option key={cat} value={cat}>
{availableCategories.map(cat => <option key={cat} value={cat}>{cat}</option>)} {cat}
</select> </option>
)} ))}
</div> </select>
)}
</div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
{sortedItems.length === 0 ? ( {sortedItems.length === 0 ? (
<div className="text-center p-8 text-gray-500 dark:text-gray-400"> <div className="text-center p-8 text-gray-500 dark:text-gray-400">
No items found for the selected category. No items found for the selected category.
</div> </div>
) : ( ) : (
<table className="min-w-full"> <table className="min-w-full">
<tbody className="divide-y divide-gray-200 dark:divide-gray-700"> <tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{sortedItems.map((item) => { {sortedItems.map((item) => {
const isWatched = !!(item.master_item_id && watchedItemIds.has(item.master_item_id)); const isWatched = !!(
const isInList = !!(item.master_item_id && activeShoppingListItems.has(item.master_item_id)); item.master_item_id && watchedItemIds.has(item.master_item_id)
);
const isInList = !!(
item.master_item_id && activeShoppingListItems.has(item.master_item_id)
);
return ( return (
<ExtractedDataTableRow <ExtractedDataTableRow
key={item.flyer_item_id} key={item.flyer_item_id}
item={item} item={item}
isWatched={isWatched} isWatched={isWatched}
isInList={isInList} isInList={isInList}
unitSystem={unitSystem} unitSystem={unitSystem}
isAuthenticated={!!userProfile} isAuthenticated={!!userProfile}
activeListId={activeListId} activeListId={activeListId}
onAddItemToList={handleAddItemToList} onAddItemToList={handleAddItemToList}
onAddWatchedItem={handleAddWatchedItem} onAddWatchedItem={handleAddWatchedItem}
/> />
); );
})} })}
</tbody> </tbody>
</table> </table>
)} )}
</div> </div>
</div> </div>
); );
}; };

View File

@@ -5,7 +5,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { FlyerDisplay } from './FlyerDisplay'; import { FlyerDisplay } from './FlyerDisplay';
import { createMockStore } from '../../tests/utils/mockFactories'; import { createMockStore } from '../../tests/utils/mockFactories';
const mockStore = createMockStore({ store_id: 1, name: 'SuperMart', logo_url: 'http://example.com/logo.png' }); const mockStore = createMockStore({
store_id: 1,
name: 'SuperMart',
logo_url: 'http://example.com/logo.png',
});
const mockOnOpenCorrectionTool = vi.fn(); const mockOnOpenCorrectionTool = vi.fn();
@@ -32,7 +36,9 @@ describe('FlyerDisplay', () => {
expect(screen.getByText('123 Main St, Anytown')).toBeInTheDocument(); expect(screen.getByText('123 Main St, Anytown')).toBeInTheDocument();
// Check for date range // Check for date range
expect(screen.getByText('Deals valid from October 26, 2023 to November 1, 2023')).toBeInTheDocument(); expect(
screen.getByText('Deals valid from October 26, 2023 to November 1, 2023'),
).toBeInTheDocument();
// Check for flyer image // Check for flyer image
expect(screen.getByAltText('Grocery Flyer')).toHaveAttribute('src', defaultProps.imageUrl); expect(screen.getByAltText('Grocery Flyer')).toHaveAttribute('src', defaultProps.imageUrl);
@@ -44,7 +50,12 @@ describe('FlyerDisplay', () => {
}); });
it('should not render the header if store and date info are missing', () => { it('should not render the header if store and date info are missing', () => {
render(<FlyerDisplay imageUrl={defaultProps.imageUrl} onOpenCorrectionTool={mockOnOpenCorrectionTool} />); render(
<FlyerDisplay
imageUrl={defaultProps.imageUrl}
onOpenCorrectionTool={mockOnOpenCorrectionTool}
/>,
);
expect(screen.queryByRole('heading')).not.toBeInTheDocument(); expect(screen.queryByRole('heading')).not.toBeInTheDocument();
}); });
@@ -83,7 +94,12 @@ describe('FlyerDisplay', () => {
}); });
it('should not be visible when the header is not rendered', () => { it('should not be visible when the header is not rendered', () => {
render(<FlyerDisplay imageUrl={defaultProps.imageUrl} onOpenCorrectionTool={mockOnOpenCorrectionTool} />); render(
<FlyerDisplay
imageUrl={defaultProps.imageUrl}
onOpenCorrectionTool={mockOnOpenCorrectionTool}
/>,
);
expect(screen.queryByRole('button', { name: /correct data/i })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /correct data/i })).not.toBeInTheDocument();
}); });
@@ -116,7 +132,9 @@ describe('FlyerDisplay', () => {
describe('Date Formatting Robustness', () => { describe('Date Formatting Robustness', () => {
it('should handle invalid date strings gracefully by not displaying them', () => { it('should handle invalid date strings gracefully by not displaying them', () => {
render(<FlyerDisplay {...defaultProps} validFrom="invalid-date" validTo="another-bad-date" />); render(
<FlyerDisplay {...defaultProps} validFrom="invalid-date" validTo="another-bad-date" />,
);
expect(screen.queryByText(/deals valid from/i)).not.toBeInTheDocument(); expect(screen.queryByText(/deals valid from/i)).not.toBeInTheDocument();
expect(screen.queryByText(/valid on/i)).not.toBeInTheDocument(); expect(screen.queryByText(/valid on/i)).not.toBeInTheDocument();
expect(screen.queryByText(/deals start/i)).not.toBeInTheDocument(); expect(screen.queryByText(/deals start/i)).not.toBeInTheDocument();

View File

@@ -13,13 +13,23 @@ export interface FlyerDisplayProps {
onOpenCorrectionTool: () => void; onOpenCorrectionTool: () => void;
} }
export const FlyerDisplay: React.FC<FlyerDisplayProps> = ({ imageUrl, store, validFrom, validTo, storeAddress, onOpenCorrectionTool }) => { export const FlyerDisplay: React.FC<FlyerDisplayProps> = ({
imageUrl,
store,
validFrom,
validTo,
storeAddress,
onOpenCorrectionTool,
}) => {
const dateRange = formatDateRange(validFrom, validTo, { verbose: true }); const dateRange = formatDateRange(validFrom, validTo, { verbose: true });
// Determine the correct image source. If imageUrl is already a full URL (starts with http) // Determine the correct image source. If imageUrl is already a full URL (starts with http)
// or is an absolute path (starts with /), use it directly. Otherwise, assume it's a relative // or is an absolute path (starts with /), use it directly. Otherwise, assume it's a relative
// filename from the database and prepend the correct path. // filename from the database and prepend the correct path.
const imageSrc = imageUrl && (imageUrl.startsWith('http') || imageUrl.startsWith('/')) ? imageUrl : `/flyer-images/${imageUrl}`; const imageSrc =
imageUrl && (imageUrl.startsWith('http') || imageUrl.startsWith('/'))
? imageUrl
: `/flyer-images/${imageUrl}`;
return ( return (
<div className="w-full rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700 shadow-sm bg-white dark:bg-gray-900 flex flex-col"> <div className="w-full rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700 shadow-sm bg-white dark:bg-gray-900 flex flex-col">
@@ -33,9 +43,17 @@ export const FlyerDisplay: React.FC<FlyerDisplayProps> = ({ imageUrl, store, val
/> />
)} )}
<div className="grow text-center sm:text-left min-w-0"> <div className="grow text-center sm:text-left min-w-0">
{store?.name && <h3 className="text-gray-900 dark:text-white text-lg font-bold tracking-wide">{store.name}</h3>} {store?.name && (
{dateRange && <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">{dateRange}</p>} <h3 className="text-gray-900 dark:text-white text-lg font-bold tracking-wide">
{storeAddress && <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{storeAddress}</p>} {store.name}
</h3>
)}
{dateRange && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">{dateRange}</p>
)}
{storeAddress && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{storeAddress}</p>
)}
</div> </div>
<button <button
onClick={onOpenCorrectionTool} onClick={onOpenCorrectionTool}
@@ -49,10 +67,14 @@ export const FlyerDisplay: React.FC<FlyerDisplayProps> = ({ imageUrl, store, val
)} )}
<div className="bg-gray-100 dark:bg-gray-800"> <div className="bg-gray-100 dark:bg-gray-800">
{imageUrl ? ( {imageUrl ? (
<img src={imageSrc} alt="Grocery Flyer" className="w-full h-auto object-contain max-h-[60vh] dark:invert dark:hue-rotate-180" /> <img
src={imageSrc}
alt="Grocery Flyer"
className="w-full h-auto object-contain max-h-[60vh] dark:invert dark:hue-rotate-180"
/>
) : ( ) : (
<div className="w-full h-64 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center"> <div className="w-full h-64 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center">
<p className="text-gray-500">Flyer image will be displayed here</p> <p className="text-gray-500">Flyer image will be displayed here</p>
</div> </div>
)} )}
</div> </div>

View File

@@ -9,133 +9,160 @@ import { logger } from '../../services/logger.client';
import * as apiClient from '../../services/apiClient'; import * as apiClient from '../../services/apiClient';
import { calculateDaysBetween, formatDateRange } from './dateUtils'; import { calculateDaysBetween, formatDateRange } from './dateUtils';
interface FlyerListProps { interface FlyerListProps {
flyers: Flyer[]; flyers: Flyer[];
onFlyerSelect: (flyer: Flyer) => void; onFlyerSelect: (flyer: Flyer) => void;
selectedFlyerId: number | null; selectedFlyerId: number | null;
profile: UserProfile | null; profile: UserProfile | null;
} }
export const FlyerList: React.FC<FlyerListProps> = ({ flyers, onFlyerSelect, selectedFlyerId, profile }) => { export const FlyerList: React.FC<FlyerListProps> = ({
const isAdmin = profile?.role === 'admin'; flyers,
onFlyerSelect,
selectedFlyerId,
profile,
}) => {
const isAdmin = profile?.role === 'admin';
const handleCleanupClick = async (e: React.MouseEvent, flyerId: number) => { const handleCleanupClick = async (e: React.MouseEvent, flyerId: number) => {
e.stopPropagation(); // Prevent the row's onClick from firing e.stopPropagation(); // Prevent the row's onClick from firing
if (!window.confirm(`Are you sure you want to clean up the files for flyer ID ${flyerId}? This action cannot be undone.`)) { if (
return; !window.confirm(
} `Are you sure you want to clean up the files for flyer ID ${flyerId}? This action cannot be undone.`,
try { )
await apiClient.cleanupFlyerFiles(flyerId); ) {
toast.success(`Cleanup job for flyer ID ${flyerId} has been enqueued.`); return;
} catch (error) { }
toast.error(error instanceof Error ? error.message : 'Failed to enqueue cleanup job.'); try {
} await apiClient.cleanupFlyerFiles(flyerId);
}; toast.success(`Cleanup job for flyer ID ${flyerId} has been enqueued.`);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to enqueue cleanup job.');
}
};
return ( return (
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700"> <div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-bold text-gray-800 dark:text-white p-4 border-b border-gray-200 dark:border-gray-700"> <h3 className="text-lg font-bold text-gray-800 dark:text-white p-4 border-b border-gray-200 dark:border-gray-700">
Processed Flyers Processed Flyers
</h3> </h3>
{flyers.length > 0 ? ( {flyers.length > 0 ? (
<ul className="divide-y divide-gray-200 dark:divide-gray-700 max-h-96 overflow-y-auto"> <ul className="divide-y divide-gray-200 dark:divide-gray-700 max-h-96 overflow-y-auto">
{flyers.map(flyer => { {flyers.map((flyer) => {
const dateRange = formatDateRange(flyer.valid_from, flyer.valid_to); const dateRange = formatDateRange(flyer.valid_from, flyer.valid_to);
const verboseDateRange = formatDateRange(flyer.valid_from, flyer.valid_to, { verbose: true }); const verboseDateRange = formatDateRange(flyer.valid_from, flyer.valid_to, {
verbose: true,
});
const daysLeft = calculateDaysBetween(format(new Date(), 'yyyy-MM-dd'), flyer.valid_to); const daysLeft = calculateDaysBetween(format(new Date(), 'yyyy-MM-dd'), flyer.valid_to);
let daysLeftText = ''; let daysLeftText = '';
let daysLeftColor = ''; let daysLeftColor = '';
if (daysLeft !== null) { if (daysLeft !== null) {
if (daysLeft < 0) { if (daysLeft < 0) {
daysLeftText = 'Expired'; daysLeftText = 'Expired';
daysLeftColor = 'text-red-500 dark:text-red-400'; daysLeftColor = 'text-red-500 dark:text-red-400';
} else if (daysLeft === 0) { } else if (daysLeft === 0) {
daysLeftText = 'Expires today'; daysLeftText = 'Expires today';
daysLeftColor = 'text-orange-500 dark:text-orange-400'; daysLeftColor = 'text-orange-500 dark:text-orange-400';
} else { } else {
daysLeftText = `Expires in ${daysLeft} day${daysLeft === 1 ? '' : 's'}`; daysLeftText = `Expires in ${daysLeft} day${daysLeft === 1 ? '' : 's'}`;
daysLeftColor = daysLeft <= 3 ? 'text-orange-500 dark:text-orange-400' : 'text-green-600 dark:text-green-400'; daysLeftColor =
} daysLeft <= 3
} ? 'text-orange-500 dark:text-orange-400'
: 'text-green-600 dark:text-green-400';
}
}
// Build a more detailed tooltip string // Build a more detailed tooltip string
const processedDate = isValid(parseISO(flyer.created_at)) ? format(parseISO(flyer.created_at), "MMMM d, yyyy 'at' h:mm:ss a") : 'N/A'; const processedDate = isValid(parseISO(flyer.created_at))
? format(parseISO(flyer.created_at), "MMMM d, yyyy 'at' h:mm:ss a")
: 'N/A';
const tooltipLines = [ const tooltipLines = [
`File: ${flyer.file_name}`, `File: ${flyer.file_name}`,
`Store: ${flyer.store?.name || 'Unknown'}`, `Store: ${flyer.store?.name || 'Unknown'}`,
`Address: ${flyer.store_address || 'N/A'}`, `Address: ${flyer.store_address || 'N/A'}`,
`Items: ${flyer.item_count}`, `Items: ${flyer.item_count}`,
verboseDateRange || 'Validity: N/A', verboseDateRange || 'Validity: N/A',
`Processed: ${processedDate}` `Processed: ${processedDate}`,
]; ];
const tooltipText = tooltipLines.join('\n'); const tooltipText = tooltipLines.join('\n');
// --- DEBUG LOGGING for icon display logic --- // --- DEBUG LOGGING for icon display logic ---
// Log the flyer object and the specific icon_url value before the conditional check. // Log the flyer object and the specific icon_url value before the conditional check.
logger.debug(`[FlyerList] Checking icon for flyer ID ${flyer.flyer_id}.`, { flyer }); logger.debug(`[FlyerList] Checking icon for flyer ID ${flyer.flyer_id}.`, { flyer });
const hasIconUrl = !!flyer.icon_url; const hasIconUrl = !!flyer.icon_url;
logger.debug(`[FlyerList] Flyer ID ${flyer.flyer_id}: hasIconUrl is ${hasIconUrl}. Rendering ${hasIconUrl ? '<img>' : 'DocumentTextIcon'}.`); logger.debug(
`[FlyerList] Flyer ID ${flyer.flyer_id}: hasIconUrl is ${hasIconUrl}. Rendering ${hasIconUrl ? '<img>' : 'DocumentTextIcon'}.`,
);
if (!flyer.store) { if (!flyer.store) {
logger.debug(`[FlyerList] Flyer ${flyer.flyer_id} has no store. "Unknown Store" fallback will be used.`); logger.debug(
} `[FlyerList] Flyer ${flyer.flyer_id} has no store. "Unknown Store" fallback will be used.`,
);
}
return ( return (
<li <li
data-testid={`flyer-list-item-${flyer.flyer_id}`} data-testid={`flyer-list-item-${flyer.flyer_id}`}
key={flyer.flyer_id} key={flyer.flyer_id}
onClick={() => onFlyerSelect(flyer)} onClick={() => onFlyerSelect(flyer)}
className={`p-4 flex items-center space-x-3 cursor-pointer transition-colors duration-200 ${selectedFlyerId === flyer.flyer_id ? 'bg-brand-light dark:bg-brand-dark/30' : 'hover:bg-gray-50 dark:hover:bg-gray-800'}`} className={`p-4 flex items-center space-x-3 cursor-pointer transition-colors duration-200 ${selectedFlyerId === flyer.flyer_id ? 'bg-brand-light dark:bg-brand-dark/30' : 'hover:bg-gray-50 dark:hover:bg-gray-800'}`}
title={tooltipText} title={tooltipText}
> >
{flyer.icon_url ? ( {flyer.icon_url ? (
<img src={flyer.icon_url} alt="Flyer Icon" className="w-6 h-6 shrink-0 rounded-sm" /> <img
) : ( src={flyer.icon_url}
<DocumentTextIcon className="w-6 h-6 text-brand-primary shrink-0" /> alt="Flyer Icon"
)} className="w-6 h-6 shrink-0 rounded-sm"
<div className="grow min-w-0"> />
<div className="flex items-center space-x-2"> ) : (
<p className="text-sm font-semibold text-gray-900 dark:text-white truncate"> <DocumentTextIcon className="w-6 h-6 text-brand-primary shrink-0" />
{flyer.store?.name || 'Unknown Store'} )}
</p> <div className="grow min-w-0">
{flyer.store_address && ( <div className="flex items-center space-x-2">
<a href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(flyer.store_address)}`} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()} title={`View address: ${flyer.store_address}`}> <p className="text-sm font-semibold text-gray-900 dark:text-white truncate">
<MapPinIcon className="w-5 h-5 text-gray-400 hover:text-brand-primary transition-colors" /> {flyer.store?.name || 'Unknown Store'}
</a> </p>
)} {flyer.store_address && (
</div> <a
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1"> href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(flyer.store_address)}`}
{`${flyer.item_count} items`} target="_blank"
{dateRange && ` • Valid: ${dateRange}`} rel="noopener noreferrer"
{daysLeftText && ( onClick={(e) => e.stopPropagation()}
<span className={`ml-1 ${daysLeftColor}`}> title={`View address: ${flyer.store_address}`}
{daysLeftText} >
</span> <MapPinIcon className="w-5 h-5 text-gray-400 hover:text-brand-primary transition-colors" />
)} </a>
</p> )}
</div> </div>
{isAdmin && ( <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
<button {`${flyer.item_count} items`}
onClick={(e) => handleCleanupClick(e, flyer.flyer_id)} {dateRange && ` • Valid: ${dateRange}`}
className="shrink-0 p-2 rounded-md hover:bg-red-100 dark:hover:bg-red-900/50 text-gray-400 hover:text-red-600 transition-colors" {daysLeftText && (
title={`Clean up files for flyer ID ${flyer.flyer_id}`} <span className={`ml-1 ${daysLeftColor}`}> {daysLeftText}</span>
> )}
<Trash2Icon className="w-4 h-4" /> </p>
</button> </div>
)} {isAdmin && (
</li> <button
); onClick={(e) => handleCleanupClick(e, flyer.flyer_id)}
})} className="shrink-0 p-2 rounded-md hover:bg-red-100 dark:hover:bg-red-900/50 text-gray-400 hover:text-red-600 transition-colors"
</ul> title={`Clean up files for flyer ID ${flyer.flyer_id}`}
) : ( >
<p className="p-4 text-sm text-gray-500 dark:text-gray-400"> <Trash2Icon className="w-4 h-4" />
No flyers have been processed yet. Upload one to get started. </button>
</p> )}
)} </li>
</div> );
); })}
</ul>
) : (
<p className="p-4 text-sm text-gray-500 dark:text-gray-400">
No flyers have been processed yet. Upload one to get started.
</p>
)}
</div>
);
}; };

View File

@@ -11,7 +11,10 @@ import { useNavigate, MemoryRouter } from 'react-router-dom';
vi.mock('../../services/aiApiClient'); vi.mock('../../services/aiApiClient');
vi.mock('../../services/logger.client', () => ({ vi.mock('../../services/logger.client', () => ({
// Keep the original logger.info/error but also spy on it for test assertions if needed // Keep the original logger.info/error but also spy on it for test assertions if needed
logger: { info: vi.fn((...args) => console.log('[LOGGER.INFO]', ...args)), error: vi.fn((...args) => console.error('[LOGGER.ERROR]', ...args)) }, logger: {
info: vi.fn((...args) => console.log('[LOGGER.INFO]', ...args)),
error: vi.fn((...args) => console.error('[LOGGER.ERROR]', ...args)),
},
})); }));
vi.mock('../../utils/checksum', () => ({ vi.mock('../../utils/checksum', () => ({
generateFileChecksum: vi.fn(), generateFileChecksum: vi.fn(),
@@ -39,7 +42,7 @@ const renderComponent = (onProcessingComplete = vi.fn()) => {
return render( return render(
<MemoryRouter> <MemoryRouter>
<FlyerUploader onProcessingComplete={onProcessingComplete} /> <FlyerUploader onProcessingComplete={onProcessingComplete} />
</MemoryRouter> </MemoryRouter>,
); );
}; };
@@ -71,10 +74,10 @@ describe('FlyerUploader', () => {
it('should handle file upload and start polling', async () => { it('should handle file upload and start polling', async () => {
console.log('--- [TEST LOG] ---: 1. Setting up mocks for upload and polling.'); console.log('--- [TEST LOG] ---: 1. Setting up mocks for upload and polling.');
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue( mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue(
new Response(JSON.stringify({ jobId: 'job-123' }), { status: 200 }) new Response(JSON.stringify({ jobId: 'job-123' }), { status: 200 }),
); );
mockedAiApiClient.getJobStatus.mockResolvedValue( mockedAiApiClient.getJobStatus.mockResolvedValue(
new Response(JSON.stringify({ state: 'active', progress: { message: 'Checking...' } })) new Response(JSON.stringify({ state: 'active', progress: { message: 'Checking...' } })),
); );
console.log('--- [TEST LOG] ---: 2. Rendering component and preparing file.'); console.log('--- [TEST LOG] ---: 2. Rendering component and preparing file.');
@@ -107,7 +110,9 @@ describe('FlyerUploader', () => {
vi.advanceTimersByTime(3000); vi.advanceTimersByTime(3000);
console.log('--- [TEST LOG] ---: 8b. vi.advanceTimersByTime(3000) complete.'); console.log('--- [TEST LOG] ---: 8b. vi.advanceTimersByTime(3000) complete.');
}); });
console.log(`--- [TEST LOG] ---: 9. Act block finished. Now checking if getJobStatus was called again.`); console.log(
`--- [TEST LOG] ---: 9. Act block finished. Now checking if getJobStatus was called again.`,
);
try { try {
await waitFor(() => { await waitFor(() => {
@@ -116,21 +121,21 @@ describe('FlyerUploader', () => {
expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(2); expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(2);
}); });
console.log('--- [TEST LOG] ---: 11. SUCCESS: Second poll confirmed.'); console.log('--- [TEST LOG] ---: 11. SUCCESS: Second poll confirmed.');
} catch(error) { } catch (error) {
console.error('--- [TEST LOG] ---: 11. ERROR: waitFor for second poll timed out.'); console.error('--- [TEST LOG] ---: 11. ERROR: waitFor for second poll timed out.');
console.log('--- [DEBUG] ---: DOM at time of failure:'); console.log('--- [DEBUG] ---: DOM at time of failure:');
screen.debug(); screen.debug();
throw error; throw error;
} }
}); });
it('should handle file upload via drag and drop', async () => { it('should handle file upload via drag and drop', async () => {
console.log('--- [TEST LOG] ---: 1. Setting up mocks for drag and drop.'); console.log('--- [TEST LOG] ---: 1. Setting up mocks for drag and drop.');
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue( mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue(
new Response(JSON.stringify({ jobId: 'job-dnd' }), { status: 200 }) new Response(JSON.stringify({ jobId: 'job-dnd' }), { status: 200 }),
); );
mockedAiApiClient.getJobStatus.mockResolvedValue( mockedAiApiClient.getJobStatus.mockResolvedValue(
new Response(JSON.stringify({ state: 'active', progress: { message: 'Dropped...' } })) new Response(JSON.stringify({ state: 'active', progress: { message: 'Dropped...' } })),
); );
console.log('--- [TEST LOG] ---: 2. Rendering component and preparing file for drop.'); console.log('--- [TEST LOG] ---: 2. Rendering component and preparing file for drop.');
@@ -155,11 +160,15 @@ describe('FlyerUploader', () => {
const onProcessingComplete = vi.fn(); const onProcessingComplete = vi.fn();
console.log('--- [TEST LOG] ---: 1. Setting up mock sequence for polling.'); console.log('--- [TEST LOG] ---: 1. Setting up mock sequence for polling.');
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue( mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue(
new Response(JSON.stringify({ jobId: 'job-123' }), { status: 200 }) new Response(JSON.stringify({ jobId: 'job-123' }), { status: 200 }),
); );
mockedAiApiClient.getJobStatus mockedAiApiClient.getJobStatus
.mockResolvedValueOnce(new Response(JSON.stringify({ state: 'active', progress: { message: 'Analyzing...' } }))) .mockResolvedValueOnce(
.mockResolvedValueOnce(new Response(JSON.stringify({ state: 'completed', returnValue: { flyerId: 42 } }))); new Response(JSON.stringify({ state: 'active', progress: { message: 'Analyzing...' } })),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ state: 'completed', returnValue: { flyerId: 42 } })),
);
console.log('--- [TEST LOG] ---: 2. Rendering component and uploading file.'); console.log('--- [TEST LOG] ---: 2. Rendering component and uploading file.');
renderComponent(onProcessingComplete); renderComponent(onProcessingComplete);
@@ -171,7 +180,7 @@ describe('FlyerUploader', () => {
try { try {
await screen.findByText('Analyzing...'); await screen.findByText('Analyzing...');
console.log('--- [TEST LOG] ---: 4. SUCCESS: UI is showing "Analyzing...".'); console.log('--- [TEST LOG] ---: 4. SUCCESS: UI is showing "Analyzing...".');
} catch(error) { } catch (error) {
console.error('--- [TEST LOG] ---: 4. ERROR: findByText("Analyzing...") timed out.'); console.error('--- [TEST LOG] ---: 4. ERROR: findByText("Analyzing...") timed out.');
console.log('--- [DEBUG] ---: DOM at time of failure:'); console.log('--- [DEBUG] ---: DOM at time of failure:');
screen.debug(); screen.debug();
@@ -187,10 +196,16 @@ describe('FlyerUploader', () => {
console.log(`--- [TEST LOG] ---: 7. Timers advanced. Now AWAITING completion message.`); console.log(`--- [TEST LOG] ---: 7. Timers advanced. Now AWAITING completion message.`);
try { try {
console.log('--- [TEST LOG] ---: 8a. waitFor check: Waiting for completion text and job status count.'); console.log(
'--- [TEST LOG] ---: 8a. waitFor check: Waiting for completion text and job status count.',
);
await waitFor(() => { await waitFor(() => {
console.log(`--- [TEST LOG] ---: 8b. waitFor interval: calls=${mockedAiApiClient.getJobStatus.mock.calls.length}`); console.log(
expect(screen.getByText('Processing complete! Redirecting to flyer 42...')).toBeInTheDocument(); `--- [TEST LOG] ---: 8b. waitFor interval: calls=${mockedAiApiClient.getJobStatus.mock.calls.length}`,
);
expect(
screen.getByText('Processing complete! Redirecting to flyer 42...'),
).toBeInTheDocument();
}); });
console.log('--- [TEST LOG] ---: 9. SUCCESS: Completion message found.'); console.log('--- [TEST LOG] ---: 9. SUCCESS: Completion message found.');
} catch (error) { } catch (error) {
@@ -215,10 +230,10 @@ describe('FlyerUploader', () => {
it('should handle a failed job', async () => { it('should handle a failed job', async () => {
console.log('--- [TEST LOG] ---: 1. Setting up mocks for a failed job.'); console.log('--- [TEST LOG] ---: 1. Setting up mocks for a failed job.');
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue( mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue(
new Response(JSON.stringify({ jobId: 'job-fail' }), { status: 200 }) new Response(JSON.stringify({ jobId: 'job-fail' }), { status: 200 }),
); );
mockedAiApiClient.getJobStatus.mockResolvedValue( mockedAiApiClient.getJobStatus.mockResolvedValue(
new Response(JSON.stringify({ state: 'failed', failedReason: 'AI model exploded' })) new Response(JSON.stringify({ state: 'failed', failedReason: 'AI model exploded' })),
); );
console.log('--- [TEST LOG] ---: 2. Rendering and uploading.'); console.log('--- [TEST LOG] ---: 2. Rendering and uploading.');
@@ -248,7 +263,7 @@ describe('FlyerUploader', () => {
it('should handle a duplicate flyer error (409)', async () => { it('should handle a duplicate flyer error (409)', async () => {
console.log('--- [TEST LOG] ---: 1. Setting up mock for 409 duplicate error.'); console.log('--- [TEST LOG] ---: 1. Setting up mock for 409 duplicate error.');
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue( mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue(
new Response(JSON.stringify({ flyerId: 99, message: 'Duplicate' }), { status: 409 }) new Response(JSON.stringify({ flyerId: 99, message: 'Duplicate' }), { status: 409 }),
); );
console.log('--- [TEST LOG] ---: 2. Rendering and uploading.'); console.log('--- [TEST LOG] ---: 2. Rendering and uploading.');
@@ -262,9 +277,11 @@ describe('FlyerUploader', () => {
try { try {
console.log('--- [TEST LOG] ---: 4. AWAITING duplicate flyer message...'); console.log('--- [TEST LOG] ---: 4. AWAITING duplicate flyer message...');
expect(await screen.findByText('This flyer has already been processed. You can view it here:')).toBeInTheDocument(); expect(
await screen.findByText('This flyer has already been processed. You can view it here:'),
).toBeInTheDocument();
console.log('--- [TEST LOG] ---: 5. SUCCESS: Duplicate message found.'); console.log('--- [TEST LOG] ---: 5. SUCCESS: Duplicate message found.');
} catch(error) { } catch (error) {
console.error('--- [TEST LOG] ---: 5. ERROR: findByText for duplicate message timed out.'); console.error('--- [TEST LOG] ---: 5. ERROR: findByText for duplicate message timed out.');
console.log('--- [DEBUG] ---: DOM at time of failure:'); console.log('--- [DEBUG] ---: DOM at time of failure:');
screen.debug(); screen.debug();
@@ -279,10 +296,10 @@ describe('FlyerUploader', () => {
it('should allow the user to stop watching progress', async () => { it('should allow the user to stop watching progress', async () => {
console.log('--- [TEST LOG] ---: 1. Setting up mocks for infinite polling.'); console.log('--- [TEST LOG] ---: 1. Setting up mocks for infinite polling.');
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue( mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue(
new Response(JSON.stringify({ jobId: 'job-stop' }), { status: 200 }) new Response(JSON.stringify({ jobId: 'job-stop' }), { status: 200 }),
); );
mockedAiApiClient.getJobStatus.mockResolvedValue( mockedAiApiClient.getJobStatus.mockResolvedValue(
new Response(JSON.stringify({ state: 'active', progress: { message: 'Analyzing...' } })) new Response(JSON.stringify({ state: 'active', progress: { message: 'Analyzing...' } })),
); );
console.log('--- [TEST LOG] ---: 2. Rendering and uploading.'); console.log('--- [TEST LOG] ---: 2. Rendering and uploading.');
@@ -299,7 +316,7 @@ describe('FlyerUploader', () => {
console.log('--- [TEST LOG] ---: 4. AWAITING polling UI...'); console.log('--- [TEST LOG] ---: 4. AWAITING polling UI...');
stopButton = await screen.findByRole('button', { name: 'Stop Watching Progress' }); stopButton = await screen.findByRole('button', { name: 'Stop Watching Progress' });
console.log('--- [TEST LOG] ---: 5. SUCCESS: Polling UI is visible.'); console.log('--- [TEST LOG] ---: 5. SUCCESS: Polling UI is visible.');
} catch(error) { } catch (error) {
console.error('--- [TEST LOG] ---: 5. ERROR: findByRole for stop button timed out.'); console.error('--- [TEST LOG] ---: 5. ERROR: findByRole for stop button timed out.');
console.log('--- [DEBUG] ---: DOM at time of failure:'); console.log('--- [DEBUG] ---: DOM at time of failure:');
screen.debug(); screen.debug();
@@ -316,7 +333,7 @@ describe('FlyerUploader', () => {
// Fix typo: queryText -> queryByText // Fix typo: queryText -> queryByText
expect(screen.queryByText('Analyzing...')).not.toBeInTheDocument(); expect(screen.queryByText('Analyzing...')).not.toBeInTheDocument();
console.log('--- [TEST LOG] ---: 9. SUCCESS: UI has reset and message removed.'); console.log('--- [TEST LOG] ---: 9. SUCCESS: UI has reset and message removed.');
} catch(error) { } catch (error) {
console.error('--- [TEST LOG] ---: 9. ERROR: findByText for idle state timed out.'); console.error('--- [TEST LOG] ---: 9. ERROR: findByText for idle state timed out.');
console.log('--- [DEBUG] ---: DOM at time of failure:'); console.log('--- [DEBUG] ---: DOM at time of failure:');
screen.debug(); screen.debug();
@@ -327,7 +344,9 @@ describe('FlyerUploader', () => {
describe('Error Handling and Edge Cases', () => { describe('Error Handling and Edge Cases', () => {
it('should handle checksum generation failure', async () => { it('should handle checksum generation failure', async () => {
console.log('--- [TEST LOG] ---: 1. Setting up mock for checksum failure.'); console.log('--- [TEST LOG] ---: 1. Setting up mock for checksum failure.');
mockedChecksumModule.generateFileChecksum.mockRejectedValue(new Error('Checksum generation failed')); mockedChecksumModule.generateFileChecksum.mockRejectedValue(
new Error('Checksum generation failed'),
);
renderComponent(); renderComponent();
const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' }); const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
const input = screen.getByLabelText(/click to select a file/i); const input = screen.getByLabelText(/click to select a file/i);
@@ -343,7 +362,9 @@ describe('FlyerUploader', () => {
it('should handle a generic network error during upload', async () => { it('should handle a generic network error during upload', async () => {
console.log('--- [TEST LOG] ---: 1. Setting up mock for generic upload error.'); console.log('--- [TEST LOG] ---: 1. Setting up mock for generic upload error.');
mockedAiApiClient.uploadAndProcessFlyer.mockRejectedValue(new Error('Network Error During Upload')); mockedAiApiClient.uploadAndProcessFlyer.mockRejectedValue(
new Error('Network Error During Upload'),
);
renderComponent(); renderComponent();
const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' }); const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
const input = screen.getByLabelText(/click to select a file/i); const input = screen.getByLabelText(/click to select a file/i);
@@ -359,7 +380,7 @@ describe('FlyerUploader', () => {
it('should handle a generic network error during polling', async () => { it('should handle a generic network error during polling', async () => {
console.log('--- [TEST LOG] ---: 1. Setting up mock for polling error.'); console.log('--- [TEST LOG] ---: 1. Setting up mock for polling error.');
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue( mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue(
new Response(JSON.stringify({ jobId: 'job-poll-fail' }), { status: 200 }) new Response(JSON.stringify({ jobId: 'job-poll-fail' }), { status: 200 }),
); );
mockedAiApiClient.getJobStatus.mockRejectedValue(new Error('Polling Network Error')); mockedAiApiClient.getJobStatus.mockRejectedValue(new Error('Polling Network Error'));
@@ -378,10 +399,10 @@ describe('FlyerUploader', () => {
it('should handle a completed job with a missing flyerId', async () => { it('should handle a completed job with a missing flyerId', async () => {
console.log('--- [TEST LOG] ---: 1. Setting up mock for malformed completion payload.'); console.log('--- [TEST LOG] ---: 1. Setting up mock for malformed completion payload.');
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue( mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue(
new Response(JSON.stringify({ jobId: 'job-no-flyerid' }), { status: 200 }) new Response(JSON.stringify({ jobId: 'job-no-flyerid' }), { status: 200 }),
); );
mockedAiApiClient.getJobStatus.mockResolvedValue( mockedAiApiClient.getJobStatus.mockResolvedValue(
new Response(JSON.stringify({ state: 'completed', returnValue: {} })) // No flyerId new Response(JSON.stringify({ state: 'completed', returnValue: {} })), // No flyerId
); );
renderComponent(); renderComponent();
@@ -392,7 +413,9 @@ describe('FlyerUploader', () => {
fireEvent.change(input, { target: { files: [file] } }); fireEvent.change(input, { target: { files: [file] } });
console.log('--- [TEST LOG] ---: 3. Awaiting error message.'); console.log('--- [TEST LOG] ---: 3. Awaiting error message.');
expect(await screen.findByText(/Job completed but did not return a flyer ID/i)).toBeInTheDocument(); expect(
await screen.findByText(/Job completed but did not return a flyer ID/i),
).toBeInTheDocument();
console.log('--- [TEST LOG] ---: 4. Assertions passed.'); console.log('--- [TEST LOG] ---: 4. Assertions passed.');
}); });

View File

@@ -49,7 +49,9 @@ export const FlyerUploader: React.FC<FlyerUploaderProps> = ({ onProcessingComple
console.debug(`[DEBUG] Polling Effect Triggered: state=${processingState}, jobId=${jobId}`); console.debug(`[DEBUG] Polling Effect Triggered: state=${processingState}, jobId=${jobId}`);
if (processingState !== 'polling' || !jobId) { if (processingState !== 'polling' || !jobId) {
if (pollingTimeoutRef.current) { if (pollingTimeoutRef.current) {
console.debug(`[DEBUG] Polling Effect: Clearing timeout ID ${pollingTimeoutRef.current} because state is not 'polling' or no jobId exists.`); console.debug(
`[DEBUG] Polling Effect: Clearing timeout ID ${pollingTimeoutRef.current} because state is not 'polling' or no jobId exists.`,
);
clearTimeout(pollingTimeoutRef.current); clearTimeout(pollingTimeoutRef.current);
} }
return; return;
@@ -92,7 +94,9 @@ export const FlyerUploader: React.FC<FlyerUploaderProps> = ({ onProcessingComple
break; break;
case 'failed': case 'failed':
console.debug(`[DEBUG] pollStatus(): Job state is "failed". Reason: ${job.failedReason}`); console.debug(
`[DEBUG] pollStatus(): Job state is "failed". Reason: ${job.failedReason}`,
);
setErrorMessage(`Processing failed: ${job.failedReason || 'Unknown error'}`); setErrorMessage(`Processing failed: ${job.failedReason || 'Unknown error'}`);
setProcessingState('error'); setProcessingState('error');
break; break;
@@ -100,14 +104,18 @@ export const FlyerUploader: React.FC<FlyerUploaderProps> = ({ onProcessingComple
case 'active': case 'active':
case 'waiting': case 'waiting':
default: default:
console.debug(`[DEBUG] pollStatus(): Job state is "${job.state}". Setting timeout for next poll (3000ms).`); console.debug(
`[DEBUG] pollStatus(): Job state is "${job.state}". Setting timeout for next poll (3000ms).`,
);
pollingTimeoutRef.current = window.setTimeout(pollStatus, 3000); pollingTimeoutRef.current = window.setTimeout(pollStatus, 3000);
console.debug(`[DEBUG] pollStatus(): Timeout ID ${pollingTimeoutRef.current} set.`); console.debug(`[DEBUG] pollStatus(): Timeout ID ${pollingTimeoutRef.current} set.`);
break; break;
} }
} catch (error) { } catch (error) {
logger.error('Error during polling:', { error }); logger.error('Error during polling:', { error });
setErrorMessage(error instanceof Error ? error.message : 'An unexpected error occurred during polling.'); setErrorMessage(
error instanceof Error ? error.message : 'An unexpected error occurred during polling.',
);
setProcessingState('error'); setProcessingState('error');
} }
}; };
@@ -116,7 +124,9 @@ export const FlyerUploader: React.FC<FlyerUploaderProps> = ({ onProcessingComple
return () => { return () => {
if (pollingTimeoutRef.current) { if (pollingTimeoutRef.current) {
console.debug(`[DEBUG] Polling Effect Cleanup: Clearing timeout ID ${pollingTimeoutRef.current}`); console.debug(
`[DEBUG] Polling Effect Cleanup: Clearing timeout ID ${pollingTimeoutRef.current}`,
);
clearTimeout(pollingTimeoutRef.current); clearTimeout(pollingTimeoutRef.current);
pollingTimeoutRef.current = null; pollingTimeoutRef.current = null;
} else { } else {
@@ -136,7 +146,9 @@ export const FlyerUploader: React.FC<FlyerUploaderProps> = ({ onProcessingComple
console.debug('[DEBUG] processFile(): Generating file checksum.'); console.debug('[DEBUG] processFile(): Generating file checksum.');
const checksum = await generateFileChecksum(file); const checksum = await generateFileChecksum(file);
setStatusMessage('Uploading file...'); setStatusMessage('Uploading file...');
console.debug(`[DEBUG] processFile(): Checksum generated: ${checksum}. Calling uploadAndProcessFlyer.`); console.debug(
`[DEBUG] processFile(): Checksum generated: ${checksum}. Calling uploadAndProcessFlyer.`,
);
const startResponse = await uploadAndProcessFlyer(file, checksum); const startResponse = await uploadAndProcessFlyer(file, checksum);
console.debug(`[DEBUG] processFile(): Upload response status: ${startResponse.status}`); console.debug(`[DEBUG] processFile(): Upload response status: ${startResponse.status}`);
@@ -145,10 +157,10 @@ export const FlyerUploader: React.FC<FlyerUploaderProps> = ({ onProcessingComple
const errorData = await startResponse.json(); const errorData = await startResponse.json();
console.debug('[DEBUG] processFile(): Upload failed. Error data:', errorData); console.debug('[DEBUG] processFile(): Upload failed. Error data:', errorData);
if (startResponse.status === 409 && errorData.flyerId) { if (startResponse.status === 409 && errorData.flyerId) {
setErrorMessage(`This flyer has already been processed. You can view it here:`); setErrorMessage(`This flyer has already been processed. You can view it here:`);
setDuplicateFlyerId(errorData.flyerId); setDuplicateFlyerId(errorData.flyerId);
} else { } else {
setErrorMessage(errorData.message || `Upload failed with status ${startResponse.status}`); setErrorMessage(errorData.message || `Upload failed with status ${startResponse.status}`);
} }
setProcessingState('error'); setProcessingState('error');
return; return;
@@ -158,7 +170,6 @@ export const FlyerUploader: React.FC<FlyerUploaderProps> = ({ onProcessingComple
console.debug(`[DEBUG] processFile(): Upload successful. Received jobId: ${newJobId}`); console.debug(`[DEBUG] processFile(): Upload successful. Received jobId: ${newJobId}`);
setJobId(newJobId); setJobId(newJobId);
setProcessingState('polling'); setProcessingState('polling');
} catch (error) { } catch (error) {
logger.error('An unexpected error occurred during file upload:', { error }); logger.error('An unexpected error occurred during file upload:', { error });
setErrorMessage(error instanceof Error ? error.message : 'An unexpected error occurred.'); setErrorMessage(error instanceof Error ? error.message : 'An unexpected error occurred.');
@@ -176,7 +187,9 @@ export const FlyerUploader: React.FC<FlyerUploaderProps> = ({ onProcessingComple
}; };
const resetUploaderState = useCallback(() => { const resetUploaderState = useCallback(() => {
console.debug(`[DEBUG] resetUploaderState(): User triggered reset. Previous jobId was: ${jobId}`); console.debug(
`[DEBUG] resetUploaderState(): User triggered reset. Previous jobId was: ${jobId}`,
);
setProcessingState('idle'); setProcessingState('idle');
setJobId(null); setJobId(null);
setErrorMessage(null); setErrorMessage(null);
@@ -187,59 +200,79 @@ export const FlyerUploader: React.FC<FlyerUploaderProps> = ({ onProcessingComple
logger.info('Uploader state has been reset. Previous job ID was:', jobId); logger.info('Uploader state has been reset. Previous job ID was:', jobId);
}, [jobId]); }, [jobId]);
const onFilesDropped = useCallback((files: FileList) => { const onFilesDropped = useCallback(
console.debug('[DEBUG] onFilesDropped(): Files were dropped.'); (files: FileList) => {
if (files && files.length > 0) { console.debug('[DEBUG] onFilesDropped(): Files were dropped.');
processFile(files[0]); if (files && files.length > 0) {
} processFile(files[0]);
}, [processFile]); }
},
[processFile],
);
const isProcessing = processingState === 'uploading' || processingState === 'polling'; const isProcessing = processingState === 'uploading' || processingState === 'polling';
const { isDragging, dropzoneProps } = useDragAndDrop<HTMLLabelElement>({ onFilesDropped, disabled: isProcessing }); const { isDragging, dropzoneProps } = useDragAndDrop<HTMLLabelElement>({
onFilesDropped,
disabled: isProcessing,
});
const borderColor = isDragging ? 'border-brand-primary' : 'border-gray-400 dark:border-gray-600'; const borderColor = isDragging ? 'border-brand-primary' : 'border-gray-400 dark:border-gray-600';
const bgColor = isDragging ? 'bg-brand-light/50 dark:bg-brand-dark/20' : 'bg-gray-50/50 dark:bg-gray-800/20'; const bgColor = isDragging
? 'bg-brand-light/50 dark:bg-brand-dark/20'
: 'bg-gray-50/50 dark:bg-gray-800/20';
// If processing, show the detailed status component. Otherwise, show the uploader. // If processing, show the detailed status component. Otherwise, show the uploader.
console.debug(`[DEBUG] FlyerUploader: Rendering. State=${processingState}, Msg=${statusMessage}, Err=${!!errorMessage}`); console.debug(
`[DEBUG] FlyerUploader: Rendering. State=${processingState}, Msg=${statusMessage}, Err=${!!errorMessage}`,
);
if (isProcessing || processingState === 'completed' || processingState === 'error') { if (isProcessing || processingState === 'completed' || processingState === 'error') {
return ( return (
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
<ProcessingStatus stages={processingStages} estimatedTime={estimatedTime} currentFile={currentFile} /> <ProcessingStatus
<div className="mt-4 text-center"> stages={processingStages}
{/* Display the current status message to the user and the test runner */} estimatedTime={estimatedTime}
{statusMessage && <p className="text-gray-600 dark:text-gray-400 mt-2 italic animate-pulse">{statusMessage}</p>} currentFile={currentFile}
/>
<div className="mt-4 text-center">
{/* Display the current status message to the user and the test runner */}
{statusMessage && (
<p className="text-gray-600 dark:text-gray-400 mt-2 italic animate-pulse">
{statusMessage}
</p>
)}
{errorMessage && ( {errorMessage && (
<div className="text-red-600 dark:text-red-400 font-semibold p-4 bg-red-100 dark:bg-red-900/30 rounded-md"> <div className="text-red-600 dark:text-red-400 font-semibold p-4 bg-red-100 dark:bg-red-900/30 rounded-md">
<p>{errorMessage}</p> <p>{errorMessage}</p>
{duplicateFlyerId && ( {duplicateFlyerId && (
<p> <p>
<Link to={`/flyers/${duplicateFlyerId}`} className="text-blue-500 underline">Flyer #{duplicateFlyerId}</Link> <Link to={`/flyers/${duplicateFlyerId}`} className="text-blue-500 underline">
</p> Flyer #{duplicateFlyerId}
)} </Link>
</div> </p>
)} )}
{processingState === 'polling' && (
<button
onClick={resetUploaderState}
className="mt-4 text-sm text-gray-500 hover:text-gray-800 dark:hover:text-gray-200 underline transition-colors"
title="The flyer will continue to process in the background."
>
Stop Watching Progress
</button>
)}
{(processingState === 'error' || processingState === 'completed') && (
<button
onClick={resetUploaderState}
className="mt-4 text-sm bg-brand-secondary hover:bg-brand-dark text-white font-bold py-2 px-4 rounded-lg"
>
Upload Another Flyer
</button>
)}
</div> </div>
)}
{processingState === 'polling' && (
<button
onClick={resetUploaderState}
className="mt-4 text-sm text-gray-500 hover:text-gray-800 dark:hover:text-gray-200 underline transition-colors"
title="The flyer will continue to process in the background."
>
Stop Watching Progress
</button>
)}
{(processingState === 'error' || processingState === 'completed') && (
<button
onClick={resetUploaderState}
className="mt-4 text-sm bg-brand-secondary hover:bg-brand-dark text-white font-bold py-2 px-4 rounded-lg"
>
Upload Another Flyer
</button>
)}
</div> </div>
</div>
); );
} }
@@ -253,7 +286,9 @@ export const FlyerUploader: React.FC<FlyerUploaderProps> = ({ onProcessingComple
{...dropzoneProps} {...dropzoneProps}
> >
<span className="text-lg font-medium">Click to select a file</span> <span className="text-lg font-medium">Click to select a file</span>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">or drag and drop a PDF or image</p> <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
or drag and drop a PDF or image
</p>
<input <input
id="flyer-upload" id="flyer-upload"
type="file" type="file"

View File

@@ -9,10 +9,24 @@ import { createMockProcessingStage } from '../../tests/utils/mockFactories';
describe('ProcessingStatus', () => { describe('ProcessingStatus', () => {
const mockStages: ProcessingStage[] = [ const mockStages: ProcessingStage[] = [
createMockProcessingStage({ name: 'Uploading File', status: 'completed', detail: 'Done' }), createMockProcessingStage({ name: 'Uploading File', status: 'completed', detail: 'Done' }),
createMockProcessingStage({ name: 'Converting to Image', status: 'in-progress', detail: 'Page 2 of 5...' }), createMockProcessingStage({
name: 'Converting to Image',
status: 'in-progress',
detail: 'Page 2 of 5...',
}),
createMockProcessingStage({ name: 'Extracting Text', status: 'pending', detail: '' }), createMockProcessingStage({ name: 'Extracting Text', status: 'pending', detail: '' }),
createMockProcessingStage({ name: 'Analyzing with AI', status: 'error', detail: 'AI model timeout', critical: false }), createMockProcessingStage({
createMockProcessingStage({ name: 'Saving to Database', status: 'error', detail: 'Connection failed', critical: true }), name: 'Analyzing with AI',
status: 'error',
detail: 'AI model timeout',
critical: false,
}),
createMockProcessingStage({
name: 'Saving to Database',
status: 'error',
detail: 'Connection failed',
critical: true,
}),
]; ];
describe('Single File Layout', () => { describe('Single File Layout', () => {
@@ -52,7 +66,7 @@ describe('ProcessingStatus', () => {
expect(screen.getByText(/estimated time remaining: 0m 0s/i)).toBeInTheDocument(); expect(screen.getByText(/estimated time remaining: 0m 0s/i)).toBeInTheDocument();
}); });
}) });
it('should render all stages with correct statuses and icons', () => { it('should render all stages with correct statuses and icons', () => {
render(<ProcessingStatus stages={mockStages} estimatedTime={120} />); render(<ProcessingStatus stages={mockStages} estimatedTime={120} />);
@@ -71,12 +85,16 @@ describe('ProcessingStatus', () => {
// Pending stage // Pending stage
const pendingStageText = screen.getByTestId('stage-text-2'); const pendingStageText = screen.getByTestId('stage-text-2');
expect(pendingStageText.className).toContain('text-gray-400'); expect(pendingStageText.className).toContain('text-gray-400');
expect(screen.getByTestId('stage-icon-2').querySelector('div')).toHaveClass('border-gray-400'); expect(screen.getByTestId('stage-icon-2').querySelector('div')).toHaveClass(
'border-gray-400',
);
// Non-critical error stage // Non-critical error stage
const nonCriticalErrorStageText = screen.getByTestId('stage-text-3'); const nonCriticalErrorStageText = screen.getByTestId('stage-text-3');
expect(nonCriticalErrorStageText.className).toContain('text-yellow-600'); expect(nonCriticalErrorStageText.className).toContain('text-yellow-600');
expect(screen.getByTestId('stage-icon-3').querySelector('svg')).toHaveClass('text-yellow-500'); expect(screen.getByTestId('stage-icon-3').querySelector('svg')).toHaveClass(
'text-yellow-500',
);
expect(screen.getByText(/optional/i)).toBeInTheDocument(); expect(screen.getByText(/optional/i)).toBeInTheDocument();
// Critical error stage // Critical error stage
@@ -86,15 +104,26 @@ describe('ProcessingStatus', () => {
}); });
it('should render PDF conversion progress bar', () => { it('should render PDF conversion progress bar', () => {
render(<ProcessingStatus stages={[]} estimatedTime={60} pageProgress={{ current: 3, total: 10 }} />); render(
const progressBar = screen.getByText(/converting pdf: page 3 of 10/i).nextElementSibling?.firstChild; <ProcessingStatus
stages={[]}
estimatedTime={60}
pageProgress={{ current: 3, total: 10 }}
/>,
);
const progressBar = screen.getByText(/converting pdf: page 3 of 10/i).nextElementSibling
?.firstChild;
expect(progressBar).toBeInTheDocument(); expect(progressBar).toBeInTheDocument();
expect(progressBar).toHaveStyle('width: 30%'); expect(progressBar).toHaveStyle('width: 30%');
}); });
it('should render item extraction progress bar for a stage', () => { it('should render item extraction progress bar for a stage', () => {
const stagesWithProgress: ProcessingStage[] = [ const stagesWithProgress: ProcessingStage[] = [
createMockProcessingStage({ name: 'Extracting Items', status: 'in-progress', progress: { current: 4, total: 8 } }), createMockProcessingStage({
name: 'Extracting Items',
status: 'in-progress',
progress: { current: 4, total: 8 },
}),
]; ];
render(<ProcessingStatus stages={stagesWithProgress} estimatedTime={60} />); render(<ProcessingStatus stages={stagesWithProgress} estimatedTime={60} />);
const progressBar = screen.getByText(/analyzing page 4 of 8/i).nextElementSibling?.firstChild; const progressBar = screen.getByText(/analyzing page 4 of 8/i).nextElementSibling?.firstChild;
@@ -128,7 +157,8 @@ describe('ProcessingStatus', () => {
it('should render the PDF conversion progress bar in bulk mode', () => { it('should render the PDF conversion progress bar in bulk mode', () => {
render(<ProcessingStatus {...bulkProps} pageProgress={{ current: 1, total: 5 }} />); render(<ProcessingStatus {...bulkProps} pageProgress={{ current: 1, total: 5 }} />);
const progressBar = screen.getByText(/converting pdf: page 1 of 5/i).nextElementSibling?.firstChild; const progressBar = screen.getByText(/converting pdf: page 1 of 5/i).nextElementSibling
?.firstChild;
expect(progressBar).toBeInTheDocument(); expect(progressBar).toBeInTheDocument();
expect(progressBar).toHaveStyle('width: 20%'); expect(progressBar).toHaveStyle('width: 20%');
}); });
@@ -136,10 +166,15 @@ describe('ProcessingStatus', () => {
it('should render the item extraction progress bar from the correct stage in bulk mode', () => { it('should render the item extraction progress bar from the correct stage in bulk mode', () => {
const stagesWithProgress: ProcessingStage[] = [ const stagesWithProgress: ProcessingStage[] = [
createMockProcessingStage({ name: 'Some Other Step', status: 'completed' }), createMockProcessingStage({ name: 'Some Other Step', status: 'completed' }),
createMockProcessingStage({ name: 'Extracting All Items from Flyer', status: 'in-progress', progress: { current: 3, total: 10 } }), createMockProcessingStage({
name: 'Extracting All Items from Flyer',
status: 'in-progress',
progress: { current: 3, total: 10 },
}),
]; ];
render(<ProcessingStatus {...bulkProps} stages={stagesWithProgress} />); render(<ProcessingStatus {...bulkProps} stages={stagesWithProgress} />);
const progressBar = screen.getByText(/analyzing page 3 of 10/i).nextElementSibling?.firstChild; const progressBar =
screen.getByText(/analyzing page 3 of 10/i).nextElementSibling?.firstChild;
expect(progressBar).toBeInTheDocument(); expect(progressBar).toBeInTheDocument();
expect(progressBar).toHaveStyle('width: 30%'); expect(progressBar).toHaveStyle('width: 30%');
}); });

View File

@@ -9,9 +9,9 @@ interface ProcessingStatusProps {
stages: ProcessingStage[]; stages: ProcessingStage[];
estimatedTime: number; estimatedTime: number;
currentFile?: string | null; currentFile?: string | null;
pageProgress?: {current: number, total: number} | null; pageProgress?: { current: number; total: number } | null;
bulkProgress?: number; bulkProgress?: number;
bulkFileCount?: {current: number, total: number} | null; bulkFileCount?: { current: number; total: number } | null;
} }
interface StageIconProps { interface StageIconProps {
@@ -22,15 +22,30 @@ interface StageIconProps {
const StageIcon: React.FC<StageIconProps> = ({ status, isCritical }) => { const StageIcon: React.FC<StageIconProps> = ({ status, isCritical }) => {
switch (status) { switch (status) {
case 'in-progress': case 'in-progress':
return <div className="w-5 h-5 text-brand-primary"><LoadingSpinner /></div>; return (
<div className="w-5 h-5 text-brand-primary">
<LoadingSpinner />
</div>
);
case 'completed': case 'completed':
return <CheckCircleIcon className="w-5 h-5 text-green-500" />; return <CheckCircleIcon className="w-5 h-5 text-green-500" />;
case 'pending': case 'pending':
return <div className="w-5 h-5 rounded-full border-2 border-gray-400 dark:border-gray-600"></div>; return (
<div className="w-5 h-5 rounded-full border-2 border-gray-400 dark:border-gray-600"></div>
);
case 'error': case 'error':
return isCritical ? ( return isCritical ? (
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 text-red-500" viewBox="0 0 20 20" fill="currentColor"> <svg
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" /> xmlns="http://www.w3.org/2000/svg"
className="w-5 h-5 text-red-500"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg> </svg>
) : ( ) : (
<ExclamationTriangleIcon className="w-5 h-5 text-yellow-500" /> <ExclamationTriangleIcon className="w-5 h-5 text-yellow-500" />
@@ -40,32 +55,39 @@ const StageIcon: React.FC<StageIconProps> = ({ status, isCritical }) => {
} }
}; };
export const ProcessingStatus: React.FC<ProcessingStatusProps> = ({ stages, estimatedTime, currentFile, pageProgress, bulkProgress, bulkFileCount }) => { export const ProcessingStatus: React.FC<ProcessingStatusProps> = ({
stages,
estimatedTime,
currentFile,
pageProgress,
bulkProgress,
bulkFileCount,
}) => {
const [timeRemaining, setTimeRemaining] = useState(estimatedTime); const [timeRemaining, setTimeRemaining] = useState(estimatedTime);
useEffect(() => { useEffect(() => {
setTimeRemaining(estimatedTime); // Reset when component gets new props setTimeRemaining(estimatedTime); // Reset when component gets new props
const timer = setInterval(() => { const timer = setInterval(() => {
setTimeRemaining(prevTime => (prevTime > 0 ? prevTime - 1 : 0)); setTimeRemaining((prevTime) => (prevTime > 0 ? prevTime - 1 : 0));
}, 1000); }, 1000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [estimatedTime]); }, [estimatedTime]);
const getStatusTextColor = (status: StageStatus, isCritical: boolean) => { const getStatusTextColor = (status: StageStatus, isCritical: boolean) => {
switch (status) { switch (status) {
case 'in-progress': case 'in-progress':
return 'text-brand-primary font-semibold'; return 'text-brand-primary font-semibold';
case 'completed': case 'completed':
return 'text-gray-700 dark:text-gray-300'; return 'text-gray-700 dark:text-gray-300';
case 'pending': case 'pending':
return 'text-gray-400 dark:text-gray-500'; return 'text-gray-400 dark:text-gray-500';
case 'error': case 'error':
return isCritical ? 'text-red-500 font-semibold' : 'text-yellow-600 dark:text-yellow-400'; return isCritical ? 'text-red-500 font-semibold' : 'text-yellow-600 dark:text-yellow-400';
default: default:
return ''; return '';
} }
} };
const title = currentFile ? `Processing: ${currentFile}` : 'Processing Your Flyer...'; const title = currentFile ? `Processing: ${currentFile}` : 'Processing Your Flyer...';
const subTitle = `Estimated time remaining: ${Math.floor(timeRemaining / 60)}m ${timeRemaining % 60}s`; const subTitle = `Estimated time remaining: ${Math.floor(timeRemaining / 60)}m ${timeRemaining % 60}s`;
@@ -79,65 +101,79 @@ export const ProcessingStatus: React.FC<ProcessingStatusProps> = ({ stages, esti
{pageProgress && pageProgress.total > 1 && ( {pageProgress && pageProgress.total > 1 && (
<div className="w-full max-w-sm mb-6"> <div className="w-full max-w-sm mb-6">
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1 text-left"> <p className="text-sm text-gray-500 dark:text-gray-400 mb-1 text-left">
Converting PDF: Page {pageProgress.current} of {pageProgress.total} Converting PDF: Page {pageProgress.current} of {pageProgress.total}
</p> </p>
<div className="w-full bg-gray-200 rounded-full h-2 dark:bg-gray-700"> <div className="w-full bg-gray-200 rounded-full h-2 dark:bg-gray-700">
<div <div
className="bg-blue-500 h-2 rounded-full" className="bg-blue-500 h-2 rounded-full"
style={{ width: `${(pageProgress.current / pageProgress.total) * 100}%`, transition: 'width 0.2s ease-in-out' }} style={{
></div> width: `${(pageProgress.current / pageProgress.total) * 100}%`,
</div> transition: 'width 0.2s ease-in-out',
}}
></div>
</div>
</div> </div>
)} )}
{/* Overall Bulk Progress */} {/* Overall Bulk Progress */}
{bulkFileCount && ( {bulkFileCount && (
<div className="w-full max-w-sm mb-6"> <div className="w-full max-w-sm mb-6">
<p className="text-sm text-center text-gray-500 dark:text-gray-400 mb-1"> <p className="text-sm text-center text-gray-500 dark:text-gray-400 mb-1">
Overall Progress: File {bulkFileCount.current} of {bulkFileCount.total} Overall Progress: File {bulkFileCount.current} of {bulkFileCount.total}
</p> </p>
<div className="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700"> <div className="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700">
<div <div
className="bg-brand-primary h-2.5 rounded-full" className="bg-brand-primary h-2.5 rounded-full"
style={{ width: `${bulkProgress || 0}%`, transition: 'width 0.5s ease-in-out' }} style={{ width: `${bulkProgress || 0}%`, transition: 'width 0.5s ease-in-out' }}
></div> ></div>
</div>
</div> </div>
</div>
)} )}
<div className="w-full max-w-sm text-left"> <div className="w-full max-w-sm text-left">
<ul className="space-y-3"> <ul className="space-y-3">
{stages.map((stage, index) => { {stages.map((stage, index) => {
const isCritical = stage.critical ?? true; const isCritical = stage.critical ?? true;
return ( return (
<li key={index} data-testid={`stage-item-${index}`}> <li key={index} data-testid={`stage-item-${index}`}>
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="shrink-0" data-testid={`stage-icon-${index}`}> <div className="shrink-0" data-testid={`stage-icon-${index}`}>
<StageIcon status={stage.status} isCritical={isCritical} /> <StageIcon status={stage.status} isCritical={isCritical} />
</div> </div>
<span className={`text-sm ${getStatusTextColor(stage.status, isCritical)}`} data-testid={`stage-text-${index}`}> <span
{stage.name} className={`text-sm ${getStatusTextColor(stage.status, isCritical)}`}
{!isCritical && <span className="text-gray-400 dark:text-gray-500 text-xs italic"> (optional)</span>} data-testid={`stage-text-${index}`}
<span className="text-gray-400 dark:text-gray-500 ml-1">{stage.detail}</span> >
{stage.name}
{!isCritical && (
<span className="text-gray-400 dark:text-gray-500 text-xs italic">
{' '}
(optional)
</span> </span>
</div>
{stage.progress && stage.status === 'in-progress' && stage.progress.total > 1 && (
<div className="w-full mt-2 pl-8">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">
Analyzing page {stage.progress.current} of {stage.progress.total}
</p>
<div className="w-full bg-gray-200 rounded-full h-1.5 dark:bg-gray-700">
<div
className="bg-purple-500 h-1.5 rounded-full"
style={{ width: `${(stage.progress.current / stage.progress.total) * 100}%`, transition: 'width 0.5s ease-out' }}
></div>
</div>
</div>
)} )}
</li> <span className="text-gray-400 dark:text-gray-500 ml-1">{stage.detail}</span>
); </span>
})} </div>
{stage.progress && stage.status === 'in-progress' && stage.progress.total > 1 && (
<div className="w-full mt-2 pl-8">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">
Analyzing page {stage.progress.current} of {stage.progress.total}
</p>
<div className="w-full bg-gray-200 rounded-full h-1.5 dark:bg-gray-700">
<div
className="bg-purple-500 h-1.5 rounded-full"
style={{
width: `${(stage.progress.current / stage.progress.total) * 100}%`,
transition: 'width 0.5s ease-out',
}}
></div>
</div>
</div>
)}
</li>
);
})}
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -2,19 +2,18 @@
import React from 'react'; import React from 'react';
interface SampleDataButtonProps { interface SampleDataButtonProps {
onClick: () => void; onClick: () => void;
} }
export const SampleDataButton: React.FC<SampleDataButtonProps> = ({ onClick }) => { export const SampleDataButton: React.FC<SampleDataButtonProps> = ({ onClick }) => {
return ( return (
<div className="text-center"> <div className="text-center">
<button <button
onClick={onClick} onClick={onClick}
className="text-sm text-brand-primary hover:text-brand-dark dark:text-brand-light dark:hover:text-white underline transition-colors" className="text-sm text-brand-primary hover:text-brand-dark dark:text-brand-light dark:hover:text-white underline transition-colors"
> >
No flyer? Try with sample data. No flyer? Try with sample data.
</button> </button>
</div> </div>
); );
}; };

View File

@@ -98,23 +98,33 @@ describe('formatDateRange', () => {
describe('verbose mode', () => { describe('verbose mode', () => {
it('should format a range with two different valid dates verbosely', () => { it('should format a range with two different valid dates verbosely', () => {
expect(formatDateRange('2023-01-01', '2023-01-05', { verbose: true })).toBe('Deals valid from January 1, 2023 to January 5, 2023'); expect(formatDateRange('2023-01-01', '2023-01-05', { verbose: true })).toBe(
'Deals valid from January 1, 2023 to January 5, 2023',
);
}); });
it('should format a range with the same start and end date verbosely', () => { it('should format a range with the same start and end date verbosely', () => {
expect(formatDateRange('2023-01-01', '2023-01-01', { verbose: true })).toBe('Valid on January 1, 2023'); expect(formatDateRange('2023-01-01', '2023-01-01', { verbose: true })).toBe(
'Valid on January 1, 2023',
);
}); });
it('should format only the start date verbosely', () => { it('should format only the start date verbosely', () => {
expect(formatDateRange('2023-01-01', null, { verbose: true })).toBe('Deals start January 1, 2023'); expect(formatDateRange('2023-01-01', null, { verbose: true })).toBe(
'Deals start January 1, 2023',
);
}); });
it('should format only the end date verbosely', () => { it('should format only the end date verbosely', () => {
expect(formatDateRange(null, '2023-01-05', { verbose: true })).toBe('Deals end January 5, 2023'); expect(formatDateRange(null, '2023-01-05', { verbose: true })).toBe(
'Deals end January 5, 2023',
);
}); });
it('should handle one valid and one invalid date verbosely', () => { it('should handle one valid and one invalid date verbosely', () => {
expect(formatDateRange('2023-01-01', 'invalid', { verbose: true })).toBe('Deals start January 1, 2023'); expect(formatDateRange('2023-01-01', 'invalid', { verbose: true })).toBe(
'Deals start January 1, 2023',
);
}); });
}); });
}); });

View File

@@ -2,57 +2,64 @@
import { parseISO, format, isValid, differenceInDays } from 'date-fns'; import { parseISO, format, isValid, differenceInDays } from 'date-fns';
export const formatShortDate = (dateString: string | null | undefined): string | null => { export const formatShortDate = (dateString: string | null | undefined): string | null => {
if (!dateString) return null; if (!dateString) return null;
// Using `parseISO` from date-fns is more reliable than `new Date()` for YYYY-MM-DD strings. // Using `parseISO` from date-fns is more reliable than `new Date()` for YYYY-MM-DD strings.
// It correctly interprets the string as a local date, avoiding timezone-related "off-by-one" errors. // It correctly interprets the string as a local date, avoiding timezone-related "off-by-one" errors.
const date = parseISO(dateString); const date = parseISO(dateString);
if (isValid(date)) { if (isValid(date)) {
return format(date, 'MMM d'); return format(date, 'MMM d');
} }
return null; return null;
} };
export const calculateDaysBetween = (startDate: string | Date | null | undefined, endDate: string | Date | null | undefined): number | null => { export const calculateDaysBetween = (
if (!startDate || !endDate) return null; startDate: string | Date | null | undefined,
endDate: string | Date | null | undefined,
): number | null => {
if (!startDate || !endDate) return null;
const start = typeof startDate === 'string' ? parseISO(startDate) : startDate; const start = typeof startDate === 'string' ? parseISO(startDate) : startDate;
const end = typeof endDate === 'string' ? parseISO(endDate) : endDate; const end = typeof endDate === 'string' ? parseISO(endDate) : endDate;
if (!isValid(start) || !isValid(end)) return null; if (!isValid(start) || !isValid(end)) return null;
return differenceInDays(end, start); return differenceInDays(end, start);
}; };
interface DateRangeOptions { interface DateRangeOptions {
verbose?: boolean; verbose?: boolean;
} }
export const formatDateRange = (startDate: string | null | undefined, endDate: string | null | undefined, options?: DateRangeOptions): string | null => { export const formatDateRange = (
if (!options?.verbose) { startDate: string | null | undefined,
const start = formatShortDate(startDate); endDate: string | null | undefined,
const end = formatShortDate(endDate); options?: DateRangeOptions,
): string | null => {
if (start && end) { if (!options?.verbose) {
return start === end ? start : `${start} - ${end}`; const start = formatShortDate(startDate);
} const end = formatShortDate(endDate);
return start || end || null;
}
// Verbose format logic
const dateFormat = 'MMMM d, yyyy';
const formatFn = (dateStr: string | null | undefined) => {
if (!dateStr) return null;
const date = parseISO(dateStr);
return isValid(date) ? format(date, dateFormat) : null;
};
const start = formatFn(startDate);
const end = formatFn(endDate);
if (start && end) { if (start && end) {
return start === end ? `Valid on ${start}` : `Deals valid from ${start} to ${end}`; return start === end ? start : `${start} - ${end}`;
} }
if (start) return `Deals start ${start}`; return start || end || null;
if (end) return `Deals end ${end}`; }
return null;
// Verbose format logic
const dateFormat = 'MMMM d, yyyy';
const formatFn = (dateStr: string | null | undefined) => {
if (!dateStr) return null;
const date = parseISO(dateStr);
return isValid(date) ? format(date, dateFormat) : null;
};
const start = formatFn(startDate);
const end = formatFn(endDate);
if (start && end) {
return start === end ? `Valid on ${start}` : `Deals valid from ${start} to ${end}`;
}
if (start) return `Deals start ${start}`;
if (end) return `Deals end ${end}`;
return null;
}; };

Some files were not shown because too many files have changed in this diff Show More