initial
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user