Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0db90dfaa6 | ||
| b7a1294ae6 | |||
|
|
be652f9790 | ||
| 1a3e6a9ab5 | |||
|
|
262396ddd0 | ||
| c542796048 | |||
| 5b8f309ad8 | |||
|
|
6a73659f85 | ||
| 22513a967b | |||
| a10f84aa48 | |||
|
|
621d30b84f | ||
| ed857f588a |
@@ -51,7 +51,14 @@ jobs:
|
||||
|
||||
# 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.
|
||||
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.
|
||||
git push --follow-tags
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.7",
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^6.14.2",
|
||||
"@bull-board/express": "^6.14.2",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"private": true,
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||
|
||||
1267
src/App.test.tsx
1267
src/App.test.tsx
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,12 @@ describe('AchievementsList', () => {
|
||||
icon: 'chef-hat',
|
||||
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
|
||||
];
|
||||
|
||||
@@ -40,6 +45,8 @@ describe('AchievementsList', () => {
|
||||
|
||||
it('should render a message when there are no 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 } = {
|
||||
'chef-hat': '🧑🍳',
|
||||
'share-2': '🤝',
|
||||
'list': '📋',
|
||||
'heart': '❤️',
|
||||
list: '📋',
|
||||
heart: '❤️',
|
||||
'git-fork': '🍴',
|
||||
'piggy-bank': '🐷',
|
||||
};
|
||||
@@ -32,14 +32,19 @@ export const AchievementsList: React.FC<AchievementsListProps> = ({ achievements
|
||||
{achievements.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{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">
|
||||
<Icon name={ach.icon} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold">{ach.name}</h3>
|
||||
<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>
|
||||
))}
|
||||
@@ -49,4 +54,4 @@ export const AchievementsList: React.FC<AchievementsListProps> = ({ achievements
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@ const renderWithRouter = (profile: Profile | null, initialPath: string) => {
|
||||
<Route index element={<AdminContent />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -51,4 +51,4 @@ describe('AdminRoute', () => {
|
||||
expect(screen.getByText('Home Page')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Admin Page Content')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,11 +71,11 @@ describe('ConfirmationModal (in components)', () => {
|
||||
confirmButtonText="Yes, Delete"
|
||||
cancelButtonText="No, Keep"
|
||||
confirmButtonClass="bg-blue-500"
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
const confirmButton = screen.getByRole('button', { name: 'Yes, Delete' });
|
||||
expect(confirmButton).toBeInTheDocument();
|
||||
expect(confirmButton).toHaveClass('bg-blue-500');
|
||||
expect(screen.getByRole('button', { name: 'No, Keep' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,7 +35,7 @@ export const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
|
||||
>
|
||||
<div
|
||||
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
|
||||
onClick={onClose}
|
||||
@@ -47,10 +47,16 @@ export const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
|
||||
<div className="p-6">
|
||||
<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">
|
||||
<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 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}
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
@@ -60,10 +66,22 @@ export const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
|
||||
</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">
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -46,4 +46,4 @@ describe('DarkModeToggle', () => {
|
||||
|
||||
expect(mockOnToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,11 @@ interface DarkModeToggleProps {
|
||||
|
||||
export const DarkModeToggle: React.FC<DarkModeToggleProps> = ({ isDarkMode, onToggle }) => {
|
||||
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">
|
||||
<input
|
||||
id="dark-mode-toggle"
|
||||
@@ -20,8 +24,14 @@ export const DarkModeToggle: React.FC<DarkModeToggleProps> = ({ isDarkMode, onTo
|
||||
onChange={onToggle}
|
||||
/>
|
||||
<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' : ''}`}>
|
||||
{isDarkMode ? <MoonIcon className="w-4 h-4 text-yellow-300" /> : <SunIcon className="w-4 h-4 text-yellow-500" />}
|
||||
<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' : ''}`}
|
||||
>
|
||||
{isDarkMode ? (
|
||||
<MoonIcon className="w-4 h-4 text-yellow-300" />
|
||||
) : (
|
||||
<SunIcon className="w-4 h-4 text-yellow-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -26,4 +26,4 @@ describe('ErrorDisplay (in components)', () => {
|
||||
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
||||
expect(alert).toHaveClass('bg-red-100');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ErrorDisplayProps {
|
||||
message: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({ message }) => {
|
||||
if (!message) return null;
|
||||
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">
|
||||
<strong className="font-bold">Error: </strong>
|
||||
<span className="block sm:inline">{message}</span>
|
||||
</div>
|
||||
);
|
||||
if (!message) return null;
|
||||
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"
|
||||
>
|
||||
<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
|
||||
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>;
|
||||
|
||||
// Mock canvas methods for jsdom environment
|
||||
@@ -109,7 +109,7 @@ describe('FlyerCorrectionTool', () => {
|
||||
// 1. Create a controllable promise for the mock.
|
||||
console.log('--- [TEST LOG] ---: 1. Setting up controllable promise for rescanImageArea.');
|
||||
let resolveRescanPromise: (value: Response | PromiseLike<Response>) => void;
|
||||
const rescanPromise = new Promise<Response>(resolve => {
|
||||
const rescanPromise = new Promise<Response>((resolve) => {
|
||||
resolveRescanPromise = resolve;
|
||||
});
|
||||
mockedAiApiClient.rescanImageArea.mockReturnValue(rescanPromise);
|
||||
@@ -162,7 +162,7 @@ describe('FlyerCorrectionTool', () => {
|
||||
expect.any(File),
|
||||
// 10*2=20, 10*2=20, (60-10)*2=100, (30-10)*2=40
|
||||
{ x: 20, y: 20, width: 100, height: 40 },
|
||||
'store_name'
|
||||
'store_name',
|
||||
);
|
||||
});
|
||||
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.
|
||||
console.log('--- [TEST LOG] ---: 6. Awaiting final state assertions...');
|
||||
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(defaultProps.onDataExtracted).toHaveBeenCalledWith('store_name', 'Super Store');
|
||||
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
|
||||
@@ -198,19 +200,21 @@ describe('FlyerCorrectionTool', () => {
|
||||
});
|
||||
|
||||
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
|
||||
// This allows us to test the guard clause inside handleRescan while the button is enabled
|
||||
global.fetch = vi.fn(() => {
|
||||
console.log('TEST: fetch called, returning pending promise to simulate loading');
|
||||
return new Promise(() => {});
|
||||
return new Promise(() => {});
|
||||
}) as Mocked<typeof fetch>;
|
||||
|
||||
render(<FlyerCorrectionTool {...defaultProps} />);
|
||||
|
||||
|
||||
const canvas = screen.getByRole('dialog').querySelector('canvas')!;
|
||||
|
||||
|
||||
// Draw a selection to enable the button (bypassing the disabled={!selectionRect} check)
|
||||
console.log('TEST: Drawing selection to enable button');
|
||||
fireEvent.mouseDown(canvas, { clientX: 10, clientY: 10 });
|
||||
@@ -221,7 +225,7 @@ describe('FlyerCorrectionTool', () => {
|
||||
expect(extractButton).toBeEnabled();
|
||||
console.log('TEST: Button is enabled, clicking now...');
|
||||
|
||||
// Attempt rescan.
|
||||
// Attempt rescan.
|
||||
// - selectionRect is present (button enabled)
|
||||
// - imageFile is null (fetch pending)
|
||||
// -> Should trigger guard and notifyError
|
||||
@@ -240,18 +244,18 @@ describe('FlyerCorrectionTool', () => {
|
||||
await waitFor(() => expect(global.fetch).toHaveBeenCalled());
|
||||
// Allow the promise chain in useEffect to complete
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
const canvas = screen.getByRole('dialog').querySelector('canvas')!;
|
||||
fireEvent.mouseDown(canvas, { clientX: 10, clientY: 10 });
|
||||
fireEvent.mouseMove(canvas, { clientX: 100, clientY: 50 });
|
||||
fireEvent.mouseUp(canvas);
|
||||
|
||||
|
||||
console.log('TEST: Clicking button to trigger API error');
|
||||
fireEvent.click(screen.getByRole('button', { name: /extract store name/i }));
|
||||
await waitFor(() => {
|
||||
expect(mockedNotifyError).toHaveBeenCalledWith('An unknown error occurred.');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,12 @@ export interface FlyerCorrectionToolProps {
|
||||
type Rect = { x: number; y: number; width: number; height: number };
|
||||
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 imageRef = useRef<HTMLImageElement>(null);
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
@@ -31,13 +36,13 @@ export const FlyerCorrectionTool: React.FC<FlyerCorrectionToolProps> = ({ isOpen
|
||||
if (isOpen && imageUrl) {
|
||||
console.debug('[DEBUG] FlyerCorrectionTool: isOpen is true, fetching image URL:', imageUrl);
|
||||
fetch(imageUrl)
|
||||
.then(res => res.blob())
|
||||
.then(blob => {
|
||||
.then((res) => res.blob())
|
||||
.then((blob) => {
|
||||
const file = new File([blob], 'flyer-image.jpg', { type: blob.type });
|
||||
setImageFile(file);
|
||||
console.debug('[DEBUG] FlyerCorrectionTool: Image fetched and stored as File object.');
|
||||
})
|
||||
.catch(err => {
|
||||
.catch((err) => {
|
||||
console.error('[DEBUG] FlyerCorrectionTool: Failed to fetch image.', { err });
|
||||
logger.error('Failed to fetch image for correction tool', { error: err });
|
||||
notifyError('Could not load the image for correction.');
|
||||
@@ -74,7 +79,9 @@ export const FlyerCorrectionTool: React.FC<FlyerCorrectionToolProps> = ({ isOpen
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [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;
|
||||
if (!canvas) return { x: 0, y: 0 };
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
@@ -110,14 +117,16 @@ export const FlyerCorrectionTool: React.FC<FlyerCorrectionToolProps> = ({ isOpen
|
||||
|
||||
const handleRescan = async (type: ExtractionType) => {
|
||||
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) {
|
||||
console.warn('[DEBUG] handleRescan: Guard failed. Missing prerequisites.');
|
||||
if (!selectionRect) console.warn('[DEBUG] Reason: No selectionRect');
|
||||
if (!imageRef.current) console.warn('[DEBUG] Reason: No imageRef');
|
||||
if (!imageFile) console.warn('[DEBUG] Reason: No imageFile');
|
||||
|
||||
|
||||
notifyError('Please select an area on the image first.');
|
||||
return;
|
||||
}
|
||||
@@ -164,16 +173,40 @@ export const FlyerCorrectionTool: React.FC<FlyerCorrectionToolProps> = ({ isOpen
|
||||
|
||||
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 (
|
||||
<div 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="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">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center"><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>
|
||||
<h2 className="text-lg font-semibold text-white flex items-center">
|
||||
<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 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
|
||||
ref={canvasRef}
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 cursor-crosshair"
|
||||
@@ -212,4 +245,4 @@ export const FlyerCorrectionTool: React.FC<FlyerCorrectionToolProps> = ({ isOpen
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -80,4 +80,4 @@ describe('FlyerCountDisplay', () => {
|
||||
expect(countDisplay).toBeInTheDocument();
|
||||
expect(countDisplay).toHaveTextContent('Number of flyers: 2');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,4 +19,4 @@ export const FlyerCountDisplay: React.FC = () => {
|
||||
}
|
||||
|
||||
return <div data-testid="flyer-count">Number of flyers: {flyers.length}</div>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -32,4 +32,4 @@ describe('Footer', () => {
|
||||
render(<Footer />);
|
||||
expect(screen.getByText('Copyright 2025-2030')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,4 +8,4 @@ export const Footer: React.FC = () => {
|
||||
Copyright 2025-{currentYear}
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -38,7 +38,7 @@ const renderWithRouter = (props: Partial<React.ComponentProps<typeof Header>>) =
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<Header {...defaultProps} {...props} />
|
||||
</MemoryRouter>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -121,4 +121,4 @@ describe('Header', () => {
|
||||
expect(screen.queryByTitle(/admin area/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,15 @@ export interface HeaderProps {
|
||||
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.
|
||||
return (
|
||||
<>
|
||||
@@ -34,14 +42,14 @@ export const Header: React.FC<HeaderProps> = ({ isDarkMode, unitSystem, authStat
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 md:space-x-6">
|
||||
{userProfile && (
|
||||
<button
|
||||
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"
|
||||
aria-label="Open voice assistant"
|
||||
title="Voice Assistant"
|
||||
>
|
||||
<MicrophoneIcon className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
aria-label="Open voice assistant"
|
||||
title="Voice Assistant"
|
||||
>
|
||||
<MicrophoneIcon className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
{/* 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">
|
||||
@@ -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.
|
||||
<div className="flex items-center space-x-3">
|
||||
<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" />
|
||||
{authStatus === 'AUTHENTICATED' ? (
|
||||
// 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-500 dark:text-gray-400 italic">Guest</span>
|
||||
)}
|
||||
<UserIcon className="w-5 h-5 text-gray-500 dark:text-gray-400" />
|
||||
{authStatus === 'AUTHENTICATED' ? (
|
||||
// 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-500 dark:text-gray-400 italic">
|
||||
Guest
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onOpenProfile}
|
||||
@@ -71,7 +83,11 @@ export const Header: React.FC<HeaderProps> = ({ isDarkMode, unitSystem, authStat
|
||||
<Cog8ToothIcon className="w-5 h-5" />
|
||||
</button>
|
||||
{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" />
|
||||
</Link>
|
||||
)}
|
||||
@@ -97,4 +113,4 @@ export const Header: React.FC<HeaderProps> = ({ isDarkMode, unitSystem, authStat
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -26,7 +26,13 @@ vi.mock('lucide-react', () => ({
|
||||
|
||||
const mockLeaderboardData: LeaderboardUser[] = [
|
||||
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-4', full_name: 'Diana', points: 850, rank: '4' }),
|
||||
];
|
||||
@@ -69,12 +75,16 @@ describe('Leaderboard', () => {
|
||||
render(<Leaderboard />);
|
||||
|
||||
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 () => {
|
||||
mockedApiClient.fetchLeaderboard.mockResolvedValue(new Response(JSON.stringify(mockLeaderboardData)));
|
||||
mockedApiClient.fetchLeaderboard.mockResolvedValue(
|
||||
new Response(JSON.stringify(mockLeaderboardData)),
|
||||
);
|
||||
render(<Leaderboard />);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -97,7 +107,9 @@ describe('Leaderboard', () => {
|
||||
});
|
||||
|
||||
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 />);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -114,7 +126,9 @@ describe('Leaderboard', () => {
|
||||
const dataWithMissingNames: LeaderboardUser[] = [
|
||||
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 />);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -128,4 +142,4 @@ describe('Leaderboard', () => {
|
||||
expect(avatar.src).toContain('seed=user-anon');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,7 +51,10 @@ export const Leaderboard: React.FC = () => {
|
||||
|
||||
if (error) {
|
||||
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">
|
||||
<ShieldAlert className="h-6 w-6 mr-3" />
|
||||
<p className="font-bold">Error: {error}</p>
|
||||
@@ -67,21 +70,29 @@ export const Leaderboard: React.FC = () => {
|
||||
Top Users
|
||||
</h2>
|
||||
{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">
|
||||
{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">
|
||||
<div className="shrink-0 w-8 text-center">
|
||||
{getRankIcon(user.rank)}
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
<div className="shrink-0 w-8 text-center">{getRankIcon(user.rank)}</div>
|
||||
<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'}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
<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 className="text-lg font-bold text-blue-600 dark:text-blue-400">
|
||||
{user.points} pts
|
||||
@@ -94,4 +105,4 @@ export const Leaderboard: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Leaderboard;
|
||||
export default Leaderboard;
|
||||
|
||||
@@ -19,4 +19,4 @@ describe('LoadingSpinner (in components)', () => {
|
||||
expect(circle).toBeInTheDocument();
|
||||
expect(path).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,24 @@
|
||||
import React from 'react';
|
||||
|
||||
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">
|
||||
<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
|
||||
className="animate-spin h-full w-full text-current"
|
||||
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>
|
||||
);
|
||||
);
|
||||
|
||||
@@ -20,14 +20,14 @@ vi.mock('../config', () => ({
|
||||
version: 'test',
|
||||
commitMessage: 'test',
|
||||
commitUrl: 'test',
|
||||
}
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('MapView', () => {
|
||||
const defaultProps = {
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
longitude: -74.006,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -41,7 +41,9 @@ describe('MapView', () => {
|
||||
describe('when API key is not configured', () => {
|
||||
it('should render a disabled message', () => {
|
||||
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', () => {
|
||||
@@ -61,7 +63,7 @@ describe('MapView', () => {
|
||||
|
||||
it('should render the iframe with the correct src URL', () => {
|
||||
render(<MapView {...defaultProps} />);
|
||||
|
||||
|
||||
// Use getByTitle to access the iframe
|
||||
const iframe = screen.getByTitle('Map view');
|
||||
const expectedSrc = `https://www.google.com/maps/embed/v1/view?key=${mockApiKey}¢er=${defaultProps.latitude},${defaultProps.longitude}&zoom=14`;
|
||||
@@ -74,4 +76,4 @@ describe('MapView', () => {
|
||||
expect(iframe).toHaveAttribute('allowFullScreen');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,9 @@ export const MapView: React.FC<MapViewProps> = ({ latitude, longitude }) => {
|
||||
const apiKey = config.google.mapsEmbedApiKey;
|
||||
|
||||
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`;
|
||||
|
||||
@@ -38,4 +38,4 @@ describe('UnitSystemToggle', () => {
|
||||
fireEvent.click(screen.getByRole('checkbox'));
|
||||
expect(mockOnToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,10 +11,15 @@ export const UnitSystemToggle: React.FC<UnitSystemToggleProps> = ({ currentSyste
|
||||
|
||||
return (
|
||||
<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
|
||||
</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
|
||||
type="checkbox"
|
||||
id="unit-system-toggle"
|
||||
@@ -24,9 +29,11 @@ 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>
|
||||
</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
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -22,11 +22,15 @@ describe('UserMenuSkeleton', () => {
|
||||
|
||||
it('should render a rectangular placeholder with correct styles', () => {
|
||||
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', () => {
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,4 +12,4 @@ export const UserMenuSkeleton: React.FC = () => {
|
||||
<div className="h-10 w-10 bg-gray-200 dark:bg-gray-700 rounded-full"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -62,4 +62,4 @@ describe('WhatsNewModal', () => {
|
||||
fireEvent.click(screen.getByText(defaultProps.commitMessage));
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,15 +10,23 @@ export interface WhatsNewModalProps {
|
||||
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;
|
||||
|
||||
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
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="whats-new-title"
|
||||
aria-labelledby="whats-new-title"
|
||||
className="relative bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-md m-4 transform transition-all"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -28,13 +36,17 @@ export const WhatsNewModal: React.FC<WhatsNewModalProps> = ({ isOpen, onClose, v
|
||||
<GiftIcon className="w-6 h-6 text-brand-primary" />
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 className="mt-6 flex justify-end">
|
||||
@@ -56,4 +68,4 @@ export const WhatsNewModal: React.FC<WhatsNewModalProps> = ({ isOpen, onClose, v
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
// src/components/icons/ArrowPathIcon.tsx
|
||||
import React from 'react';
|
||||
|
||||
export const ArrowPathIcon: 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.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>
|
||||
);
|
||||
<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.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';
|
||||
|
||||
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}>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
);
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
export const BellAlertIcon: 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="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>
|
||||
);
|
||||
<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="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>
|
||||
);
|
||||
|
||||
@@ -16,4 +16,4 @@ export const BookOpenIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) =>
|
||||
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
);
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
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}>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
export const BuildingStorefrontIcon: 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="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>
|
||||
);
|
||||
<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="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';
|
||||
|
||||
export const ChartBarIcon: 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 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>
|
||||
);
|
||||
<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 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 }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
{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>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
{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';
|
||||
|
||||
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';
|
||||
|
||||
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';
|
||||
|
||||
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}>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
);
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
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}>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
export const DocumentDuplicateIcon: 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="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>
|
||||
);
|
||||
<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.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';
|
||||
|
||||
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}>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
export const ExclamationTriangleIcon: 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,8 +1,19 @@
|
||||
import React from 'react';
|
||||
|
||||
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}>
|
||||
<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" />
|
||||
<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="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" />
|
||||
</svg>
|
||||
);
|
||||
);
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import React from 'react';
|
||||
|
||||
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}>
|
||||
<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" />
|
||||
<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.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" />
|
||||
</svg>
|
||||
);
|
||||
);
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
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}>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
);
|
||||
|
||||
@@ -2,6 +2,10 @@ import React from 'react';
|
||||
|
||||
export const GithubIcon: React.FC<React.SVGProps<SVGSVGElement>> = (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>
|
||||
);
|
||||
|
||||
@@ -2,9 +2,21 @@ import React from 'react';
|
||||
|
||||
export const GoogleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (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 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>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
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}>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
);
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
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}>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
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}>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
);
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import React from 'react';
|
||||
|
||||
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="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>
|
||||
);
|
||||
);
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
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}>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
);
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
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}>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
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}>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
export const PencilIcon: 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.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>
|
||||
);
|
||||
<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.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';
|
||||
|
||||
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}>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
);
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import React from 'react';
|
||||
|
||||
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}>
|
||||
<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
|
||||
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 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>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
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}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v6m3-3H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
<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 9v6m3-3H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
);
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
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}>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
);
|
||||
|
||||
@@ -2,5 +2,18 @@
|
||||
import React from 'react';
|
||||
|
||||
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';
|
||||
|
||||
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}>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
);
|
||||
|
||||
@@ -2,5 +2,18 @@
|
||||
import React from 'react';
|
||||
|
||||
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';
|
||||
|
||||
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';
|
||||
|
||||
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}>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
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}>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
);
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
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}>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
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';
|
||||
|
||||
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}>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
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}>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
);
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
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}>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
);
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
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}>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
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}>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
);
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
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}>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
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}>
|
||||
<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" />
|
||||
<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.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" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
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}>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
);
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import React from 'react';
|
||||
|
||||
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}>
|
||||
<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
|
||||
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.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>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
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}>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
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}>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
export const UsersIcon: 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="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>
|
||||
);
|
||||
<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="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';
|
||||
|
||||
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}>
|
||||
<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>
|
||||
<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.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';
|
||||
|
||||
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" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -12,4 +12,4 @@ type ApiContextType = typeof apiClient;
|
||||
* Creates the React Context for the API client.
|
||||
* It's initialized with the actual apiClient module.
|
||||
*/
|
||||
export const ApiContext = createContext<ApiContextType>(apiClient);
|
||||
export const ApiContext = createContext<ApiContextType>(apiClient);
|
||||
|
||||
@@ -19,4 +19,4 @@ export interface AuthContextType {
|
||||
updateProfile: (updatedProfileData: Partial<UserProfile>) => void;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
export const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
@@ -12,4 +12,4 @@ export interface FlyersContextType {
|
||||
refetchFlyers: () => void;
|
||||
}
|
||||
|
||||
export const FlyersContext = createContext<FlyersContextType | undefined>(undefined);
|
||||
export const FlyersContext = createContext<FlyersContextType | undefined>(undefined);
|
||||
|
||||
@@ -8,4 +8,4 @@ export interface MasterItemsContextType {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const MasterItemsContext = createContext<MasterItemsContextType | undefined>(undefined);
|
||||
export const MasterItemsContext = createContext<MasterItemsContextType | undefined>(undefined);
|
||||
|
||||
@@ -17,4 +17,4 @@ export interface ModalContextType {
|
||||
}
|
||||
|
||||
// Create the context with a default value (which should not be used directly).
|
||||
export const ModalContext = createContext<ModalContextType | undefined>(undefined);
|
||||
export const ModalContext = createContext<ModalContextType | undefined>(undefined);
|
||||
|
||||
@@ -11,4 +11,4 @@ export interface UserDataContextType {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const UserDataContext = React.createContext<UserDataContextType | undefined>(undefined);
|
||||
export const UserDataContext = React.createContext<UserDataContextType | undefined>(undefined);
|
||||
|
||||
@@ -50,21 +50,21 @@ const pool = new Pool({
|
||||
* Tables with direct user_id foreign keys come first.
|
||||
*/
|
||||
const USER_DATA_TABLES: Record<string, string> = {
|
||||
'users': 'user_id',
|
||||
'profiles': 'user_id',
|
||||
'pantry_locations': 'user_id',
|
||||
'shopping_lists': 'user_id',
|
||||
'recipes': 'user_id',
|
||||
'menu_plans': 'user_id',
|
||||
'recipe_collections': 'user_id',
|
||||
'user_item_aliases': 'user_id',
|
||||
'user_appliances': 'user_id',
|
||||
'user_dietary_restrictions': 'user_id',
|
||||
'favorite_stores': 'user_id',
|
||||
'favorite_recipes': 'user_id',
|
||||
'user_watched_items': 'user_id',
|
||||
'receipts': 'user_id',
|
||||
'shopping_trips': 'user_id',
|
||||
users: 'user_id',
|
||||
profiles: 'user_id',
|
||||
pantry_locations: 'user_id',
|
||||
shopping_lists: 'user_id',
|
||||
recipes: 'user_id',
|
||||
menu_plans: 'user_id',
|
||||
recipe_collections: 'user_id',
|
||||
user_item_aliases: 'user_id',
|
||||
user_appliances: 'user_id',
|
||||
user_dietary_restrictions: 'user_id',
|
||||
favorite_stores: 'user_id',
|
||||
favorite_recipes: 'user_id',
|
||||
user_watched_items: 'user_id',
|
||||
receipts: 'user_id',
|
||||
shopping_trips: 'user_id',
|
||||
};
|
||||
|
||||
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.
|
||||
*/
|
||||
function generateInsertStatement(table: string, row: Record<string, DBValue>): string {
|
||||
const columns = Object.keys(row).map(col => `"${col}"`).join(', ');
|
||||
const values = Object.values(row).map(val => {
|
||||
if (val === null) return 'NULL';
|
||||
if (val instanceof Date) return `'${val.toISOString()}'`; // Handle Date objects
|
||||
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(', ');
|
||||
const columns = Object.keys(row)
|
||||
.map((col) => `"${col}"`)
|
||||
.join(', ');
|
||||
const values = Object.values(row)
|
||||
.map((val) => {
|
||||
if (val === null) return 'NULL';
|
||||
if (val instanceof Date) return `'${val.toISOString()}'`; // Handle Date objects
|
||||
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() {
|
||||
const args = process.argv.slice(2);
|
||||
const emailIndex = args.indexOf('--email');
|
||||
const outputIndex = args.indexOf('--output');
|
||||
const args = process.argv.slice(2);
|
||||
const emailIndex = args.indexOf('--email');
|
||||
const outputIndex = args.indexOf('--output');
|
||||
|
||||
if (emailIndex === -1 || outputIndex === -1) {
|
||||
console.error('Usage: tsx src/db/backup_user.ts --email <user_email> --output <output_file.sql>');
|
||||
process.exit(1);
|
||||
if (emailIndex === -1 || outputIndex === -1) {
|
||||
console.error(
|
||||
'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];
|
||||
const outputFile = args[outputIndex + 1];
|
||||
let client: PoolClient | null = null;
|
||||
// 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);
|
||||
|
||||
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;
|
||||
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);
|
||||
}
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
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) {
|
||||
await client.query(`TRUNCATE ${tables} RESTART IDENTITY CASCADE`);
|
||||
logger.info('All tables in public schema have been truncated.');
|
||||
await client.query(`TRUNCATE ${tables} RESTART IDENTITY CASCADE`);
|
||||
logger.info('All tables in public schema have been truncated.');
|
||||
}
|
||||
|
||||
// 2. Seed Categories
|
||||
logger.info('--- Seeding Categories... ---');
|
||||
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 categoryMap = new Map(seededCategories.map(c => [c.name, c.category_id]));
|
||||
const seededCategories = (
|
||||
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.`);
|
||||
|
||||
// 3. Seed Stores
|
||||
logger.info('--- Seeding Stores... ---');
|
||||
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 seededStores = (await client.query<{store_id: number, name: string}>(storeQuery, stores)).rows;
|
||||
const storeMap = new Map(seededStores.map(s => [s.name, s.store_id]));
|
||||
const seededStores = (
|
||||
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.`);
|
||||
|
||||
// 4. Seed Master Grocery Items
|
||||
logger.info('--- Seeding Master Grocery Items... ---');
|
||||
const masterItems = [
|
||||
{ name: 'Chicken Breast, Boneless Skinless', category: 'Meat & Seafood' },
|
||||
{ name: 'Ground Beef, Lean', category: 'Meat & Seafood' },
|
||||
{ name: 'Avocado', category: 'Fruits & Vegetables' },
|
||||
{ name: 'Bananas', category: 'Fruits & Vegetables' },
|
||||
{ name: 'Broccoli', category: 'Fruits & Vegetables' },
|
||||
{ name: 'Cheddar Cheese, Block', category: 'Dairy & Eggs' },
|
||||
{ name: 'Milk, 2%', category: 'Dairy & Eggs' },
|
||||
{ name: 'Eggs, Large', category: 'Dairy & Eggs' },
|
||||
{ name: 'Whole Wheat Bread', category: 'Bakery & Bread' },
|
||||
{ name: 'Pasta, Spaghetti', category: 'Pantry & Dry Goods' },
|
||||
{ name: 'Canned Tomatoes, Diced', category: 'Canned Goods' },
|
||||
{ name: 'Coca-Cola, 12-pack', category: 'Beverages' },
|
||||
{ name: 'Frozen Pizza', category: 'Frozen Foods' },
|
||||
{ name: 'Paper Towels', category: 'Household & Cleaning' },
|
||||
{ name: 'Chicken Breast, Boneless Skinless', category: 'Meat & Seafood' },
|
||||
{ name: 'Ground Beef, Lean', category: 'Meat & Seafood' },
|
||||
{ name: 'Avocado', category: 'Fruits & Vegetables' },
|
||||
{ name: 'Bananas', category: 'Fruits & Vegetables' },
|
||||
{ name: 'Broccoli', category: 'Fruits & Vegetables' },
|
||||
{ name: 'Cheddar Cheese, Block', category: 'Dairy & Eggs' },
|
||||
{ name: 'Milk, 2%', category: 'Dairy & Eggs' },
|
||||
{ name: 'Eggs, Large', category: 'Dairy & Eggs' },
|
||||
{ name: 'Whole Wheat Bread', category: 'Bakery & Bread' },
|
||||
{ name: 'Pasta, Spaghetti', category: 'Pantry & Dry Goods' },
|
||||
{ name: 'Canned Tomatoes, Diced', category: 'Canned Goods' },
|
||||
{ name: 'Coca-Cola, 12-pack', category: 'Beverages' },
|
||||
{ name: 'Frozen Pizza', category: 'Frozen Foods' },
|
||||
{ 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 seededMasterItems = (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]));
|
||||
const seededMasterItems = (
|
||||
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.`);
|
||||
|
||||
// 5. Seed Users & Profiles
|
||||
@@ -91,9 +101,14 @@ async function main() {
|
||||
const userPassHash = await bcrypt.hash('userpass', saltRounds);
|
||||
|
||||
// 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.
|
||||
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;
|
||||
// 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]);
|
||||
@@ -101,8 +116,13 @@ async function main() {
|
||||
logger.info(`> Role for ${adminId} set to 'admin'.`);
|
||||
|
||||
// Regular User
|
||||
await client.query("SELECT set_config('my_app.user_metadata', $1, true)", [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]);
|
||||
await client.query("SELECT set_config('my_app.user_metadata', $1, true)", [
|
||||
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;
|
||||
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)
|
||||
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;
|
||||
logger.info(`Seeded flyer for Safeway (ID: ${flyerId}).`);
|
||||
|
||||
// 7. Seed Flyer Items
|
||||
logger.info('--- Seeding Flyer Items... ---');
|
||||
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: '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 },
|
||||
{
|
||||
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: '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) {
|
||||
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)`,
|
||||
[flyerId, item.name, item.price_display, item.price_in_cents, item.quantity, item.master_item_id]
|
||||
);
|
||||
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)`,
|
||||
[
|
||||
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.`);
|
||||
|
||||
// 8. Seed Watched Items for the user
|
||||
logger.info('--- Seeding Watched Items... ---');
|
||||
const watchedItemIds = [
|
||||
masterItemMap.get('Chicken Breast, Boneless Skinless'),
|
||||
masterItemMap.get('Avocado'),
|
||||
masterItemMap.get('Ground Beef, Lean'),
|
||||
masterItemMap.get('Chicken Breast, Boneless Skinless'),
|
||||
masterItemMap.get('Avocado'),
|
||||
masterItemMap.get('Ground Beef, Lean'),
|
||||
];
|
||||
for (const itemId of watchedItemIds) {
|
||||
if (itemId) {
|
||||
await client.query('INSERT INTO public.user_watched_items (user_id, master_item_id) VALUES ($1, $2)', [userId, itemId]);
|
||||
}
|
||||
if (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.`);
|
||||
|
||||
// 9. Seed 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 shoppingListItems = [
|
||||
{ master_item_id: masterItemMap.get('Milk, 2%'), quantity: 1 },
|
||||
{ master_item_id: masterItemMap.get('Eggs, Large'), quantity: 1 },
|
||||
{ custom_item_name: 'Specialty Hot Sauce', quantity: 1 },
|
||||
{ master_item_id: masterItemMap.get('Milk, 2%'), quantity: 1 },
|
||||
{ master_item_id: masterItemMap.get('Eggs, Large'), quantity: 1 },
|
||||
{ custom_item_name: 'Specialty Hot Sauce', quantity: 1 },
|
||||
];
|
||||
|
||||
for (const item of shoppingListItems) {
|
||||
await client.query(
|
||||
'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]
|
||||
);
|
||||
await client.query(
|
||||
'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],
|
||||
);
|
||||
}
|
||||
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
|
||||
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`;
|
||||
await client.query(brandQuery, brands);
|
||||
logger.info(`Seeded ${brands.length} brands.`);
|
||||
@@ -184,56 +252,91 @@ async function main() {
|
||||
// Link store-specific brands
|
||||
const loblawsId = storeMap.get('Loblaws');
|
||||
if (loblawsId) {
|
||||
await client.query('UPDATE public.brands SET store_id = $1 WHERE name = $2 OR name = $3', [loblawsId, 'No Name', 'President\'s Choice']);
|
||||
logger.info('Linked store brands to Loblaws.');
|
||||
await client.query('UPDATE public.brands SET store_id = $1 WHERE name = $2 OR name = $3', [
|
||||
loblawsId,
|
||||
'No Name',
|
||||
"President's Choice",
|
||||
]);
|
||||
logger.info('Linked store brands to Loblaws.');
|
||||
}
|
||||
|
||||
// 11. Seed Recipes
|
||||
logger.info('--- Seeding 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: '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 },
|
||||
{
|
||||
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: '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) {
|
||||
await client.query(
|
||||
`INSERT INTO public.recipes (name, description, instructions, prep_time_minutes, cook_time_minutes, servings, status)
|
||||
await client.query(
|
||||
`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`,
|
||||
[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.`);
|
||||
|
||||
|
||||
// --- SEED SCRIPT DEBUG LOGGING ---
|
||||
// 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).
|
||||
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:');
|
||||
console.table(allUsersInDb.rows);
|
||||
// --- END DEBUG LOGGING ---
|
||||
|
||||
await client.query('COMMIT');
|
||||
logger.info('✅ Database seeding completed successfully!');
|
||||
|
||||
} catch (error) {
|
||||
// Check if the error is a detailed PostgreSQL error object.
|
||||
if (error && typeof error === 'object' && 'code' in error && 'message' in error) {
|
||||
const dbError = error as { code: string; message: string; detail?: string; table?: string };
|
||||
logger.error({
|
||||
code: dbError.code,
|
||||
message: dbError.message,
|
||||
detail: dbError.detail,
|
||||
table: dbError.table,
|
||||
}, '🔴 A database error occurred during seeding.');
|
||||
const dbError = error as { code: string; message: string; detail?: string; table?: string };
|
||||
logger.error(
|
||||
{
|
||||
code: dbError.code,
|
||||
message: dbError.message,
|
||||
detail: dbError.detail,
|
||||
table: dbError.table,
|
||||
},
|
||||
'🔴 A database error occurred during seeding.',
|
||||
);
|
||||
} else {
|
||||
// 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.');
|
||||
// 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.');
|
||||
}
|
||||
|
||||
if (client) {
|
||||
await client.query('ROLLBACK');
|
||||
logger.warn('Database transaction rolled back.');
|
||||
await client.query('ROLLBACK');
|
||||
logger.warn('Database transaction rolled back.');
|
||||
}
|
||||
|
||||
process.exit(1); // Exit with an error code
|
||||
|
||||
@@ -19,14 +19,19 @@ async function seedAdminUser() {
|
||||
|
||||
try {
|
||||
// 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) {
|
||||
const userId = existingUserRes.rows[0].user_id;
|
||||
console.log(`Admin user '${ADMIN_EMAIL}' already exists with ID: ${userId}.`);
|
||||
|
||||
// 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') {
|
||||
await client.query("UPDATE public.profiles SET role = 'admin' WHERE id = $1", [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.
|
||||
const newUserRes = await client.query(
|
||||
'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;
|
||||
console.log(`Successfully created user with ID: ${newUserId}.`);
|
||||
|
||||
// The trigger creates a profile with the 'user' role. We now update it to 'admin'.
|
||||
await client.query(
|
||||
"UPDATE public.profiles SET role = 'admin' WHERE user_id = $1",
|
||||
[newUserId]
|
||||
);
|
||||
await client.query("UPDATE public.profiles SET role = 'admin' WHERE user_id = $1", [newUserId]);
|
||||
|
||||
console.log(`Successfully set role to 'admin' for user ${newUserId}.`);
|
||||
console.log('Admin user seeding complete!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error during admin user seeding:', error);
|
||||
} finally {
|
||||
@@ -70,4 +71,4 @@ async function seedAdminUser() {
|
||||
}
|
||||
|
||||
// Execute the seeding function
|
||||
seedAdminUser();
|
||||
seedAdminUser();
|
||||
|
||||
@@ -112,4 +112,4 @@ describe('PriceChart', () => {
|
||||
// Milk: $1.13/L (already metric)
|
||||
expect(screen.getByText('$1.13/L')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,11 +18,11 @@ export const PriceChart: React.FC<PriceChartProps> = ({ unitSystem, user }) => {
|
||||
if (!user) {
|
||||
return (
|
||||
<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" />
|
||||
<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">
|
||||
Log in to see active deals for items on your watchlist.
|
||||
</p>
|
||||
<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>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Log in to see active deals for items on your watchlist.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -30,11 +30,16 @@ export const PriceChart: React.FC<PriceChartProps> = ({ unitSystem, user }) => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-4">
|
||||
@@ -44,7 +49,11 @@ export const PriceChart: React.FC<PriceChartProps> = ({ unitSystem, user }) => {
|
||||
}
|
||||
|
||||
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 (
|
||||
@@ -52,41 +61,59 @@ export const PriceChart: React.FC<PriceChartProps> = ({ unitSystem, user }) => {
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800 sticky top-0 z-10">
|
||||
<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">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>
|
||||
<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">
|
||||
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>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{deals.map((deal, index) => {
|
||||
// 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.
|
||||
const unitPriceData = deal.unit_price ? formatUnitPrice(deal.unit_price, unitSystem) : null;
|
||||
const formattedUnitPriceString = unitPriceData
|
||||
? `${unitPriceData.price}${unitPriceData.unit}`
|
||||
const unitPriceData = deal.unit_price
|
||||
? formatUnitPrice(deal.unit_price, unitSystem)
|
||||
: null;
|
||||
const formattedUnitPriceString = unitPriceData
|
||||
? `${unitPriceData.price}${unitPriceData.unit}`
|
||||
: 'N/A';
|
||||
|
||||
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">
|
||||
<div className="flex justify-between items-baseline">
|
||||
<span>{deal.item}</span>
|
||||
{deal.master_item_name && deal.master_item_name.toLowerCase() !== deal.item.toLowerCase() && (
|
||||
<span className="ml-2 text-xs font-normal italic text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
({deal.master_item_name})
|
||||
</span>
|
||||
)}
|
||||
{deal.master_item_name &&
|
||||
deal.master_item_name.toLowerCase() !== deal.item.toLowerCase() && (
|
||||
<span className="ml-2 text-xs font-normal italic text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
({deal.master_item_name})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 font-normal">
|
||||
{deal.quantity}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 font-normal">{deal.quantity}</div>
|
||||
</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-right text-gray-700 dark:text-gray-200">{deal.price_display}</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
{formattedUnitPriceString}
|
||||
<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-right text-gray-700 dark:text-gray-200">
|
||||
{deal.price_display}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">{formattedUnitPriceString}</td>
|
||||
</tr>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -103,4 +130,4 @@ export const PriceChart: React.FC<PriceChartProps> = ({ unitSystem, user }) => {
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,7 +6,10 @@ import { PriceHistoryChart } from './PriceHistoryChart';
|
||||
import { useUserData } from '../../hooks/useUserData';
|
||||
import * as apiClient from '../../services/apiClient';
|
||||
import type { MasterGroceryItem, HistoricalPriceDataPoint } from '../../types';
|
||||
import { createMockMasterGroceryItem, createMockHistoricalPriceDataPoint } from '../../tests/utils/mockFactories';
|
||||
import {
|
||||
createMockMasterGroceryItem,
|
||||
createMockHistoricalPriceDataPoint,
|
||||
} from '../../tests/utils/mockFactories';
|
||||
|
||||
// Mock the apiClient
|
||||
vi.mock('../../services/apiClient');
|
||||
@@ -24,19 +27,24 @@ vi.mock('../../services/logger', () => ({
|
||||
|
||||
// Mock the recharts library to prevent rendering complex SVGs in jsdom
|
||||
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
|
||||
LineChart: ({ children, data }: { children: React.ReactNode; data: any[] }) => (
|
||||
<div data-testid="line-chart" data-chartdata={JSON.stringify(data)}>
|
||||
{children}
|
||||
</div>),
|
||||
</div>
|
||||
),
|
||||
CartesianGrid: () => <div data-testid="cartesian-grid" />,
|
||||
XAxis: () => <div data-testid="x-axis" />,
|
||||
YAxis: () => <div data-testid="y-axis" />,
|
||||
Tooltip: () => <div data-testid="tooltip" />,
|
||||
Legend: () => <div data-testid="legend" />,
|
||||
// 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[] = [
|
||||
@@ -45,10 +53,26 @@ const mockWatchedItems: MasterGroceryItem[] = [
|
||||
];
|
||||
|
||||
const mockPriceHistory: HistoricalPriceDataPoint[] = [
|
||||
createMockHistoricalPriceDataPoint({ master_item_id: 1, 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 }),
|
||||
createMockHistoricalPriceDataPoint({ master_item_id: 2, summary_date: '2024-10-08', avg_price_in_cents: 349 }),
|
||||
createMockHistoricalPriceDataPoint({
|
||||
master_item_id: 1,
|
||||
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,
|
||||
}),
|
||||
createMockHistoricalPriceDataPoint({
|
||||
master_item_id: 2,
|
||||
summary_date: '2024-10-08',
|
||||
avg_price_in_cents: 349,
|
||||
}),
|
||||
];
|
||||
|
||||
describe('PriceHistoryChart', () => {
|
||||
@@ -75,7 +99,9 @@ describe('PriceHistoryChart', () => {
|
||||
error: null,
|
||||
});
|
||||
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', () => {
|
||||
@@ -95,16 +121,24 @@ describe('PriceHistoryChart', () => {
|
||||
});
|
||||
|
||||
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 />);
|
||||
|
||||
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 () => {
|
||||
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(new Response(JSON.stringify(mockPriceHistory)));
|
||||
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(
|
||||
new Response(JSON.stringify(mockPriceHistory)),
|
||||
);
|
||||
render(<PriceHistoryChart />);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -139,7 +173,9 @@ describe('PriceHistoryChart', () => {
|
||||
});
|
||||
|
||||
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 />);
|
||||
|
||||
// Initial render with items
|
||||
@@ -160,18 +196,34 @@ describe('PriceHistoryChart', () => {
|
||||
|
||||
// Chart should be gone, placeholder should appear
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter out items with only one data point', async () => {
|
||||
const dataWithSinglePoint: HistoricalPriceDataPoint[] = [
|
||||
createMockHistoricalPriceDataPoint({ master_item_id: 1, 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
|
||||
createMockHistoricalPriceDataPoint({
|
||||
master_item_id: 1,
|
||||
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 />);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -182,37 +234,65 @@ describe('PriceHistoryChart', () => {
|
||||
|
||||
it('should process data to only keep the lowest price for a given day', async () => {
|
||||
const dataWithDuplicateDate: HistoricalPriceDataPoint[] = [
|
||||
createMockHistoricalPriceDataPoint({ master_item_id: 1, 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 }),
|
||||
createMockHistoricalPriceDataPoint({
|
||||
master_item_id: 1,
|
||||
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 />);
|
||||
|
||||
await waitFor(() => {
|
||||
const chart = screen.getByTestId('line-chart');
|
||||
const chartData = JSON.parse(chart.getAttribute('data-chartdata')!);
|
||||
|
||||
|
||||
// The date gets formatted to 'Oct 1'
|
||||
const dataPointForOct1 = chartData.find((d: any) => d.date === 'Oct 1');
|
||||
|
||||
|
||||
expect(dataPointForOct1['Organic Bananas']).toBe(105);
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter out data points with a price of zero', async () => {
|
||||
const dataWithZeroPrice: HistoricalPriceDataPoint[] = [
|
||||
createMockHistoricalPriceDataPoint({ master_item_id: 1, 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 }),
|
||||
createMockHistoricalPriceDataPoint({
|
||||
master_item_id: 1,
|
||||
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 />);
|
||||
|
||||
await waitFor(() => {
|
||||
const chart = screen.getByTestId('line-chart');
|
||||
const chartData = JSON.parse(chart.getAttribute('data-chartdata')!);
|
||||
|
||||
|
||||
// The date 'Oct 8' should not be in the chart data at all
|
||||
const dataPointForOct8 = chartData.find((d: any) => d.date === 'Oct 8');
|
||||
expect(dataPointForOct8).toBeUndefined();
|
||||
@@ -221,4 +301,4 @@ describe('PriceHistoryChart', () => {
|
||||
expect(chartData).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
// src/components/PriceHistoryChart.tsx
|
||||
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 { LoadingSpinner } from '../../components/LoadingSpinner'; // This path is correct
|
||||
import { useUserData } from '../../hooks/useUserData';
|
||||
@@ -17,62 +26,83 @@ export const PriceHistoryChart: React.FC = () => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
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(() => {
|
||||
if (watchedItems.length === 0) {
|
||||
setIsLoading(false);
|
||||
setHistoricalData({}); // Clear data if watchlist becomes empty
|
||||
return;
|
||||
setIsLoading(false);
|
||||
setHistoricalData({}); // Clear data if watchlist becomes empty
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
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 rawData: HistoricalPriceDataPoint[] = await response.json();
|
||||
if (rawData.length === 0) {
|
||||
setHistoricalData({});
|
||||
return;
|
||||
setHistoricalData({});
|
||||
return;
|
||||
}
|
||||
|
||||
const processedData = rawData.reduce<HistoricalData>((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);
|
||||
if (!itemName) return acc;
|
||||
const processedData = rawData.reduce<HistoricalData>(
|
||||
(acc, record: HistoricalPriceDataPoint) => {
|
||||
if (
|
||||
!record.master_item_id ||
|
||||
record.avg_price_in_cents === null ||
|
||||
!record.summary_date
|
||||
)
|
||||
return acc;
|
||||
|
||||
const priceInCents = record.avg_price_in_cents;
|
||||
const date = new Date(`${record.summary_date}T00:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
|
||||
if(priceInCents === 0) return acc;
|
||||
const itemName = watchedItemsMap.get(record.master_item_id);
|
||||
if (!itemName) return acc;
|
||||
|
||||
if (!acc[itemName]) {
|
||||
acc[itemName] = [];
|
||||
}
|
||||
const priceInCents = record.avg_price_in_cents;
|
||||
const date = new Date(`${record.summary_date}T00:00:00`).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
// 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;
|
||||
if (priceInCents === 0) return acc;
|
||||
|
||||
if (!acc[itemName]) {
|
||||
acc[itemName] = [];
|
||||
}
|
||||
} 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
|
||||
const filteredData = Object.entries(processedData).reduce<HistoricalData>((acc, [key, value]) => {
|
||||
if(value.length > 1){
|
||||
acc[key] = value.sort((a,b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
const filteredData = Object.entries(processedData).reduce<HistoricalData>(
|
||||
(acc, [key, value]) => {
|
||||
if (value.length > 1) {
|
||||
acc[key] = value.sort(
|
||||
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
|
||||
);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
setHistoricalData(filteredData);
|
||||
} catch (e) {
|
||||
@@ -89,10 +119,10 @@ export const PriceHistoryChart: React.FC = () => {
|
||||
const chartData = useMemo<ChartData[]>(() => {
|
||||
const availableItems = Object.keys(historicalData);
|
||||
if (availableItems.length === 0) return [];
|
||||
|
||||
|
||||
const dateMap: Map<string, ChartData> = new Map();
|
||||
|
||||
availableItems.forEach(itemName => {
|
||||
|
||||
availableItems.forEach((itemName) => {
|
||||
historicalData[itemName]?.forEach(({ date, price }) => {
|
||||
if (!dateMap.has(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]);
|
||||
|
||||
|
||||
const availableItems = Object.keys(historicalData);
|
||||
|
||||
const renderContent = () => {
|
||||
if (isLoading || isLoadingUserData) {
|
||||
return (
|
||||
<div role="status" className="flex justify-center items-center h-full min-h-[200px]">
|
||||
<LoadingSpinner /> <span className="ml-2">Loading Price History...</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div role="status" className="flex justify-center items-center h-full min-h-[200px]">
|
||||
<LoadingSpinner /> <span className="ml-2">Loading Price History...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (error) {
|
||||
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">
|
||||
<p><strong>Error:</strong> {error}</p>
|
||||
</div>
|
||||
);
|
||||
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"
|
||||
>
|
||||
<p>
|
||||
<strong>Error:</strong> {error}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (watchedItems.length === 0) {
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (availableItems.length === 0) {
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer>
|
||||
<LineChart
|
||||
data={chartData}
|
||||
margin={{ top: 5, right: 20, left: -10, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(128, 128, 128, 0.2)" />
|
||||
<XAxis dataKey="date" tick={{ fill: '#9CA3AF', fontSize: 12 }} />
|
||||
<YAxis
|
||||
tick={{ fill: '#9CA3AF', fontSize: 12 }}
|
||||
tickFormatter={(value: number) => `$${(value / 100).toFixed(2)}`}
|
||||
domain={[(a: number) => Math.floor(a * 0.95), (b: number) => Math.ceil(b * 1.05)]}
|
||||
<ResponsiveContainer>
|
||||
<LineChart data={chartData} margin={{ top: 5, right: 20, left: -10, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(128, 128, 128, 0.2)" />
|
||||
<XAxis dataKey="date" tick={{ fill: '#9CA3AF', fontSize: 12 }} />
|
||||
<YAxis
|
||||
tick={{ fill: '#9CA3AF', fontSize: 12 }}
|
||||
tickFormatter={(value: number) => `$${(value / 100).toFixed(2)}`}
|
||||
domain={[(a: number) => Math.floor(a * 0.95), (b: number) => Math.ceil(b * 1.05)]}
|
||||
/>
|
||||
<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
|
||||
/>
|
||||
<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>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
<div style={{ width: '100%', height: 300 }}>
|
||||
{renderContent()}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-800 dark:text-white">
|
||||
Historical Price Trends
|
||||
</h3>
|
||||
<div style={{ width: '100%', height: 300 }}>{renderContent()}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user