Files
dpu-food-scanner/src/components/Scanner.astro
2025-11-21 01:22:50 +01:00

1013 lines
24 KiB
Plaintext

---
// Astro component - no props needed
---
<style>
/* 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;
}
</style>
<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>