This commit is contained in:
Darius
2025-11-21 00:58:30 +01:00
commit c42fc96899
12 changed files with 8334 additions and 0 deletions

29
.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# Dependencies
node_modules/
# Dist
dist/
tsconfig.tsbuildinfo
db/
.astro/
# IDE
.vscode/
.idea/
.zed/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Environment
.env
.env.local

118
README.md Normal file
View File

@@ -0,0 +1,118 @@
# Food Scanner
## Features
- **Automatischer Barcode Scanner** - Scannt 1D & 2D Barcodes (EAN, UPC, QR-Codes)
- **Vegan-Analyse** - Erkennt tierische Zutaten und offizielle Vegan-Labels
- **Gewichtsverlust-Score** - Bewertet Produkte von 0-100 basierend auf:
- Kalorien
- Zucker
- Fett & gesättigte Fettsäuren
- Protein (sättigend!)
- Ballaststoffe (sättigend!)
- Natrium
- **Detaillierte Analyse** - Pro & Contra Listen für jedes Produkt
- **Open Food Facts API** - Zugriff auf riesige Produktdatenbank
## Installation
```bash
# Dependencies installieren
npm install
# Development Server starten
npm run dev
# App öffnen
# http://localhost:4321
```
## Wie es funktioniert
### 1. Barcode Scannen
```typescript
// ZXing scannt automatisch Barcodes
const codeReader = new ZXing.BrowserMultiFormatReader();
await codeReader.decodeFromVideoDevice(deviceId, videoElement, callback);
```
### 2. Produkt abrufen
```typescript
// Axios ruft Open Food Facts API auf
const response = await axios.get(
`https://world.openfoodfacts.org/api/v2/product/${barcode}.json`,
);
```
### 3. Vegan-Analyse
```typescript
function analyzeVegan(product) {
// Prüft Labels
if (labels.includes('en:vegan')) return true;
// Sucht nach tierischen Zutaten
const animalIngredients = ['milch', 'ei', 'honig', ...];
// ... Analyse Logik
}
```
### 4. Gewichtsverlust-Analyse
```typescript
function analyzeWeightLoss(product) {
let score = 100;
// Kalorien (wichtigster Faktor)
if (calories < 50) score += 10;
else if (calories > 400) score -= 40;
// Zucker (kritisch für Gewichtsverlust)
if (sugar > 20) score -= 30;
// Protein & Ballaststoffe (gut - sättigend!)
if (protein > 15) score += 15;
if (fiber > 6) score += 15;
// ... weitere Faktoren
return score; // 0-100
}
```
## 🎯 Bewertungs-System
### Vegan Score
-**Vegan** - Offiziell zertifiziert oder keine tierischen Zutaten
-**Nicht Vegan** - Enthält tierische Produkte
-**Unklar** - Status nicht eindeutig
### Gewichtsverlust Score
- 🌟 **80-100**: Excellent - Perfekt für Diät
-**60-79**: Good - In normalen Mengen okay
- ⚠️ **40-59**: Moderate - Kleine Portionen
-**20-39**: Poor - Besser vermeiden
- 🚫 **0-19**: Avoid - Nicht empfohlen
## Datenschutz
- Keine Daten werden gespeichert
- Alle Anfragen gehen direkt an Open Food Facts API
- Kamera wird nur mit Erlaubnis verwendet
- Keine Tracking oder Analytics
## Contributing
Feature Ideen:
- [ ] Allergen-Warnung
- [ ] PWA Support (Offline-Modus)
## Bekannte Probleme
- Manche Produkte sind nicht in der Datenbank
- Barcode-Scanner funktioniert nur mit HTTPS
- Einige Browser unterstützen Kamera nicht

5
astro.config.mjs Normal file
View File

@@ -0,0 +1,5 @@
import { defineConfig } from "astro/config";
export default defineConfig({
integrations: [],
});

19
biome.json Normal file
View File

@@ -0,0 +1,19 @@
{
"overrides": [
{
"includes": ["**/*.astro"],
"linter": {
"rules": {
"style": {
"useConst": "off",
"useImportType": "off"
},
"correctness": {
"noUnusedVariables": "off",
"noUnusedImports": "off"
}
}
}
}
]
}

6629
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "@dpu/food-scanner",
"version": "1.0.0",
"type": "module",
"description": "food product scanner",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"start": "node ./dist/server/entry.mjs"
},
"keywords": [],
"author": "",
"license": "MIT",
"dependencies": {
"@astrojs/check": "^0.9.5",
"@biomejs/biome": "^2.3.6",
"astro": "^5.16.0",
"axios": "^1.13.2",
"typescript": "^5.9.3"
}
}

536
public/styles/scanner.css Normal file
View File

@@ -0,0 +1,536 @@
/* Reset and Base Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial,
sans-serif;
}
/* Container */
.app-container {
min-height: 100vh;
background: linear-gradient(135deg, #2d5016 0%, #1a472a 50%, #0f3d3e 100%);
padding: 1rem;
}
.content-wrapper {
max-width: 42rem;
margin: 0 auto;
}
/* Main Card */
.main-card {
background: #1f2937;
border-radius: 1rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
padding: 1.5rem;
margin-bottom: 1rem;
}
/* Header */
.header {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.header-emoji {
font-size: 2.25rem;
}
.header-title {
font-size: 2.25rem;
font-weight: bold;
background: white;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.header-subtitle {
text-align: center;
color: #4b5563;
font-size: 0.875rem;
margin-bottom: 1.5rem;
}
/* Buttons */
.btn {
width: 100%;
font-weight: 600;
padding: 1rem 1.5rem;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-size: 1rem;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border: 2px solid rgba(255, 255, 255, 0.2);
border-radius: 12px;
color: white;
}
.btn:hover {
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
transform: scale(1.05);
}
.btn-cancel {
background: #4b5563;
color: white;
}
.btn-cancel:hover {
background: #374151;
}
.btn-danger {
background: #dc2626;
color: white;
padding: 0.5rem 1rem;
margin-top: 0.75rem;
}
.btn-danger:hover {
background: #b91c1c;
}
.btn-green {
background: #16a34a;
color: white;
padding: 0.75rem 1.5rem;
}
.btn-green:hover {
background: #15803d;
}
/* Form Elements */
.input {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid #d1d5db;
border-radius: 0.75rem;
font-size: 1rem;
transition: all 0.3s ease;
}
.input:focus {
outline: none;
border-color: #16a34a;
box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.1);
}
.form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
/* Divider */
.divider {
position: relative;
margin: 1.5rem 0;
}
.divider-line {
position: absolute;
inset: 0;
display: flex;
align-items: center;
}
.divider-line > div {
width: 100%;
border-top: 1px solid #d1d5db;
}
.divider-text {
position: relative;
display: flex;
justify-content: center;
font-size: 0.875rem;
}
.divider-text span {
padding: 0 0.5rem;
background: #1f2937;
color: lightgrey;
}
/* Scanner */
.scanner-container {
display: flex;
flex-direction: column;
gap: 1rem;
}
.video-wrapper {
position: relative;
background: black;
border-radius: 0.75rem;
overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
.video {
width: 100%;
height: 16rem;
object-fit: cover;
}
.video-overlay {
position: absolute;
inset: 0;
border: 4px solid #4ade80;
opacity: 0.5;
margin: 2rem;
border-radius: 0.5rem;
}
.barcode-display {
position: absolute;
bottom: 1rem;
left: 1rem;
right: 1rem;
background: #16a34a;
color: white;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
text-align: center;
font-family: "Courier New", monospace;
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.scanner-info {
text-align: center;
font-size: 0.875rem;
color: #4b5563;
}
/* Loading */
.loading-container {
text-align: center;
padding: 2rem 0;
}
.spinner {
display: inline-block;
width: 3rem;
height: 3rem;
border: 4px solid #16a34a;
border-top-color: transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-text {
margin-top: 1rem;
color: #4b5563;
}
/* Error */
.error-box {
background: #fef2f2;
border: 2px solid #fecaca;
border-radius: 0.75rem;
padding: 1rem;
color: #b91c1c;
}
.error-title {
font-weight: 600;
}
.error-message {
font-size: 0.875rem;
margin-top: 0.25rem;
}
/* Product Display */
.product-card {
display: flex;
flex-direction: column;
gap: 1rem;
}
.product-header {
margin-bottom: 1rem;
}
.product-title {
font-size: 1.5rem;
font-weight: bold;
color: white;
}
.product-brand {
color: lightgrey;
}
.product-barcode {
font-size: 0.875rem;
color: #6b7280;
margin-top: 0.25rem;
}
/* Analysis Boxes */
.analysis-box {
padding: 1rem;
border-radius: 0.75rem;
border: 2px solid;
margin-bottom: 1rem;
}
.analysis-box.vegan-yes {
background: #f0fdf4;
border-color: #86efac;
}
.analysis-box.vegan-no-high {
background: #fef2f2;
border-color: #fca5a5;
}
.analysis-box.vegan-uncertain {
background: #fefce8;
border-color: #fde047;
}
.analysis-box.rating-excellent {
background: #f0fdf4;
border-color: #86efac;
}
.analysis-box.rating-good {
background: #dbeafe;
border-color: #93c5fd;
}
.analysis-box.rating-moderate {
background: #fefce8;
border-color: #fde047;
}
.analysis-box.rating-poor {
background: #ffedd5;
border-color: #fdba74;
}
.analysis-box.rating-avoid {
background: #fef2f2;
border-color: #fca5a5;
}
.analysis-box.rating-default {
background: #f9fafb;
border-color: #d1d5db;
}
.analysis-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.analysis-emoji {
font-size: 1.5rem;
}
.analysis-title {
font-weight: bold;
font-size: 1.125rem;
}
.confidence-badge {
margin-left: auto;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
background: white;
border-radius: 9999px;
}
.analysis-reason {
font-size: 0.875rem;
}
.analysis-details {
margin-top: 0.5rem;
font-size: 0.75rem;
background: white;
padding: 0.5rem;
border-radius: 0.25rem;
}
/* Weight Loss Score */
.score-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.score-value {
font-size: 1.875rem;
font-weight: bold;
}
.score-value.score-excellent {
color: #16a34a;
}
.score-value.score-good {
color: #2563eb;
}
.score-value.score-moderate {
color: #ca8a04;
}
.score-value.score-poor {
color: #ea580c;
}
.score-value.score-avoid {
color: #dc2626;
}
.score-recommendation {
font-size: 0.875rem;
margin-bottom: 0.75rem;
}
/* Nutritional Grid */
.nutrition-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
margin-bottom: 0.75rem;
font-size: 0.75rem;
}
.nutrition-item {
background: white;
padding: 0.5rem;
border-radius: 0.25rem;
}
/* Pros and Cons */
.pros-cons {
margin-bottom: 0.5rem;
}
.pros-cons-title {
font-size: 0.75rem;
font-weight: 600;
}
.pros-cons-title.pros {
color: #15803d;
}
.pros-cons-title.cons {
color: #b91c1c;
}
.pros-cons-list {
list-style: none;
font-size: 0.75rem;
margin-top: 0.25rem;
}
.pros-cons-list li {
background: white;
padding: 0.25rem;
border-radius: 0.25rem;
margin-bottom: 0.25rem;
}
/* Ingredients */
.ingredients-box {
background: #f9fafb;
padding: 1rem;
border-radius: 0.75rem;
margin-top: 1rem;
margin-bottom: 1rem;
}
.ingredients-title {
font-weight: bold;
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
.ingredients-text {
font-size: 0.75rem;
color: #374151;
}
/* SVG Icons */
.icon {
width: 1.5rem;
height: 1.5rem;
}
.icon-sm {
width: 1.25rem;
height: 1.25rem;
}
/* Utility Classes */
.space-y-4 > * + * {
margin-top: 1rem;
}
.space-y-3 > * + * {
margin-top: 0.75rem;
}
.space-y-2 > * + * {
margin-top: 0.5rem;
}
.space-y-1 > * + * {
margin-top: 0.25rem;
}
.gap-2 {
gap: 0.5rem;
}
.gap-3 {
gap: 0.75rem;
}

View File

@@ -0,0 +1,473 @@
---
// Astro component - no props needed
---
<div class="app-container">
<div class="content-wrapper">
<div class="main-card">
<div class="header">
<h1 class="header-title">Food Scanner</h1>
</div>
<div id="initial-view" class="space-y-4">
<button id="start-scanner-btn" class="btn">
<svg
class="icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
></path>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
Scan Barcode
</button>
<div class="divider">
<div class="divider-line">
<div></div>
</div>
<div class="divider-text">
<span>or</span>
</div>
</div>
<form id="barcode-form" class="form">
<input
type="text"
name="barcode"
placeholder="Enter barcode (e.g. 4260122510030)"
class="input"
/>
<button type="submit" class="btn">
Analyze Product
</button>
</form>
</div>
<div
id="scanner-view"
class="scanner-container"
style="display: none;"
>
<div class="video-wrapper">
<video id="scanner-video" class="video"></video>
<div class="video-overlay"></div>
<div
id="barcode-display"
class="barcode-display"
style="display: none;"
>
</div>
</div>
<p class="scanner-info">
📸 Point the camera at the barcode or QR code
</p>
<form id="scanner-barcode-form" class="form">
<input
type="text"
name="barcode"
placeholder="Or enter barcode manually"
class="input"
/>
<button type="submit" class="btn btn-green">
Search
</button>
</form>
<button id="stop-scanner-btn" class="btn btn-cancel">
<svg
class="icon-sm"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"></path>
</svg>
Cancel
</button>
</div>
<div
id="loading-view"
class="loading-container"
style="display: none;"
>
<div class="spinner"></div>
<p class="loading-text">Analyzing product...</p>
</div>
<div id="error-view" class="error-box" style="display: none;">
<p class="error-title">❌ Error</p>
<p id="error-message" class="error-message"></p>
<button id="error-back-btn" class="btn btn-danger">
Back
</button>
</div>
</div>
<div id="product-view" class="space-y-4" style="display: none;">
<!-- Product data will be inserted here dynamically -->
</div>
</div>
</div>
<script>
import type { ProductAnalysis } from "../types";
import { analyzeProduct } from "../utils/productAnalysis";
declare global {
interface Window {
ZXing: any;
}
}
let codeReader: any = null;
let scanning = false;
// Get DOM elements
const initialView = document.getElementById("initial-view")!;
const scannerView = document.getElementById("scanner-view")!;
const loadingView = document.getElementById("loading-view")!;
const errorView = document.getElementById("error-view")!;
const productView = document.getElementById("product-view")!;
const errorMessage = document.getElementById("error-message")!;
const barcodeDisplay = document.getElementById("barcode-display")!;
const videoElement = document.getElementById(
"scanner-video",
) as HTMLVideoElement;
// Helper functions
function showView(view: HTMLElement) {
[initialView, scannerView, loadingView, errorView, productView].forEach(
(v) => {
v.style.display = "none";
},
);
view.style.display = view === productView ? "flex" : "block";
}
function showError(message: string) {
errorMessage.textContent = message;
showView(errorView);
}
function getRatingColor(rating: string): string {
switch (rating) {
case "excellent":
return "rating-excellent";
case "good":
return "rating-good";
case "moderate":
return "rating-moderate";
case "poor":
return "rating-poor";
case "avoid":
return "rating-avoid";
default:
return "rating-default";
}
}
function getScoreColor(score: number): string {
if (score >= 80) return "score-excellent";
if (score >= 60) return "score-good";
if (score >= 40) return "score-moderate";
if (score >= 20) return "score-poor";
return "score-avoid";
}
function renderProduct(data: ProductAnalysis) {
const veganClass = data.vegan.isVegan
? "vegan-yes"
: data.vegan.confidence === "high"
? "vegan-no-high"
: "vegan-uncertain";
const veganEmoji = data.vegan.isVegan
? "✅"
: data.vegan.confidence === "high"
? "❌"
: "❓";
const veganText = data.vegan.isVegan ? "VEGAN" : "NOT VEGAN";
const confidenceText =
data.vegan.confidence === "high"
? "Certain"
: data.vegan.confidence === "medium"
? "Medium"
: "Uncertain";
let nutritionGrid = "";
if (Object.keys(data.weightLoss.details).length > 0) {
nutritionGrid = '<div class="nutrition-grid">';
if (data.weightLoss.details.calories !== undefined) {
nutritionGrid += `<div class="nutrition-item"><strong>Calories:</strong> ${Math.round(data.weightLoss.details.calories)} kcal</div>`;
}
if (data.weightLoss.details.sugar !== undefined) {
nutritionGrid += `<div class="nutrition-item"><strong>Sugar:</strong> ${data.weightLoss.details.sugar.toFixed(1)}g</div>`;
}
if (data.weightLoss.details.fat !== undefined) {
nutritionGrid += `<div class="nutrition-item"><strong>Fat:</strong> ${data.weightLoss.details.fat.toFixed(1)}g</div>`;
}
if (data.weightLoss.details.protein !== undefined) {
nutritionGrid += `<div class="nutrition-item"><strong>Protein:</strong> ${data.weightLoss.details.protein.toFixed(1)}g</div>`;
}
if (data.weightLoss.details.fiber !== undefined) {
nutritionGrid += `<div class="nutrition-item"><strong>Fiber:</strong> ${data.weightLoss.details.fiber.toFixed(1)}g</div>`;
}
nutritionGrid += "</div>";
}
let prosHtml = "";
if (data.weightLoss.pros.length > 0) {
prosHtml = `
<div class="pros-cons">
<strong class="pros-cons-title pros">Pros:</strong>
<ul class="pros-cons-list">
${data.weightLoss.pros.map((pro) => `<li>${pro}</li>`).join("")}
</ul>
</div>
`;
}
let consHtml = "";
if (data.weightLoss.cons.length > 0) {
consHtml = `
<div class="pros-cons">
<strong class="pros-cons-title cons">Cons:</strong>
<ul class="pros-cons-list">
${data.weightLoss.cons.map((con) => `<li>${con}</li>`).join("")}
</ul>
</div>
`;
}
productView.innerHTML = `
<div class="main-card">
<div class="product-header">
<h2 class="product-title">${data.product.name}</h2>
${data.product.brand ? `<p class="product-brand">${data.product.brand}</p>` : ""}
<p class="product-barcode">Barcode: ${data.product.barcode}</p>
</div>
<div class="analysis-box ${veganClass}">
<div class="analysis-header">
<span class="analysis-emoji">${veganEmoji}</span>
<h3 class="analysis-title">${veganText}</h3>
<span class="confidence-badge">${confidenceText}</span>
</div>
<p class="analysis-reason">${data.vegan.reason}</p>
${
data.vegan.animalIngredients.length > 0
? `
<div class="analysis-details">
<strong>Animal Ingredients:</strong> ${data.vegan.animalIngredients.join(", ")}
</div>
`
: ""
}
</div>
<div class="analysis-box ${getRatingColor(data.weightLoss.rating)}">
<div class="score-header">
<h3 class="analysis-title">WEIGHT LOSS SCORE</h3>
<div class="score-value ${getScoreColor(data.weightLoss.score)}">
${data.weightLoss.score}/100
</div>
</div>
<p class="score-recommendation">${data.weightLoss.recommendation}</p>
${nutritionGrid}
${prosHtml}
${consHtml}
</div>
${
data.product.allergens && data.product.allergens.length > 0
? `
<div class="ingredients-box">
<h3 class="ingredients-title">Allergens</h3>
<div class="allergens-list">
${data.product.allergens.map((allergen) => `<span class="allergen-badge">${allergen}</span>`).join(" - ")}
</div>
</div>
`
: ""
}
${
data.product.ingredients
? `
<div class="ingredients-box">
<h3 class="ingredients-title">Ingredients</h3>
<p class="ingredients-text">${data.product.ingredients}</p>
</div>
`
: ""
}
<button id="new-scan-btn" class="btn">
Scan New Product
</button>
</div>
`;
document
.getElementById("new-scan-btn")!
.addEventListener("click", () => {
window.location.hash = "";
showView(initialView);
});
showView(productView);
}
async function searchProduct(barcode: string) {
showView(loadingView);
try {
const analysis = await analyzeProduct(barcode);
renderProduct(analysis);
// Update URL hash with barcode for sharing/bookmarking
window.location.hash = `#product/${barcode}`;
} catch (err: any) {
showError(err.message || "Error fetching product data");
if (scanning) {
stopScanner();
}
}
}
async function startScanner() {
try {
if (!window.ZXing) {
showError("Barcode scanner not yet loaded. Please wait...");
return;
}
scanning = true;
barcodeDisplay.style.display = "none";
barcodeDisplay.textContent = "";
showView(scannerView);
codeReader = new window.ZXing.BrowserMultiFormatReader();
const videoInputDevices =
await window.ZXing.BrowserCodeReader.listVideoInputDevices();
if (videoInputDevices.length === 0) {
showError("No camera found");
scanning = false;
return;
}
const selectedDeviceId = videoInputDevices[0].deviceId;
await codeReader.decodeFromVideoDevice(
selectedDeviceId,
videoElement,
(result: any, err: any) => {
if (result) {
const code = result.getText();
barcodeDisplay.textContent = code;
barcodeDisplay.style.display = "block";
codeReader.reset();
searchProduct(code);
}
},
);
} catch (err) {
console.error("Scanner error:", err);
showError(
"Camera could not be started. Please allow camera access.",
);
scanning = false;
}
}
function stopScanner() {
if (codeReader) {
codeReader.reset();
}
scanning = false;
showView(initialView);
}
// Event listeners
document
.getElementById("start-scanner-btn")!
.addEventListener("click", startScanner);
document
.getElementById("stop-scanner-btn")!
.addEventListener("click", stopScanner);
document.getElementById("error-back-btn")!.addEventListener("click", () => {
showView(initialView);
});
document.getElementById("barcode-form")!.addEventListener("submit", (e) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
const barcode = formData.get("barcode") as string;
if (barcode?.trim()) {
searchProduct(barcode.trim());
(e.target as HTMLFormElement).reset();
}
});
document
.getElementById("scanner-barcode-form")!
.addEventListener("submit", (e) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
const barcode = formData.get("barcode") as string;
if (barcode?.trim()) {
searchProduct(barcode.trim());
(e.target as HTMLFormElement).reset();
}
});
// Hash-based routing for direct URL access and sharing
function handleHashChange() {
const hash = window.location.hash;
if (hash.startsWith("#product/")) {
const barcode = hash.replace("#product/", "");
if (barcode) {
searchProduct(barcode);
}
} else {
// No hash or empty hash, show initial view
showView(initialView);
}
}
// Handle hash changes (back/forward navigation)
window.addEventListener("hashchange", handleHashChange);
// Check hash on page load
if (window.location.hash) {
handleHashChange();
}
// Cleanup on page unload
window.addEventListener("beforeunload", () => {
if (codeReader) {
codeReader.reset();
}
});
</script>

21
src/pages/index.astro Normal file
View File

@@ -0,0 +1,21 @@
---
import Scanner from "../components/Scanner.astro";
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DPU Food Scanner</title>
<meta
name="description"
content="Scan products and check if they are vegan and suitable for weight loss"
/>
<script src="https://unpkg.com/@zxing/library@latest"></script>
<link rel="stylesheet" href="/styles/scanner.css" />
</head>
<body>
<Scanner />
</body>
</html>

64
src/types/index.ts Normal file
View File

@@ -0,0 +1,64 @@
export interface OpenFoodFactsProduct {
product_name?: string;
brands?: string;
image_url?: string;
ingredients_text?: string;
categories?: string;
labels_tags?: string[];
allergens?: string;
allergens_tags?: string[];
nutriments?: {
"energy-kcal_100g"?: number;
fat_100g?: number;
"saturated-fat_100g"?: number;
carbohydrates_100g?: number;
sugars_100g?: number;
fiber_100g?: number;
proteins_100g?: number;
salt_100g?: number;
sodium_100g?: number;
};
}
export interface OpenFoodFactsResponse {
status: number;
product?: OpenFoodFactsProduct;
}
export interface VeganAnalysis {
isVegan: boolean;
confidence: "high" | "medium" | "low";
reason: string;
animalIngredients: string[];
}
export interface WeightLossAnalysis {
score: number;
rating: "excellent" | "good" | "moderate" | "poor" | "avoid";
recommendation: string;
details: {
calories?: number;
sugar?: number;
fat?: number;
saturatedFat?: number;
fiber?: number;
protein?: number;
sodium?: number;
};
pros: string[];
cons: string[];
}
export interface ProductAnalysis {
product: {
name: string;
brand: string;
barcode: string;
image?: string;
categories?: string;
ingredients?: string;
allergens?: string[];
};
vegan: VeganAnalysis;
weightLoss: WeightLossAnalysis;
}

View File

@@ -0,0 +1,410 @@
import axios from "axios";
import type {
OpenFoodFactsProduct,
OpenFoodFactsResponse,
ProductAnalysis,
VeganAnalysis,
WeightLossAnalysis,
} from "../types";
const API_BASE_URL = "https://world.openfoodfacts.org/api/v2/product";
/**
* Fetch product data from Open Food Facts API
*/
export async function fetchProduct(
barcode: string,
): Promise<OpenFoodFactsResponse> {
const response = await axios.get<OpenFoodFactsResponse>(
`${API_BASE_URL}/${barcode}.json`,
{
timeout: 10000,
headers: {
"User-Agent": "VeganScanner/1.0",
},
},
);
return response.data;
}
/**
* Analyze if a product is vegan
*/
export function analyzeVegan(
product: OpenFoodFactsProduct | undefined,
): VeganAnalysis {
if (!product) {
return {
isVegan: false,
confidence: "low",
reason: "No product data available",
animalIngredients: [],
};
}
const labels = product.labels_tags || [];
const ingredientsText = (product.ingredients_text || "").toLowerCase();
// Check official labels first
if (labels.includes("en:vegan")) {
return {
isVegan: true,
confidence: "high",
reason: "Officially certified as vegan ✓",
animalIngredients: [],
};
}
if (labels.includes("en:non-vegan")) {
return {
isVegan: false,
confidence: "high",
reason: "Officially labeled as non-vegan",
animalIngredients: [],
};
}
// Comprehensive list of animal ingredients (German and English)
const animalIngredientsDb = [
// Dairy
"milch",
"milk",
"butter",
"sahne",
"cream",
"käse",
"cheese",
"joghurt",
"yogurt",
"quark",
"molke",
"whey",
"lactose",
"laktose",
"casein",
"kasein",
"milchpulver",
"milk powder",
"buttermilch",
// Eggs
"ei",
"egg",
"eigelb",
"eiweiß",
"albumin",
"eiweiss",
// Meat & Fish
"fleisch",
"meat",
"fisch",
"fish",
"geflügel",
"poultry",
"huhn",
"chicken",
"rind",
"beef",
"schwein",
"pork",
"lamm",
"lamb",
"gelatine",
"gelatin",
// Other animal products
"honig",
"honey",
"kollagen",
"collagen",
"schmalz",
"lard",
"talg",
"tallow",
"bienenwachs",
"beeswax",
"karmin",
"carmine",
"shellac",
"schellack",
];
const foundIngredients: string[] = [];
for (const ingredient of animalIngredientsDb) {
// Use word boundaries to avoid false positives
const regex = new RegExp(`\\b${ingredient}\\b`, "i");
if (regex.test(ingredientsText)) {
foundIngredients.push(ingredient);
}
}
if (foundIngredients.length > 0) {
return {
isVegan: false,
confidence: "high",
reason: `Contains animal ingredients: ${foundIngredients.slice(0, 3).join(", ")}${foundIngredients.length > 3 ? "..." : ""}`,
animalIngredients: foundIngredients,
};
}
// If vegetarian but not vegan
if (labels.includes("en:vegetarian")) {
return {
isVegan: false,
confidence: "medium",
reason: "Only labeled as vegetarian, not vegan",
animalIngredients: [],
};
}
return {
isVegan: false,
confidence: "low",
reason: "Status unclear - please check ingredients list manually",
animalIngredients: [],
};
}
/**
* Analyze product for weight loss suitability
*/
export function analyzeWeightLoss(
product: OpenFoodFactsProduct | undefined,
): WeightLossAnalysis {
if (!product || !product.nutriments) {
return {
score: 0,
rating: "avoid",
recommendation: "No nutritional information available",
details: {},
pros: [],
cons: ["No nutritional data available"],
};
}
const nutrients = product.nutriments;
const details = {
calories: nutrients["energy-kcal_100g"],
sugar: nutrients["sugars_100g"],
fat: nutrients["fat_100g"],
saturatedFat: nutrients["saturated-fat_100g"],
fiber: nutrients["fiber_100g"],
protein: nutrients["proteins_100g"],
sodium: nutrients["sodium_100g"],
};
let score = 100;
const pros: string[] = [];
const cons: string[] = [];
// Calories analysis (most important for weight loss)
if (details.calories !== undefined) {
if (details.calories < 50) {
pros.push(
`🌟 Very low calorie (${Math.round(details.calories)} kcal/100g)`,
);
score += 10;
} else if (details.calories < 150) {
pros.push(`✅ Low calorie (${Math.round(details.calories)} kcal/100g)`);
} else if (details.calories < 250) {
cons.push(
`⚠️ Moderate calories (${Math.round(details.calories)} kcal/100g)`,
);
score -= 10;
} else if (details.calories < 400) {
cons.push(`❌ High calorie (${Math.round(details.calories)} kcal/100g)`);
score -= 25;
} else {
cons.push(
`🚫 Very high calorie (${Math.round(details.calories)} kcal/100g)`,
);
score -= 40;
}
}
// Sugar analysis (critical for weight loss)
if (details.sugar !== undefined) {
if (details.sugar < 2) {
pros.push(`🌟 Very low sugar (${details.sugar.toFixed(1)}g/100g)`);
score += 5;
} else if (details.sugar < 5) {
pros.push(`✅ Low sugar (${details.sugar.toFixed(1)}g/100g)`);
} else if (details.sugar < 10) {
cons.push(
`⚠️ Moderate sugar content (${details.sugar.toFixed(1)}g/100g)`,
);
score -= 10;
} else if (details.sugar < 20) {
cons.push(`❌ High sugar content (${details.sugar.toFixed(1)}g/100g)`);
score -= 20;
} else {
cons.push(
`🚫 Very high sugar content (${details.sugar.toFixed(1)}g/100g)`,
);
score -= 30;
}
}
// Fat analysis
if (details.fat !== undefined) {
if (details.fat < 3) {
pros.push(`✅ Low fat (${details.fat.toFixed(1)}g/100g)`);
score += 5;
} else if (details.fat < 10) {
// Moderate fat is okay
} else if (details.fat < 20) {
cons.push(`⚠️ Moderately high fat (${details.fat.toFixed(1)}g/100g)`);
score -= 5;
} else {
cons.push(`❌ High fat content (${details.fat.toFixed(1)}g/100g)`);
score -= 15;
}
}
// Saturated fat analysis
if (details.saturatedFat !== undefined) {
if (details.saturatedFat > 5) {
cons.push(
`❌ High saturated fat (${details.saturatedFat.toFixed(1)}g/100g)`,
);
score -= 10;
} else if (details.saturatedFat > 1.5) {
cons.push(
`⚠️ Contains saturated fat (${details.saturatedFat.toFixed(1)}g/100g)`,
);
score -= 5;
}
}
// Fiber analysis (excellent for weight loss - increases satiety)
if (details.fiber !== undefined) {
if (details.fiber > 6) {
pros.push(
`🌟 Very high fiber (${details.fiber.toFixed(1)}g/100g) - keeps you full!`,
);
score += 15;
} else if (details.fiber > 3) {
pros.push(`✅ Good fiber content (${details.fiber.toFixed(1)}g/100g)`);
score += 8;
}
} else {
cons.push("❓ No fiber information");
}
// Protein analysis (excellent for weight loss - increases satiety & thermogenesis)
if (details.protein !== undefined) {
if (details.protein > 15) {
pros.push(
`🌟 Very high protein (${details.protein.toFixed(1)}g/100g) - keeps you full longer!`,
);
score += 15;
} else if (details.protein > 8) {
pros.push(
`✅ Good protein content (${details.protein.toFixed(1)}g/100g)`,
);
score += 8;
} else if (details.protein > 5) {
pros.push(`Contains protein (${details.protein.toFixed(1)}g/100g)`);
score += 3;
}
}
// Sodium analysis (can cause water retention)
if (details.sodium !== undefined) {
const sodiumMg = details.sodium * 1000;
if (sodiumMg > 600) {
cons.push(
`❌ High sodium content (${Math.round(sodiumMg)}mg) - may cause water retention`,
);
score -= 10;
} else if (sodiumMg > 300) {
cons.push(`⚠️ Moderate sodium content (${Math.round(sodiumMg)}mg)`);
score -= 5;
}
}
// Ensure score is between 0 and 100
score = Math.max(0, Math.min(100, score));
// Determine rating and recommendation
let rating: WeightLossAnalysis["rating"];
let recommendation: string;
if (score >= 80) {
rating = "excellent";
recommendation =
"🌟 Excellent for weight loss! You can enjoy this product regularly.";
} else if (score >= 60) {
rating = "good";
recommendation = "✅ Good for weight loss. No problem in normal portions.";
} else if (score >= 40) {
rating = "moderate";
recommendation =
"⚠️ Moderately suitable. Best in small amounts and not daily.";
} else if (score >= 20) {
rating = "poor";
recommendation =
"❌ Less suitable for weight loss. Better to use sparingly.";
} else {
rating = "avoid";
recommendation =
"🚫 Not recommended for weight loss. Better to look for alternatives.";
}
return {
score,
rating,
recommendation,
details,
pros,
cons,
};
}
/**
* Analyze a product by barcode
*/
export async function analyzeProduct(
barcode: string,
): Promise<ProductAnalysis> {
const response = await fetchProduct(barcode);
if (response.status !== 1 || !response.product) {
throw new Error("Product not found");
}
const product = response.product;
const veganAnalysis = analyzeVegan(product);
const weightLossAnalysis = analyzeWeightLoss(product);
// Extract allergens from tags and clean them up
const allergens: string[] = [];
if (product.allergens_tags && product.allergens_tags.length > 0) {
product.allergens_tags.forEach((tag) => {
// Remove "en:" prefix and convert to readable format
const allergen = tag
.replace(/^en:/, "")
.replace(/-/g, " ")
.split(" ")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
allergens.push(allergen);
});
}
return {
product: {
name: product.product_name || "Unknown Product",
brand: product.brands || "Unknown Brand",
barcode: barcode,
image: product.image_url,
categories: product.categories,
ingredients: product.ingredients_text,
allergens: allergens.length > 0 ? allergens : undefined,
},
vegan: veganAnalysis,
weightLoss: weightLossAnalysis,
};
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react"
}
}