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

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>