1013 lines
24 KiB
Plaintext
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>
|