Add Open Food Facts barcode lookup workflow

This commit is contained in:
beatz174-bit
2025-11-21 18:44:38 +10:00
parent 7f24948317
commit cf9a4f010d
10 changed files with 3187 additions and 18 deletions
+2893 -1
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -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"
} }
} }
+80
View File
@@ -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();
});
});
+53
View File
@@ -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);
}
};
+3
View File
@@ -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>
); );
+66
View File
@@ -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');
});
});
});
+58 -2
View File
@@ -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);
}} }}
/> />
+8
View File
@@ -0,0 +1,8 @@
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup();
});
+1 -1
View File
@@ -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" }]
+4
View File
@@ -9,4 +9,8 @@ export default defineConfig({
build: { build: {
outDir: 'dist', outDir: 'dist',
}, },
test: {
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
},
}); });