initial
This commit is contained in:
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal 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
118
README.md
Normal 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
5
astro.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { defineConfig } from "astro/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
integrations: [],
|
||||||
|
});
|
||||||
19
biome.json
Normal file
19
biome.json
Normal 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
6629
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal 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
536
public/styles/scanner.css
Normal 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;
|
||||||
|
}
|
||||||
473
src/components/Scanner.astro
Normal file
473
src/components/Scanner.astro
Normal 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
21
src/pages/index.astro
Normal 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
64
src/types/index.ts
Normal 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;
|
||||||
|
}
|
||||||
410
src/utils/productAnalysis.ts
Normal file
410
src/utils/productAnalysis.ts
Normal 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
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "react"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user