AI product identification with Gemini + dashboard layout fix
AI Identification:
- Rewrite analyzeWithAI() to use Gemini API for real image analysis
- Auto-start analysis immediately after taking photo (no manual button)
- Gemini identifies product name, brand, category from photo
- Reverse search on Open Food Facts to find matching barcoded products
- User can select a match to import full product data with barcode
- Or save product directly without barcode
- New API endpoint: gemini_identify with OFF reverse search
Dashboard:
- Move 🚫 Scaduti section to TOP of dashboard
- Show only top 4 soonest expiring items below
- Limit API query to 4 results
This commit is contained in:
+172
-2
@@ -91,6 +91,10 @@ try {
|
|||||||
generateRecipe($db);
|
generateRecipe($db);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'gemini_identify':
|
||||||
|
geminiIdentifyProduct();
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
echo json_encode(['error' => 'Unknown action: ' . $action]);
|
echo json_encode(['error' => 'Unknown action: ' . $action]);
|
||||||
@@ -509,13 +513,13 @@ function getStats(PDO $db): void {
|
|||||||
$recentIn = $db->query("SELECT COUNT(*) FROM transactions WHERE type='in' AND created_at >= datetime('now', '-7 days')")->fetchColumn();
|
$recentIn = $db->query("SELECT COUNT(*) FROM transactions WHERE type='in' AND created_at >= datetime('now', '-7 days')")->fetchColumn();
|
||||||
$recentOut = $db->query("SELECT COUNT(*) FROM transactions WHERE type='out' AND created_at >= datetime('now', '-7 days')")->fetchColumn();
|
$recentOut = $db->query("SELECT COUNT(*) FROM transactions WHERE type='out' AND created_at >= datetime('now', '-7 days')")->fetchColumn();
|
||||||
|
|
||||||
// Expiring soonest (next items to expire, up to 10)
|
// Expiring soonest (next 4 items to expire)
|
||||||
$expiring = $db->query("
|
$expiring = $db->query("
|
||||||
SELECT i.*, p.name, p.brand
|
SELECT i.*, p.name, p.brand
|
||||||
FROM inventory i JOIN products p ON i.product_id = p.id
|
FROM inventory i JOIN products p ON i.product_id = p.id
|
||||||
WHERE i.expiry_date IS NOT NULL AND i.expiry_date >= date('now') AND i.quantity > 0
|
WHERE i.expiry_date IS NOT NULL AND i.expiry_date >= date('now') AND i.quantity > 0
|
||||||
ORDER BY i.expiry_date ASC
|
ORDER BY i.expiry_date ASC
|
||||||
LIMIT 10
|
LIMIT 4
|
||||||
")->fetchAll();
|
")->fetchAll();
|
||||||
|
|
||||||
// Expired
|
// Expired
|
||||||
@@ -798,3 +802,169 @@ PROMPT;
|
|||||||
echo json_encode(['success' => false, 'error' => 'Impossibile generare la ricetta', 'raw' => $text]);
|
echo json_encode(['success' => false, 'error' => 'Impossibile generare la ricetta', 'raw' => $text]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== GEMINI AI PRODUCT IDENTIFICATION =====
|
||||||
|
function geminiIdentifyProduct(): void {
|
||||||
|
// Load API key
|
||||||
|
$envFile = __DIR__ . '/../.env';
|
||||||
|
$apiKey = '';
|
||||||
|
if (file_exists($envFile)) {
|
||||||
|
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
if (strpos($line, '#') === 0) continue;
|
||||||
|
if (strpos($line, '=') !== false) {
|
||||||
|
list($key, $val) = explode('=', $line, 2);
|
||||||
|
if (trim($key) === 'GEMINI_API_KEY') {
|
||||||
|
$apiKey = trim($val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($apiKey)) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'no_api_key']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$imageBase64 = $input['image'] ?? '';
|
||||||
|
|
||||||
|
if (empty($imageBase64)) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'No image provided']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Ask Gemini to identify the product
|
||||||
|
$prompt = <<<PROMPT
|
||||||
|
Analizza questa foto di un prodotto alimentare o di uso domestico. Identifica il prodotto nel modo più preciso possibile.
|
||||||
|
|
||||||
|
Rispondi SOLO con un JSON valido (senza markdown, senza backtick):
|
||||||
|
{
|
||||||
|
"name": "Nome del prodotto (es: Yogurt Greco Bianco)",
|
||||||
|
"brand": "Marca se visibile (es: Fage, Müller) o stringa vuota",
|
||||||
|
"category": "Categoria in italiano (es: latticini, pasta, bevande, snack, carne, pesce, frutta, verdura, surgelati, condimenti, conserve, cereali, pane, igiene, pulizia, altro)",
|
||||||
|
"search_terms": "termini di ricerca per trovare il prodotto su un database (es: greek yogurt fage, pasta barilla spaghetti)",
|
||||||
|
"confidence": "alta/media/bassa",
|
||||||
|
"description": "Breve descrizione del prodotto identificato"
|
||||||
|
}
|
||||||
|
PROMPT;
|
||||||
|
|
||||||
|
$url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={$apiKey}";
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'contents' => [
|
||||||
|
[
|
||||||
|
'parts' => [
|
||||||
|
['text' => $prompt],
|
||||||
|
[
|
||||||
|
'inline_data' => [
|
||||||
|
'mime_type' => 'image/jpeg',
|
||||||
|
'data' => $imageBase64
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'generationConfig' => [
|
||||||
|
'temperature' => 0.2,
|
||||||
|
'maxOutputTokens' => 512
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||||
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 30,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($response === false || $httpCode !== 200) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Errore API Gemini', 'http_code' => $httpCode]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($response, true);
|
||||||
|
$text = $data['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
||||||
|
|
||||||
|
$text = preg_replace('/^```json\\s*/i', '', $text);
|
||||||
|
$text = preg_replace('/\\s*```$/i', '', $text);
|
||||||
|
$text = trim($text);
|
||||||
|
|
||||||
|
$identified = json_decode($text, true);
|
||||||
|
|
||||||
|
if (!$identified || empty($identified['name'])) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Impossibile identificare il prodotto', 'raw' => $text]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Search Open Food Facts by product name to find a matching barcode
|
||||||
|
$searchTerms = $identified['search_terms'] ?? $identified['name'];
|
||||||
|
$offProducts = searchOpenFoodFacts($searchTerms, $identified['name'], $identified['brand'] ?? '');
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'identified' => $identified,
|
||||||
|
'off_matches' => $offProducts
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchOpenFoodFacts(string $searchTerms, string $name, string $brand): array {
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
// Try multiple search strategies
|
||||||
|
$queries = [];
|
||||||
|
if (!empty($brand)) {
|
||||||
|
$queries[] = trim($brand . ' ' . $name);
|
||||||
|
}
|
||||||
|
$queries[] = $name;
|
||||||
|
if ($searchTerms !== $name) {
|
||||||
|
$queries[] = $searchTerms;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seen = [];
|
||||||
|
foreach ($queries as $query) {
|
||||||
|
$encodedQuery = urlencode($query);
|
||||||
|
$url = "https://world.openfoodfacts.org/cgi/search.pl?search_terms={$encodedQuery}&search_simple=1&action=process&json=1&page_size=5&fields=code,product_name,product_name_it,brands,image_front_small_url,quantity,categories_tags&lc=it";
|
||||||
|
|
||||||
|
$ctx = stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'timeout' => 8,
|
||||||
|
'header' => "User-Agent: DispensaManager/1.0\r\n"
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = @file_get_contents($url, false, $ctx);
|
||||||
|
if ($response === false) continue;
|
||||||
|
|
||||||
|
$data = json_decode($response, true);
|
||||||
|
if (empty($data['products'])) continue;
|
||||||
|
|
||||||
|
foreach ($data['products'] as $p) {
|
||||||
|
$code = $p['code'] ?? '';
|
||||||
|
if (empty($code) || isset($seen[$code])) continue;
|
||||||
|
$seen[$code] = true;
|
||||||
|
|
||||||
|
$pName = $p['product_name_it'] ?? $p['product_name'] ?? '';
|
||||||
|
if (empty($pName)) continue;
|
||||||
|
|
||||||
|
$results[] = [
|
||||||
|
'barcode' => $code,
|
||||||
|
'name' => $pName,
|
||||||
|
'brand' => $p['brands'] ?? '',
|
||||||
|
'image_url' => $p['image_front_small_url'] ?? '',
|
||||||
|
'quantity_info' => $p['quantity'] ?? '',
|
||||||
|
'category' => $p['categories_tags'][0] ?? '',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (count($results) >= 6) break 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1725,3 +1725,65 @@ body {
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== AI IDENTIFICATION RESULTS ===== */
|
||||||
|
.ai-identified-card {
|
||||||
|
background: var(--bg-light);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-matches-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-match-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 2px solid var(--bg-light);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-match-item:active {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--bg-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-match-img {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-match-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-match-info strong {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-match-barcode {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: monospace;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|||||||
+160
-69
@@ -1669,14 +1669,12 @@ async function initAICamera() {
|
|||||||
const captureDiv = document.getElementById('ai-capture');
|
const captureDiv = document.getElementById('ai-capture');
|
||||||
const previewDiv = document.getElementById('ai-preview');
|
const previewDiv = document.getElementById('ai-preview');
|
||||||
const captureBtn = document.getElementById('ai-capture-btn');
|
const captureBtn = document.getElementById('ai-capture-btn');
|
||||||
const analyzeBtn = document.getElementById('ai-analyze-btn');
|
|
||||||
const retakeBtn = document.getElementById('ai-retake-btn');
|
const retakeBtn = document.getElementById('ai-retake-btn');
|
||||||
const resultDiv = document.getElementById('ai-result');
|
const resultDiv = document.getElementById('ai-result');
|
||||||
|
|
||||||
captureDiv.style.display = 'block';
|
captureDiv.style.display = 'block';
|
||||||
previewDiv.style.display = 'none';
|
previewDiv.style.display = 'none';
|
||||||
captureBtn.style.display = 'block';
|
captureBtn.style.display = 'block';
|
||||||
analyzeBtn.style.display = 'none';
|
|
||||||
retakeBtn.style.display = 'none';
|
retakeBtn.style.display = 'none';
|
||||||
resultDiv.style.display = 'none';
|
resultDiv.style.display = 'none';
|
||||||
|
|
||||||
@@ -1718,8 +1716,10 @@ function takePhotoForAI() {
|
|||||||
document.getElementById('ai-capture').style.display = 'none';
|
document.getElementById('ai-capture').style.display = 'none';
|
||||||
document.getElementById('ai-preview').style.display = 'block';
|
document.getElementById('ai-preview').style.display = 'block';
|
||||||
document.getElementById('ai-capture-btn').style.display = 'none';
|
document.getElementById('ai-capture-btn').style.display = 'none';
|
||||||
document.getElementById('ai-analyze-btn').style.display = 'block';
|
|
||||||
document.getElementById('ai-retake-btn').style.display = 'block';
|
document.getElementById('ai-retake-btn').style.display = 'block';
|
||||||
|
|
||||||
|
// Immediately start analysis
|
||||||
|
analyzeWithAI();
|
||||||
}
|
}
|
||||||
|
|
||||||
function retakePhotoAI() {
|
function retakePhotoAI() {
|
||||||
@@ -1730,82 +1730,173 @@ function retakePhotoAI() {
|
|||||||
async function analyzeWithAI() {
|
async function analyzeWithAI() {
|
||||||
const resultDiv = document.getElementById('ai-result');
|
const resultDiv = document.getElementById('ai-result');
|
||||||
resultDiv.style.display = 'block';
|
resultDiv.style.display = 'block';
|
||||||
resultDiv.innerHTML = '<p>🤖 Analisi in corso...</p><div class="loading-spinner" style="margin:12px auto"></div>';
|
resultDiv.innerHTML = '<div style="text-align:center;padding:20px"><div class="loading-spinner" style="margin:0 auto 12px"></div><p>🤖 Identifico il prodotto...</p></div>';
|
||||||
|
|
||||||
const canvas = document.getElementById('ai-canvas');
|
const canvas = document.getElementById('ai-canvas');
|
||||||
const imageData = canvas.toDataURL('image/jpeg', 0.7);
|
const base64 = canvas.toDataURL('image/jpeg', 0.7).split(',')[1];
|
||||||
|
|
||||||
// We'll use a free approach: analyze image colors and shapes locally
|
try {
|
||||||
// and try to identify using image analysis heuristics
|
const result = await api('gemini_identify', {}, 'POST', { image: base64 });
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
if (!result.success) {
|
||||||
|
if (result.error === 'no_api_key') {
|
||||||
// Simple color analysis to guess product type
|
resultDiv.innerHTML = `<p style="color:var(--warning)">⚠️ Chiave API Gemini non configurata.<br><small>Aggiungi GEMINI_API_KEY nel file .env sul server.</small></p>`;
|
||||||
let r = 0, g = 0, b = 0;
|
} else {
|
||||||
const pixels = imgData.data;
|
resultDiv.innerHTML = `<p style="color:var(--danger)">❌ ${escapeHtml(result.error || 'Errore nell\'identificazione')}</p>
|
||||||
const count = pixels.length / 4;
|
<button class="btn btn-secondary full-width mt-2" onclick="retakePhotoAI()">🔄 Riprova</button>`;
|
||||||
for (let i = 0; i < pixels.length; i += 16) { // sample every 4th pixel
|
}
|
||||||
r += pixels[i];
|
return;
|
||||||
g += pixels[i + 1];
|
}
|
||||||
b += pixels[i + 2];
|
|
||||||
|
const id = result.identified;
|
||||||
|
const matches = result.off_matches || [];
|
||||||
|
|
||||||
|
let html = `<h4>🤖 Prodotto identificato</h4>`;
|
||||||
|
html += `<div class="ai-identified-card">`;
|
||||||
|
html += `<strong>${escapeHtml(id.name)}</strong>`;
|
||||||
|
if (id.brand) html += ` <span style="color:var(--text-muted)">- ${escapeHtml(id.brand)}</span>`;
|
||||||
|
if (id.description) html += `<p style="font-size:0.85rem;color:var(--text-light);margin:4px 0 0">${escapeHtml(id.description)}</p>`;
|
||||||
|
html += `</div>`;
|
||||||
|
|
||||||
|
if (matches.length > 0) {
|
||||||
|
html += `<h4 style="margin-top:16px">📦 Prodotti corrispondenti</h4>`;
|
||||||
|
html += `<div class="ai-matches-list">`;
|
||||||
|
matches.forEach((m, idx) => {
|
||||||
|
html += `<div class="ai-match-item" onclick="selectAIMatch(${idx})">`;
|
||||||
|
if (m.image_url) {
|
||||||
|
html += `<img src="${m.image_url}" alt="" class="ai-match-img" onerror="this.style.display='none'">`;
|
||||||
|
}
|
||||||
|
html += `<div class="ai-match-info">`;
|
||||||
|
html += `<strong>${escapeHtml(m.name)}</strong>`;
|
||||||
|
if (m.brand) html += `<br><small>${escapeHtml(m.brand)}</small>`;
|
||||||
|
if (m.quantity_info) html += `<br><small style="color:var(--text-muted)">${escapeHtml(m.quantity_info)}</small>`;
|
||||||
|
html += `</div>`;
|
||||||
|
html += `<span class="ai-match-barcode">${m.barcode}</span>`;
|
||||||
|
html += `</div>`;
|
||||||
|
});
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option to save as-is without barcode
|
||||||
|
html += `<div style="margin-top:16px; border-top: 1px solid var(--bg-light); padding-top: 12px">`;
|
||||||
|
html += `<button class="btn btn-secondary full-width" onclick="saveAIProductDirect()">✏️ Salva senza barcode</button>`;
|
||||||
|
html += `</div>`;
|
||||||
|
|
||||||
|
resultDiv.innerHTML = html;
|
||||||
|
|
||||||
|
// Store data for later use
|
||||||
|
window._aiIdentified = id;
|
||||||
|
window._aiMatches = matches;
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('AI identify error:', err);
|
||||||
|
resultDiv.innerHTML = `<p style="color:var(--danger)">❌ Errore di connessione</p>
|
||||||
|
<button class="btn btn-secondary full-width mt-2" onclick="retakePhotoAI()">🔄 Riprova</button>`;
|
||||||
}
|
}
|
||||||
const samples = count / 4;
|
|
||||||
r = Math.round(r / samples);
|
|
||||||
g = Math.round(g / samples);
|
|
||||||
b = Math.round(b / samples);
|
|
||||||
|
|
||||||
// Provide a manual identification form since free AI APIs are limited
|
|
||||||
resultDiv.innerHTML = `
|
|
||||||
<h4>🤖 Identificazione Prodotto</h4>
|
|
||||||
<p style="font-size:0.85rem;color:var(--text-light);margin:8px 0">
|
|
||||||
L'analisi automatica ha dei limiti senza API a pagamento.
|
|
||||||
Puoi descrivere il prodotto qui sotto e lo salveremo nel database.
|
|
||||||
</p>
|
|
||||||
<form class="form" onsubmit="submitAIProduct(event)" style="margin-top:12px">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>🏷️ Che prodotto è? *</label>
|
|
||||||
<input type="text" id="ai-product-name" class="form-input" required
|
|
||||||
placeholder="Es: Yogurt greco, Pasta Barilla..." autofocus>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>🏢 Marca (se visibile)</label>
|
|
||||||
<input type="text" id="ai-product-brand" class="form-input" placeholder="Es: Müller, Barilla...">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>📂 Categoria</label>
|
|
||||||
<select id="ai-product-category" class="form-input">
|
|
||||||
<option value="">-- Seleziona --</option>
|
|
||||||
${Object.entries(CATEGORY_ICONS).map(([k, v]) => `<option value="${k}">${v} ${k.charAt(0).toUpperCase() + k.slice(1)}</option>`).join('')}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-large btn-accent full-width">✅ Salva e Continua</button>
|
|
||||||
</form>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitAIProduct(e) {
|
async function selectAIMatch(idx) {
|
||||||
e.preventDefault();
|
const match = window._aiMatches[idx];
|
||||||
|
if (!match) return;
|
||||||
|
|
||||||
showLoading(true);
|
showLoading(true);
|
||||||
|
|
||||||
const name = document.getElementById('ai-product-name').value;
|
|
||||||
const brand = document.getElementById('ai-product-brand').value;
|
|
||||||
const category = document.getElementById('ai-product-category').value;
|
|
||||||
|
|
||||||
// Save the captured image as base64 (we could save to file, but for simplicity use image_url)
|
|
||||||
const canvas = document.getElementById('ai-canvas');
|
|
||||||
// For a lightweight approach, don't store the actual image data in DB
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await api('product_save', {}, 'POST', {
|
// Use the barcode to do a full lookup (gets all details)
|
||||||
name, brand, category,
|
const localResult = await api('search_barcode', { barcode: match.barcode });
|
||||||
|
if (localResult.found) {
|
||||||
|
currentProduct = localResult.product;
|
||||||
|
showLoading(false);
|
||||||
|
showProductAction();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full lookup via OpenFoodFacts
|
||||||
|
const lookupResult = await api('lookup_barcode', { barcode: match.barcode });
|
||||||
|
if (lookupResult.found && lookupResult.product) {
|
||||||
|
const p = lookupResult.product;
|
||||||
|
const detected = detectUnitAndQuantity(p.quantity_info);
|
||||||
|
|
||||||
|
const notesParts = [];
|
||||||
|
if (p.quantity_info) notesParts.push(`Peso: ${p.quantity_info}`);
|
||||||
|
if (p.nutriscore) notesParts.push(`Nutriscore: ${p.nutriscore.toUpperCase()}`);
|
||||||
|
if (p.nova_group) notesParts.push(`NOVA: ${p.nova_group}`);
|
||||||
|
if (p.ecoscore) notesParts.push(`Ecoscore: ${p.ecoscore.toUpperCase()}`);
|
||||||
|
if (p.origin) notesParts.push(`Origine: ${p.origin}`);
|
||||||
|
|
||||||
|
const saveResult = await api('product_save', {}, 'POST', {
|
||||||
|
barcode: match.barcode,
|
||||||
|
name: p.name || match.name,
|
||||||
|
brand: p.brand || match.brand || '',
|
||||||
|
category: p.category || '',
|
||||||
|
image_url: p.image_url || match.image_url || '',
|
||||||
|
unit: detected.unit,
|
||||||
|
default_quantity: detected.quantity,
|
||||||
|
notes: notesParts.join(' · '),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (saveResult.id) {
|
||||||
|
currentProduct = {
|
||||||
|
id: saveResult.id,
|
||||||
|
barcode: match.barcode,
|
||||||
|
name: p.name || match.name,
|
||||||
|
brand: p.brand || match.brand || '',
|
||||||
|
category: p.category || '',
|
||||||
|
image_url: p.image_url || match.image_url || '',
|
||||||
|
unit: detected.unit,
|
||||||
|
default_quantity: detected.quantity,
|
||||||
|
weight_info: p.quantity_info || '',
|
||||||
|
};
|
||||||
|
showLoading(false);
|
||||||
|
showProductAction();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: save with basic info from match
|
||||||
|
const saveResult = await api('product_save', {}, 'POST', {
|
||||||
|
barcode: match.barcode,
|
||||||
|
name: match.name,
|
||||||
|
brand: match.brand || '',
|
||||||
|
category: match.category || '',
|
||||||
|
image_url: match.image_url || '',
|
||||||
unit: 'pz',
|
unit: 'pz',
|
||||||
default_quantity: 1,
|
default_quantity: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (saveResult.id) {
|
||||||
currentProduct = { id: result.id, name, brand, category, unit: 'pz', default_quantity: 1 };
|
currentProduct = { id: saveResult.id, barcode: match.barcode, name: match.name, brand: match.brand || '', category: match.category || '', image_url: match.image_url || '', unit: 'pz', default_quantity: 1 };
|
||||||
showLoading(false);
|
showLoading(false);
|
||||||
showToast('Prodotto identificato e salvato!', 'success');
|
showProductAction();
|
||||||
|
} else {
|
||||||
|
showLoading(false);
|
||||||
|
showToast('Errore nel salvataggio', 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showLoading(false);
|
||||||
|
console.error('AI match select error:', err);
|
||||||
|
showToast('Errore di connessione', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAIProductDirect() {
|
||||||
|
const id = window._aiIdentified;
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
showLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await api('product_save', {}, 'POST', {
|
||||||
|
name: id.name,
|
||||||
|
brand: id.brand || '',
|
||||||
|
category: id.category || '',
|
||||||
|
unit: 'pz',
|
||||||
|
default_quantity: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success || result.id) {
|
||||||
|
currentProduct = { id: result.id, name: id.name, brand: id.brand || '', category: id.category || '', unit: 'pz', default_quantity: 1 };
|
||||||
|
showLoading(false);
|
||||||
|
showToast('Prodotto salvato!', 'success');
|
||||||
showProductAction();
|
showProductAction();
|
||||||
} else {
|
} else {
|
||||||
showLoading(false);
|
showLoading(false);
|
||||||
|
|||||||
+6
-8
@@ -53,15 +53,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Alert for expiring items -->
|
<!-- Alert for expired items (on top) -->
|
||||||
<div class="alert-section" id="alert-expiring" style="display:none">
|
|
||||||
<h3>⏰ Prossime Scadenze</h3>
|
|
||||||
<div id="expiring-list"></div>
|
|
||||||
</div>
|
|
||||||
<div class="alert-section alert-danger" id="alert-expired" style="display:none">
|
<div class="alert-section alert-danger" id="alert-expired" style="display:none">
|
||||||
<h3>🚫 Scaduti</h3>
|
<h3>🚫 Scaduti</h3>
|
||||||
<div id="expired-list"></div>
|
<div id="expired-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Alert for soonest expiring items -->
|
||||||
|
<div class="alert-section" id="alert-expiring" style="display:none">
|
||||||
|
<h3>⏰ Prossime Scadenze</h3>
|
||||||
|
<div id="expiring-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Full inventory by location -->
|
<!-- Full inventory by location -->
|
||||||
<div class="section-card" id="dash-section-dispensa">
|
<div class="section-card" id="dash-section-dispensa">
|
||||||
@@ -436,9 +437,6 @@
|
|||||||
<button class="btn btn-large btn-accent" onclick="takePhotoForAI()" id="ai-capture-btn">
|
<button class="btn btn-large btn-accent" onclick="takePhotoForAI()" id="ai-capture-btn">
|
||||||
📸 Scatta Foto
|
📸 Scatta Foto
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-large btn-primary" onclick="analyzeWithAI()" id="ai-analyze-btn" style="display:none">
|
|
||||||
🤖 Analizza con AI
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-large btn-secondary" onclick="retakePhotoAI()" id="ai-retake-btn" style="display:none">
|
<button class="btn btn-large btn-secondary" onclick="retakePhotoAI()" id="ai-retake-btn" style="display:none">
|
||||||
🔄 Riscatta
|
🔄 Riscatta
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user