Use Open Food Facts for barcode lookup
This commit is contained in:
@@ -22,6 +22,7 @@ Die App richtet sich an Privatpersonen, die im Supermarkt schnell bessere Kaufen
|
|||||||
- Manuelle Produktsuche, falls der Barcode nicht erkannt wird
|
- Manuelle Produktsuche, falls der Barcode nicht erkannt wird
|
||||||
- Lokale Einkaufsliste mit Menge, Preis, Abhaken und Löschen
|
- Lokale Einkaufsliste mit Menge, Preis, Abhaken und Löschen
|
||||||
- Lokaler Produktcache und Offline-Modus für gespeicherte Produkte
|
- Lokaler Produktcache und Offline-Modus für gespeicherte Produkte
|
||||||
|
- Produktdaten per Barcode aus der Open-Food-Facts-API
|
||||||
- Später geplant: erweiterte Profile, Budgettracking, Nachhaltigkeitsfilter und Rezeptvorschläge
|
- Später geplant: erweiterte Profile, Budgettracking, Nachhaltigkeitsfilter und Rezeptvorschläge
|
||||||
|
|
||||||
## One-Pager
|
## One-Pager
|
||||||
@@ -40,6 +41,7 @@ Der One-Pager aus Aufgabe 6.1 beschreibt das Go-to-Market-Konzept:
|
|||||||
- React Native
|
- React Native
|
||||||
- JavaScript
|
- JavaScript
|
||||||
- Smartphone-Kamera und Barcode-Scanner
|
- Smartphone-Kamera und Barcode-Scanner
|
||||||
|
- Open Food Facts API für Barcode-Produktdaten
|
||||||
- Lokale Speicherung persönlicher Präferenzen
|
- Lokale Speicherung persönlicher Präferenzen
|
||||||
- GitHub oder GitLab für Versionsverwaltung und Abgabe
|
- GitHub oder GitLab für Versionsverwaltung und Abgabe
|
||||||
|
|
||||||
@@ -66,21 +68,21 @@ Shoppy/
|
|||||||
Die App enthält jetzt einen MVP-Flow mit Bottom-Tabs:
|
Die App enthält jetzt einen MVP-Flow mit Bottom-Tabs:
|
||||||
|
|
||||||
- Home: Startseite und letzte Scans
|
- Home: Startseite und letzte Scans
|
||||||
- Scan: Kamera-Scanner, Beispiel-Barcodes und manuelle Suche
|
- Scan: Kamera-Scanner mit Open Food Facts, Beispiel-Barcodes und manuelle Suche
|
||||||
- Produktdetail: Ampelbewertung nach Allergien, Budget, Ernährung und Nachhaltigkeit
|
- Produktdetail: Ampelbewertung nach Allergien, Budget, Ernährung und Nachhaltigkeit
|
||||||
- Alternativen: bessere Produktvorschläge aus dem lokalen Beispieldatensatz
|
- Alternativen: bessere Produktvorschläge aus dem lokalen Beispieldatensatz
|
||||||
- Einkaufsliste: Produkte hinzufügen, abhaken, löschen und Gesamtsumme sehen
|
- Einkaufsliste: Produkte hinzufügen, abhaken, löschen und Gesamtsumme sehen
|
||||||
- Profil: Präferenzen, Allergien, Budget, Bio-Vorliebe und Offline-Modus verwalten
|
- Profil: Präferenzen, Allergien, Budget, Bio-Vorliebe und Offline-Modus verwalten
|
||||||
|
|
||||||
Beispiel-Barcodes für Tests ohne echte Produkte:
|
Beispiel-Barcodes für Tests über Open Food Facts:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
7610200336707 - Bio Haferdrink
|
3017620422003 - Nutella
|
||||||
7612345001112 - Erdnuss Schoko-Riegel
|
737628064502 - Open-Food-Facts-Beispielprodukt
|
||||||
7612345002225 - Protein Joghurt Beeren
|
|
||||||
7612345003338 - Vegane Linsen-Bowl
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Die App nutzt für Barcodes `https://world.openfoodfacts.org/api/v2/product/{barcode}.json`. Wenn ein Produkt bereits lokal gespeichert ist oder der Offline-Modus aktiv ist, wird zuerst der lokale Cache verwendet.
|
||||||
|
|
||||||
## Runbook
|
## Runbook
|
||||||
|
|
||||||
Projekt installieren:
|
Projekt installieren:
|
||||||
|
|||||||
@@ -80,11 +80,11 @@ export default function ScanScreen({ preferences, productCache, onScanBarcode, o
|
|||||||
<View style={styles.permissionBox}>
|
<View style={styles.permissionBox}>
|
||||||
<Text style={styles.permissionTitle}>Scanner-Simulation</Text>
|
<Text style={styles.permissionTitle}>Scanner-Simulation</Text>
|
||||||
<Text style={styles.permissionCopy}>
|
<Text style={styles.permissionCopy}>
|
||||||
Auf Web oder ohne Kamera kannst du mit Beispiel-Barcodes testen.
|
Auf Web oder ohne Kamera kannst du Open-Food-Facts-Barcodes testen.
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.demoRow}>
|
<View style={styles.demoRow}>
|
||||||
<SecondaryButton label="Bio Haferdrink" onPress={() => handleBarcodeScanned({ data: '7610200336707' })} />
|
<SecondaryButton label="Nutella" onPress={() => handleBarcodeScanned({ data: '3017620422003' })} />
|
||||||
<SecondaryButton label="Schoko-Riegel" onPress={() => handleBarcodeScanned({ data: '7612345001112' })} />
|
<SecondaryButton label="OpenFoodFacts Beispiel" onPress={() => handleBarcodeScanned({ data: '737628064502' })} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+202
-14
@@ -1,13 +1,33 @@
|
|||||||
import { mockProducts } from '../data/mockProducts';
|
import { mockProducts } from '../data/mockProducts';
|
||||||
|
|
||||||
function delay(ms = 250) {
|
const openFoodFactsBaseUrl = 'https://world.openfoodfacts.org';
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
const productFields = [
|
||||||
}
|
'code',
|
||||||
|
'product_name',
|
||||||
|
'product_name_de',
|
||||||
|
'product_name_en',
|
||||||
|
'brands',
|
||||||
|
'categories',
|
||||||
|
'categories_tags',
|
||||||
|
'image_url',
|
||||||
|
'ingredients_text',
|
||||||
|
'ingredients_text_de',
|
||||||
|
'ingredients_text_en',
|
||||||
|
'allergens_tags',
|
||||||
|
'nutriments',
|
||||||
|
'nutriscore_grade',
|
||||||
|
'ecoscore_grade',
|
||||||
|
'labels_tags',
|
||||||
|
'origins',
|
||||||
|
'origins_tags',
|
||||||
|
'countries_tags',
|
||||||
|
'packaging'
|
||||||
|
].join(',');
|
||||||
|
|
||||||
export async function lookupProductByBarcode(barcode, productCache = {}, offlineModeEnabled = false) {
|
export async function lookupProductByBarcode(barcode, productCache = {}, offlineModeEnabled = false) {
|
||||||
await delay();
|
const normalizedBarcode = normalizeBarcode(barcode);
|
||||||
|
const cachedProduct = productCache[normalizedBarcode] || productCache[barcode];
|
||||||
|
|
||||||
const cachedProduct = productCache[barcode];
|
|
||||||
if (cachedProduct) {
|
if (cachedProduct) {
|
||||||
return { status: 'found', product: cachedProduct, source: 'cache' };
|
return { status: 'found', product: cachedProduct, source: 'cache' };
|
||||||
}
|
}
|
||||||
@@ -19,27 +39,113 @@ export async function lookupProductByBarcode(barcode, productCache = {}, offline
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const product = mockProducts.find((item) => item.barcode === barcode);
|
try {
|
||||||
if (!product) {
|
const response = await fetch(
|
||||||
return { status: 'notFound', errorMessage: 'Kein Produkt zu diesem Barcode gefunden.' };
|
`${openFoodFactsBaseUrl}/api/v2/product/${encodeURIComponent(normalizedBarcode)}.json?fields=${productFields}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
errorMessage: 'Open Food Facts konnte gerade nicht erreicht werden.'
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { status: 'found', product, source: 'mock-api' };
|
const payload = await response.json();
|
||||||
|
if (payload.status !== 1 || !payload.product) {
|
||||||
|
return {
|
||||||
|
status: 'notFound',
|
||||||
|
errorMessage: 'Kein Produkt zu diesem Barcode in Open Food Facts gefunden.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'found',
|
||||||
|
product: mapOpenFoodFactsProduct(payload.product, normalizedBarcode),
|
||||||
|
source: 'open-food-facts'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
errorMessage: 'Netzwerkfehler beim Laden von Open Food Facts.'
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchProducts(query, productCache = {}, offlineModeEnabled = false) {
|
export async function searchProducts(query, productCache = {}, offlineModeEnabled = false) {
|
||||||
await delay();
|
|
||||||
|
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
const cachedProducts = Object.values(productCache);
|
const cachedProducts = Object.values(productCache);
|
||||||
const sourceProducts = offlineModeEnabled ? cachedProducts : [...mockProducts, ...cachedProducts];
|
|
||||||
const uniqueProducts = Array.from(new Map(sourceProducts.map((product) => [product.id, product])).values());
|
|
||||||
|
|
||||||
if (!normalizedQuery) {
|
if (!normalizedQuery) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return uniqueProducts.filter((product) => {
|
if (offlineModeEnabled) {
|
||||||
|
return filterProducts(cachedProducts, normalizedQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${openFoodFactsBaseUrl}/cgi/search.pl?search_terms=${encodeURIComponent(query)}&search_simple=1&action=process&json=1&page_size=20&fields=${productFields}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return filterProducts([...cachedProducts, ...mockProducts], normalizedQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
const apiProducts = (payload.products || [])
|
||||||
|
.filter((product) => product.code)
|
||||||
|
.map((product) => mapOpenFoodFactsProduct(product, product.code));
|
||||||
|
const products = [...apiProducts, ...cachedProducts, ...mockProducts];
|
||||||
|
|
||||||
|
return uniqueProducts(filterProducts(products, normalizedQuery));
|
||||||
|
} catch (error) {
|
||||||
|
return filterProducts([...cachedProducts, ...mockProducts], normalizedQuery);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapOpenFoodFactsProduct(product, fallbackBarcode) {
|
||||||
|
const barcode = normalizeBarcode(product.code || fallbackBarcode);
|
||||||
|
const nutriments = product.nutriments || {};
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `off-${barcode}`,
|
||||||
|
barcode,
|
||||||
|
name: product.product_name_de || product.product_name || product.product_name_en || `Produkt ${barcode}`,
|
||||||
|
brand: firstTextValue(product.brands),
|
||||||
|
category: firstCategory(product.categories, product.categories_tags),
|
||||||
|
imageUrl: product.image_url,
|
||||||
|
price: undefined,
|
||||||
|
currency: 'CHF',
|
||||||
|
ingredients: parseIngredients(product.ingredients_text_de || product.ingredients_text || product.ingredients_text_en),
|
||||||
|
allergens: normalizeAllergens(product.allergens_tags || []),
|
||||||
|
nutrition: {
|
||||||
|
caloriesPer100g: numberOrUndefined(nutriments['energy-kcal_100g']),
|
||||||
|
sugarPer100g: numberOrUndefined(nutriments.sugars_100g),
|
||||||
|
fatPer100g: numberOrUndefined(nutriments.fat_100g),
|
||||||
|
proteinPer100g: numberOrUndefined(nutriments.proteins_100g),
|
||||||
|
saltPer100g: numberOrUndefined(nutriments.salt_100g),
|
||||||
|
nutriScore: normalizeGrade(product.nutriscore_grade)
|
||||||
|
},
|
||||||
|
sustainability: {
|
||||||
|
ecoScore: normalizeGrade(product.ecoscore_grade),
|
||||||
|
isBio: hasOrganicLabel(product.labels_tags || []),
|
||||||
|
originCountry: firstOrigin(product.origins, product.origins_tags, product.countries_tags),
|
||||||
|
packaging: firstTextValue(product.packaging)
|
||||||
|
},
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBarcode(barcode) {
|
||||||
|
return String(barcode || '').replace(/[^\d]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterProducts(products, normalizedQuery) {
|
||||||
|
return uniqueProducts(products).filter((product) => {
|
||||||
const haystack = [
|
const haystack = [
|
||||||
product.name,
|
product.name,
|
||||||
product.brand,
|
product.brand,
|
||||||
@@ -50,3 +156,85 @@ export async function searchProducts(query, productCache = {}, offlineModeEnable
|
|||||||
return haystack.includes(normalizedQuery);
|
return haystack.includes(normalizedQuery);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function uniqueProducts(products) {
|
||||||
|
return Array.from(new Map(products.map((product) => [product.id, product])).values());
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstTextValue(value) {
|
||||||
|
if (!value) return undefined;
|
||||||
|
return String(value).split(',').map((item) => item.trim()).filter(Boolean)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstCategory(categories, categoryTags = []) {
|
||||||
|
const category = firstTextValue(categories);
|
||||||
|
if (category) return category;
|
||||||
|
|
||||||
|
const tag = categoryTags.find(Boolean);
|
||||||
|
return tag ? cleanTag(tag) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstOrigin(origins, originTags = [], countryTags = []) {
|
||||||
|
return firstTextValue(origins)
|
||||||
|
|| cleanTag(originTags.find(Boolean))
|
||||||
|
|| cleanTag(countryTags.find(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIngredients(ingredientsText) {
|
||||||
|
if (!ingredientsText) return [];
|
||||||
|
return ingredientsText
|
||||||
|
.split(/[,;.]/)
|
||||||
|
.map((ingredient) => ingredient.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAllergens(allergenTags) {
|
||||||
|
const allergenMap = {
|
||||||
|
'en:peanuts': 'Erdnüsse',
|
||||||
|
'de:erdnusse': 'Erdnüsse',
|
||||||
|
'de:erdnüsse': 'Erdnüsse',
|
||||||
|
'en:milk': 'Milch',
|
||||||
|
'de:milch': 'Milch',
|
||||||
|
'en:gluten': 'Gluten',
|
||||||
|
'de:gluten': 'Gluten',
|
||||||
|
'en:oats': 'Hafer',
|
||||||
|
'de:hafer': 'Hafer',
|
||||||
|
'en:soybeans': 'Soja',
|
||||||
|
'de:soja': 'Soja',
|
||||||
|
'en:nuts': 'Nüsse',
|
||||||
|
'de:nusse': 'Nüsse',
|
||||||
|
'de:nüsse': 'Nüsse',
|
||||||
|
'en:eggs': 'Eier',
|
||||||
|
'de:eier': 'Eier'
|
||||||
|
};
|
||||||
|
|
||||||
|
return allergenTags
|
||||||
|
.map((tag) => allergenMap[tag] || cleanTag(tag))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGrade(value) {
|
||||||
|
const grade = String(value || '').trim().toUpperCase();
|
||||||
|
return ['A', 'B', 'C', 'D', 'E'].includes(grade) ? grade : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasOrganicLabel(labelTags) {
|
||||||
|
return labelTags.some((tag) => {
|
||||||
|
const normalizedTag = String(tag).toLowerCase();
|
||||||
|
return normalizedTag.includes('organic') || normalizedTag.includes('bio');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberOrUndefined(value) {
|
||||||
|
const numberValue = Number(value);
|
||||||
|
return Number.isFinite(numberValue) ? numberValue : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanTag(tag) {
|
||||||
|
if (!tag) return undefined;
|
||||||
|
const withoutLanguage = String(tag).replace(/^[a-z]{2}:/, '');
|
||||||
|
return withoutLanguage
|
||||||
|
.replace(/-/g, ' ')
|
||||||
|
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user