Add Open Food Facts barcode lookup workflow
This commit is contained in:
Generated
+2893
-1
File diff suppressed because it is too large
Load Diff
+9
-2
@@ -7,7 +7,8 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && cp src/pwa/manifest.json public/manifest.json && cp src/pwa/service-worker.js public/service-worker.js && vite build",
|
"build": "tsc && cp src/pwa/manifest.json public/manifest.json && cp src/pwa/service-worker.js public/service-worker.js && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint ."
|
"lint": "eslint .",
|
||||||
|
"test": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.13.0",
|
"@emotion/react": "^11.13.0",
|
||||||
@@ -29,11 +30,17 @@
|
|||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||||
"@typescript-eslint/parser": "^7.18.0",
|
"@typescript-eslint/parser": "^7.18.0",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.1.0",
|
||||||
|
"@testing-library/user-event": "^14.5.2",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^8.57.1",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0",
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.7",
|
"eslint-plugin-react-refresh": "^0.4.7",
|
||||||
|
"jsdom": "^24.1.0",
|
||||||
|
"msw": "^2.6.5",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.5.4",
|
||||||
"vite": "^7.2.4"
|
"vite": "^7.2.4",
|
||||||
|
"vitest": "^2.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { setupServer } from 'msw/node';
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { fetchProductFromOFF } from './openFoodFacts';
|
||||||
|
|
||||||
|
const server = setupServer();
|
||||||
|
|
||||||
|
beforeAll(() => server.listen());
|
||||||
|
afterEach(() => server.resetHandlers());
|
||||||
|
afterAll(() => server.close());
|
||||||
|
|
||||||
|
describe('fetchProductFromOFF', () => {
|
||||||
|
const endpoint = 'https://world.openfoodfacts.org/api/v2/product/123456.json';
|
||||||
|
|
||||||
|
it('returns parsed product data when available', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(endpoint, () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
status: 1,
|
||||||
|
product: {
|
||||||
|
product_name: 'Sparkling Water',
|
||||||
|
brands: 'Acme',
|
||||||
|
quantity: '500ml',
|
||||||
|
image_url: 'https://example.com/water.png',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await fetchProductFromOFF('123456');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
name: 'Sparkling Water',
|
||||||
|
brand: 'Acme',
|
||||||
|
quantity: '500ml',
|
||||||
|
image: 'https://example.com/water.png',
|
||||||
|
source: 'openfoodfacts',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coerces missing optional fields to null', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(endpoint, () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
status: 1,
|
||||||
|
product: {
|
||||||
|
product_name: 'Chocolate Bar',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await fetchProductFromOFF('123456');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
name: 'Chocolate Bar',
|
||||||
|
brand: null,
|
||||||
|
quantity: null,
|
||||||
|
image: null,
|
||||||
|
source: 'openfoodfacts',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the product is not found', async () => {
|
||||||
|
server.use(http.get(endpoint, () => HttpResponse.json({ status: 0 })));
|
||||||
|
|
||||||
|
const result = await fetchProductFromOFF('123456');
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null on network error', async () => {
|
||||||
|
server.use(http.get(endpoint, () => HttpResponse.error()));
|
||||||
|
|
||||||
|
const result = await fetchProductFromOFF('123456');
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
export interface ExternalProductInfo {
|
||||||
|
name: string | null;
|
||||||
|
brand?: string | null;
|
||||||
|
quantity?: string | null;
|
||||||
|
image?: string | null;
|
||||||
|
source: 'openfoodfacts';
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_URL = 'https://world.openfoodfacts.org/api/v2/product';
|
||||||
|
const REQUEST_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
const isOffline = () => typeof navigator !== 'undefined' && 'onLine' in navigator && !navigator.onLine;
|
||||||
|
|
||||||
|
export const fetchProductFromOFF = async (barcode: string): Promise<ExternalProductInfo | null> => {
|
||||||
|
if (!barcode || isOffline()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}/${barcode}.json`, { signal: controller.signal });
|
||||||
|
if (!response.ok) return null;
|
||||||
|
|
||||||
|
const data = (await response.json()) as {
|
||||||
|
status?: number;
|
||||||
|
product?: {
|
||||||
|
product_name?: string | null;
|
||||||
|
brands?: string | null;
|
||||||
|
quantity?: string | null;
|
||||||
|
image_url?: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data?.status !== 1 || !data.product || !data.product.product_name) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: data.product.product_name ?? null,
|
||||||
|
brand: data.product.brands ?? null,
|
||||||
|
quantity: data.product.quantity ?? null,
|
||||||
|
image: data.product.image_url ?? null,
|
||||||
|
source: 'openfoodfacts',
|
||||||
|
} satisfies ExternalProductInfo;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@@ -22,6 +22,9 @@ export const HomeScreen = () => (
|
|||||||
<Button component={RouterLink} to="/scan" variant="outlined">
|
<Button component={RouterLink} to="/scan" variant="outlined">
|
||||||
Scan Barcode
|
Scan Barcode
|
||||||
</Button>
|
</Button>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
||||||
|
Product data provided by Open Food Facts (openfoodfacts.org)
|
||||||
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { setupServer } from 'msw/node';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ManageProductsScreen } from './ManageProductsScreen';
|
||||||
|
|
||||||
|
vi.mock('../hooks/dataHooks', () => ({
|
||||||
|
useProducts: () => [],
|
||||||
|
useCategories: () => [{ id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../context/DBProvider', () => ({
|
||||||
|
useDatabase: () => ({
|
||||||
|
products: {
|
||||||
|
add: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/BarcodeScannerView', () => ({
|
||||||
|
BarcodeScannerView: ({ onDetected }: { onDetected?: (code: string) => void }) => (
|
||||||
|
<button type="button" onClick={() => onDetected?.('123456')}>
|
||||||
|
Mock Scan
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const server = setupServer();
|
||||||
|
|
||||||
|
describe('ManageProductsScreen barcode lookup', () => {
|
||||||
|
beforeAll(() => server.listen());
|
||||||
|
afterEach(() => server.resetHandlers());
|
||||||
|
afterAll(() => server.close());
|
||||||
|
|
||||||
|
it('prefills the product name after scanning a barcode', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('https://world.openfoodfacts.org/api/v2/product/123456.json', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
status: 1,
|
||||||
|
product: {
|
||||||
|
product_name: 'OFF Test Product',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ManageProductsScreen />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /scan barcode/i }));
|
||||||
|
await user.click(screen.getByRole('button', { name: /mock scan/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/name/i)).toHaveValue('OFF Test Product');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -12,13 +12,14 @@ import {
|
|||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
import { Link as RouterLink, useLocation } from 'react-router-dom';
|
import { Link as RouterLink, useLocation } from 'react-router-dom';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { ProductRow } from '../components/ProductRow';
|
import { ProductRow } from '../components/ProductRow';
|
||||||
import { useCategories, useProducts } from '../hooks/dataHooks';
|
import { useCategories, useProducts } from '../hooks/dataHooks';
|
||||||
import { useDatabase } from '../context/DBProvider';
|
import { useDatabase } from '../context/DBProvider';
|
||||||
import { DEFAULT_BULK_NAME, DEFAULT_UNIT_TYPE } from '../models/Product';
|
import { DEFAULT_BULK_NAME, DEFAULT_UNIT_TYPE } from '../models/Product';
|
||||||
import { BarcodeScannerView } from '../components/BarcodeScannerView';
|
import { BarcodeScannerView } from '../components/BarcodeScannerView';
|
||||||
|
import { ExternalProductInfo, fetchProductFromOFF } from '../modules/openFoodFacts';
|
||||||
|
|
||||||
export const ManageProductsScreen = () => {
|
export const ManageProductsScreen = () => {
|
||||||
const db = useDatabase();
|
const db = useDatabase();
|
||||||
@@ -30,6 +31,34 @@ export const ManageProductsScreen = () => {
|
|||||||
const [category, setCategory] = useState('');
|
const [category, setCategory] = useState('');
|
||||||
const [barcode, setBarcode] = useState('');
|
const [barcode, setBarcode] = useState('');
|
||||||
const [scannerOpen, setScannerOpen] = useState(false);
|
const [scannerOpen, setScannerOpen] = useState(false);
|
||||||
|
const [lookupStatus, setLookupStatus] = useState<'idle' | 'loading' | 'found' | 'notfound' | 'offline'>(
|
||||||
|
'idle',
|
||||||
|
);
|
||||||
|
const [externalProduct, setExternalProduct] = useState<ExternalProductInfo | null>(null);
|
||||||
|
|
||||||
|
const lookupBarcode = useCallback(async (code: string) => {
|
||||||
|
if (!code) return;
|
||||||
|
|
||||||
|
if (typeof navigator !== 'undefined' && 'onLine' in navigator && !navigator.onLine) {
|
||||||
|
setLookupStatus('offline');
|
||||||
|
setExternalProduct(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLookupStatus('loading');
|
||||||
|
const result = await fetchProductFromOFF(code);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
setExternalProduct(result);
|
||||||
|
setLookupStatus('found');
|
||||||
|
if (result.name) {
|
||||||
|
setName((prev) => prev || result.name || '');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setExternalProduct(null);
|
||||||
|
setLookupStatus('notfound');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const categoryOptions = useMemo(() => {
|
const categoryOptions = useMemo(() => {
|
||||||
const categoryNames = categories.map((item) => item.name);
|
const categoryNames = categories.map((item) => item.name);
|
||||||
@@ -56,8 +85,16 @@ export const ManageProductsScreen = () => {
|
|||||||
const state = location.state as { newBarcode?: string } | null;
|
const state = location.state as { newBarcode?: string } | null;
|
||||||
if (state?.newBarcode) {
|
if (state?.newBarcode) {
|
||||||
setBarcode(state.newBarcode);
|
setBarcode(state.newBarcode);
|
||||||
|
void lookupBarcode(state.newBarcode);
|
||||||
}
|
}
|
||||||
}, [location.state]);
|
}, [location.state, lookupBarcode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!barcode) {
|
||||||
|
setLookupStatus('idle');
|
||||||
|
setExternalProduct(null);
|
||||||
|
}
|
||||||
|
}, [barcode]);
|
||||||
|
|
||||||
const addProduct = async () => {
|
const addProduct = async () => {
|
||||||
if (!name || !category) return;
|
if (!name || !category) return;
|
||||||
@@ -128,6 +165,7 @@ export const ManageProductsScreen = () => {
|
|||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
{barcode ? (
|
{barcode ? (
|
||||||
|
<Stack spacing={1}>
|
||||||
<TextField
|
<TextField
|
||||||
label="Barcode"
|
label="Barcode"
|
||||||
value={barcode}
|
value={barcode}
|
||||||
@@ -140,6 +178,23 @@ export const ManageProductsScreen = () => {
|
|||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{lookupStatus === 'loading' ? <Typography variant="body2">Looking up product…</Typography> : null}
|
||||||
|
{lookupStatus === 'found' && externalProduct ? (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Found {externalProduct.name ?? 'product'} via Open Food Facts. Please confirm details.
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
{lookupStatus === 'notfound' ? (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Product not found. Add it manually.
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
{lookupStatus === 'offline' ? (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
You are offline. Enter details manually.
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="outlined" onClick={() => setScannerOpen(true)}>
|
<Button variant="outlined" onClick={() => setScannerOpen(true)}>
|
||||||
Scan Barcode
|
Scan Barcode
|
||||||
@@ -165,6 +220,7 @@ export const ManageProductsScreen = () => {
|
|||||||
<BarcodeScannerView
|
<BarcodeScannerView
|
||||||
onDetected={(code) => {
|
onDetected={(code) => {
|
||||||
setBarcode(code);
|
setBarcode(code);
|
||||||
|
void lookupBarcode(code);
|
||||||
setScannerOpen(false);
|
setScannerOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
import { cleanup } from '@testing-library/react';
|
||||||
|
import { afterEach } from 'vitest';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"types": ["vite/client"]
|
"types": ["vite/client", "vitest/globals"]
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
|||||||
@@ -9,4 +9,8 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
},
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: './src/test/setup.ts',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user