Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2041b4ac3c | ||
| e547363a65 | |||
| bddaf765fc | |||
|
|
3c0bebb65c | ||
| 265cc3ffd4 | |||
| 3d5767b60b | |||
|
|
e9cb45efe0 | ||
| 99a57f3a30 | |||
| e46f5eb7f6 | |||
|
|
034887069c | ||
| 84b5e0e15e | |||
| dc0f774699 | |||
|
|
1195b7e87f | ||
| e9889f1f1e | |||
| 3c7f6429aa | |||
|
|
0db90dfaa6 | ||
| b7a1294ae6 | |||
|
|
be652f9790 | ||
| 1a3e6a9ab5 | |||
|
|
262396ddd0 | ||
| c542796048 | |||
| 5b8f309ad8 | |||
|
|
6a73659f85 | ||
| 22513a967b | |||
| a10f84aa48 | |||
|
|
621d30b84f | ||
| ed857f588a | |||
|
|
fee55b0afd | ||
| 35538ea011 | |||
| 368b8e704c |
@@ -51,7 +51,14 @@ jobs:
|
|||||||
|
|
||||||
# Bump the patch version number. This creates a new commit and a new tag.
|
# Bump the patch version number. This creates a new commit and a new tag.
|
||||||
# The commit message includes [skip ci] to prevent this push from triggering another workflow run.
|
# The commit message includes [skip ci] to prevent this push from triggering another workflow run.
|
||||||
npm version patch -m "ci: Bump version to %s [skip ci]"
|
# If the tag already exists (e.g. re-running a failed job), we skip the conflicting version.
|
||||||
|
if ! npm version patch -m "ci: Bump version to %s [skip ci]"; then
|
||||||
|
echo "⚠️ Version bump failed (likely tag exists). Attempting to skip to next version..."
|
||||||
|
# Bump package.json to the conflicting version without git tagging
|
||||||
|
npm version patch --no-git-tag-version > /dev/null
|
||||||
|
# Bump again to the next version, forcing it because the directory is now dirty
|
||||||
|
npm version patch -m "ci: Bump version to %s [skip ci]" --force
|
||||||
|
fi
|
||||||
|
|
||||||
# Push the new commit and the new tag back to the main branch.
|
# Push the new commit and the new tag back to the main branch.
|
||||||
git push --follow-tags
|
git push --follow-tags
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "flyer-crawler",
|
"name": "flyer-crawler",
|
||||||
"version": "0.0.1",
|
"version": "0.0.12",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "flyer-crawler",
|
"name": "flyer-crawler",
|
||||||
"version": "0.0.1",
|
"version": "0.0.12",
|
||||||
"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",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "flyer-crawler",
|
"name": "flyer-crawler",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.1",
|
"version": "0.0.12",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||||
|
|||||||
1265
src/App.test.tsx
1265
src/App.test.tsx
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -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')!;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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}¢er=${latitude},${longitude}&zoom=14`;
|
const mapSrc = `https://www.google.com/maps/embed/v1/view?key=${apiKey}¢er=${latitude},${longitude}&zoom=14`;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
257
src/db/seed.ts
257
src/db/seed.ts
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
@@ -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.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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%');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -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',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user