diff --git a/dashboard/catalog-public.html b/dashboard/catalog-public.html index 86dfe46..0aa625c 100644 --- a/dashboard/catalog-public.html +++ b/dashboard/catalog-public.html @@ -375,5 +375,9 @@ + + + + diff --git a/dashboard/chat-public.css b/dashboard/chat-public.css new file mode 100644 index 0000000..35381ac --- /dev/null +++ b/dashboard/chat-public.css @@ -0,0 +1,237 @@ +/* ========================================================================== + NEXUS — Public Catalog Chat Widget + Reuses design tokens from tokens.css + ========================================================================== */ + +.chat-fab { + position: fixed; + bottom: 24px; + right: 24px; + z-index: 8000; + width: 52px; + height: 52px; + border-radius: var(--radius-full, 50%); + border: none; + cursor: pointer; + background: var(--color-accent, #F5A623); + color: #fff; + font-size: 1.5rem; + display: flex; + align-items: center; + justify-content: center; + box-shadow: var(--shadow-lg, 0 4px 12px rgba(0,0,0,0.3)); + transition: transform 0.2s ease, background 0.2s ease; +} + +.chat-fab:hover { + transform: scale(1.08); + background: var(--color-primary-hover, #e5952f); +} + +.chat-panel { + position: fixed; + bottom: 90px; + right: 24px; + z-index: 8001; + width: 400px; + height: 520px; + max-height: calc(100vh - 100px); + display: flex; + flex-direction: column; + background: var(--color-bg-elevated, #1a1a1a); + border: 1px solid var(--color-border, #333); + border-radius: var(--radius-xl, 16px); + box-shadow: var(--shadow-xl, 0 8px 32px rgba(0,0,0,0.4)); + overflow: hidden; + transform: translateY(20px) scale(0.95); + opacity: 0; + pointer-events: none; + transition: transform 0.25s ease, opacity 0.25s ease; +} + +.chat-panel.open { + transform: translateY(0) scale(1); + opacity: 1; + pointer-events: all; +} + +.chat-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: var(--color-accent, #F5A623); + color: #fff; + flex-shrink: 0; +} + +.chat-header h3 { + font-family: var(--font-heading, sans-serif); + font-size: 0.95rem; + font-weight: 600; + margin: 0; +} + +.chat-header-close { + background: none; + border: none; + color: #fff; + font-size: 1.2rem; + cursor: pointer; + padding: 4px; + line-height: 1; + opacity: 0.8; +} + +.chat-header-close:hover { opacity: 1; } + +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.chat-msg { + max-width: 85%; + padding: 8px 12px; + border-radius: 12px; + font-size: 0.875rem; + line-height: 1.45; + word-wrap: break-word; +} + +.chat-msg.user { + align-self: flex-end; + background: var(--color-accent, #F5A623); + color: #fff; + border-bottom-right-radius: 4px; +} + +.chat-msg.ai { + align-self: flex-start; + background: var(--color-surface-2, rgba(255,255,255,0.06)); + color: var(--color-text-primary, #fff); + border-bottom-left-radius: 4px; +} + +.chat-typing { + align-self: flex-start; + display: none; + gap: 4px; + padding: 8px 12px; + background: var(--color-surface-2, rgba(255,255,255,0.06)); + border-radius: 12px; + border-bottom-left-radius: 4px; +} + +.chat-typing.visible { display: flex; } + +.chat-typing span { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--color-text-muted, #888); + animation: chatBounce 1.2s infinite; +} +.chat-typing span:nth-child(2) { animation-delay: 0.2s; } +.chat-typing span:nth-child(3) { animation-delay: 0.4s; } + +@keyframes chatBounce { + 0%, 60%, 100% { transform: translateY(0); } + 30% { transform: translateY(-5px); } +} + +.chat-parts { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 8px; +} + +.chat-part-card { + background: var(--color-bg-elevated, #1a1a1a); + border: 1px solid var(--color-border, #333); + border-radius: 8px; + padding: 8px 12px; + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease; +} + +.chat-part-card:hover { + border-color: var(--color-accent, #F5A623); + background: var(--color-bg-base, #111); +} + +.chat-part-card .part-number { + font-family: var(--font-mono, monospace); + font-size: 0.75rem; + color: var(--color-accent, #F5A623); + font-weight: 600; +} + +.chat-part-card .part-name { + font-size: 0.875rem; + color: var(--color-text-primary, #fff); + margin-top: 2px; +} + +.chat-input-area { + display: flex; + gap: 8px; + padding: 12px; + border-top: 1px solid var(--color-border, #333); + flex-shrink: 0; +} + +.chat-input { + flex: 1; + padding: 8px 12px; + border: 1px solid var(--color-border, #333); + border-radius: 8px; + background: var(--color-bg-base, #111); + color: var(--color-text-primary, #fff); + font-size: 0.875rem; + font-family: var(--font-body, sans-serif); + resize: none; + outline: none; + min-height: 38px; + max-height: 80px; +} + +.chat-input:focus { + border-color: var(--color-accent, #F5A623); +} + +.chat-input::placeholder { + color: var(--color-text-muted, #888); +} + +.chat-send-btn { + width: 38px; + height: 38px; + border-radius: 8px; + border: none; + background: var(--color-accent, #F5A623); + color: #fff; + font-size: 1.1rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: background 0.15s ease; +} + +.chat-send-btn:hover { background: var(--color-primary-hover, #e5952f); } +.chat-send-btn:disabled { opacity: 0.5; cursor: not-allowed; } + +@media (max-width: 480px) { + .chat-panel { + width: calc(100vw - 16px); + right: 8px; + height: 60vh; + } +} diff --git a/dashboard/chat-public.js b/dashboard/chat-public.js new file mode 100644 index 0000000..6d9cb98 --- /dev/null +++ b/dashboard/chat-public.js @@ -0,0 +1,191 @@ +// /home/Autopartes/dashboard/chat-public.js +// Public catalog chatbot — no auth required, calls /api/chat + +(function () { + 'use strict'; + + var isOpen = false; + var isSending = false; + var history = []; + + function init() { + // FAB button + var fab = document.createElement('button'); + fab.className = 'chat-fab'; + fab.id = 'chatFab'; + fab.title = 'Asistente IA'; + fab.innerHTML = '💬'; + fab.setAttribute('aria-label', 'Abrir asistente IA'); + + // Chat panel + var panel = document.createElement('div'); + panel.className = 'chat-panel'; + panel.id = 'chatPanel'; + panel.innerHTML = + '
' + + '

Asistente — Buscar partes

' + + '' + + '
' + + '
' + + '
Hola, soy el asistente de Nexus Autoparts. Dime que refaccion buscas y te ayudo a encontrarla en el catalogo.
' + + '
' + + '
' + + '
' + + '' + + '' + + '
'; + + document.body.appendChild(fab); + document.body.appendChild(panel); + + fab.addEventListener('click', toggleChat); + document.getElementById('chatClose').addEventListener('click', toggleChat); + document.getElementById('chatSend').addEventListener('click', sendMessage); + document.getElementById('chatInput').addEventListener('keydown', function (e) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }); + + document.getElementById('chatInput').addEventListener('input', function () { + this.style.height = 'auto'; + this.style.height = Math.min(this.scrollHeight, 80) + 'px'; + }); + } + + function toggleChat() { + isOpen = !isOpen; + var panel = document.getElementById('chatPanel'); + var fab = document.getElementById('chatFab'); + if (isOpen) { + panel.classList.add('open'); + fab.style.display = 'none'; + document.getElementById('chatInput').focus(); + } else { + panel.classList.remove('open'); + fab.style.display = 'flex'; + } + } + + function sendMessage() { + if (isSending) return; + var input = document.getElementById('chatInput'); + var text = input.value.trim(); + if (!text) return; + + input.value = ''; + input.style.height = 'auto'; + + addBubble(text, 'user'); + + history.push({ role: 'user', content: text }); + if (history.length > 20) history.splice(0, 2); + + isSending = true; + document.getElementById('chatSend').disabled = true; + showTyping(true); + + fetch('/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message: text, + history: history.slice(-10) + }) + }) + .then(function (resp) { return resp.json(); }) + .then(function (data) { + if (data.error) { + addBubble('Error: ' + data.error, 'ai'); + return; + } + var aiMsg = data.response || 'Sin respuesta.'; + addBubble(aiMsg, 'ai'); + history.push({ role: 'assistant', content: aiMsg }); + + if (data.search_results && data.search_results.length > 0) { + addPartResults(data.search_results); + } + }) + .catch(function (err) { + addBubble('Error de conexion: ' + err.message, 'ai'); + }) + .finally(function () { + isSending = false; + document.getElementById('chatSend').disabled = false; + showTyping(false); + }); + } + + function addBubble(text, role) { + var container = document.getElementById('chatMessages'); + var typing = document.getElementById('chatTyping'); + var div = document.createElement('div'); + div.className = 'chat-msg ' + role; + div.textContent = text; + container.insertBefore(div, typing); + scrollToBottom(); + } + + function addPartResults(parts) { + var container = document.getElementById('chatMessages'); + var typing = document.getElementById('chatTyping'); + + var wrapper = document.createElement('div'); + wrapper.className = 'chat-parts'; + + parts.slice(0, 8).forEach(function (p) { + var card = document.createElement('div'); + card.className = 'chat-part-card'; + + var name = p.name_es || p.name_part || ''; + var partNum = p.oem_part_number || p.part_number || ''; + var brand = p.brand || ''; + + card.innerHTML = + '
' + esc(partNum) + '
' + + '
' + esc(name) + (brand ? ' (' + esc(brand) + ')' : '') + '
'; + + card.style.cursor = 'pointer'; + card.addEventListener('click', function () { + // Search in catalog + var searchInput = document.getElementById('searchInput'); + if (searchInput && partNum) { + searchInput.value = partNum; + if (typeof window.doSearch === 'function') window.doSearch(); + toggleChat(); + } + }); + + wrapper.appendChild(card); + }); + + container.insertBefore(wrapper, typing); + scrollToBottom(); + } + + function showTyping(show) { + var el = document.getElementById('chatTyping'); + if (el) el.classList.toggle('visible', show); + if (show) scrollToBottom(); + } + + function scrollToBottom() { + var el = document.getElementById('chatMessages'); + if (el) el.scrollTop = el.scrollHeight; + } + + function esc(s) { + if (!s) return ''; + var d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/dashboard/server.py b/dashboard/server.py index 7c38499..9802745 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -249,6 +249,97 @@ def admin_js(): def enhanced_search_js(): return send_from_directory('.', 'enhanced-search.js') +@app.route('/chat-public.js') +def chat_public_js(): + return send_from_directory('.', 'chat-public.js') + +@app.route('/chat-public.css') +def chat_public_css(): + return send_from_directory('.', 'chat-public.css') + + +# ============================================================================ +# Public Chat Endpoint (rate limited, no auth) +# ============================================================================ + +# Simple in-memory rate limiting for public chat +_chat_rate = {} # ip -> [timestamps] + +def _check_chat_rate(ip, max_per_min=10): + """Return True if request is allowed, False if rate limited.""" + import time + now = time.time() + if ip not in _chat_rate: + _chat_rate[ip] = [] + # Clean old entries + _chat_rate[ip] = [t for t in _chat_rate[ip] if now - t < 60] + if len(_chat_rate[ip]) >= max_per_min: + return False + _chat_rate[ip].append(now) + return True + +@app.route('/api/chat', methods=['POST']) +def public_chat(): + """Public chatbot endpoint — searches the public catalog (no auth required).""" + client_ip = request.remote_addr or '0.0.0.0' + if not _check_chat_rate(client_ip): + return jsonify({'error': 'Demasiadas solicitudes. Intenta en un minuto.'}), 429 + + body = request.get_json(force=True) if request.is_json else {} + user_message = (body.get('message') or '').strip() + if not user_message: + return jsonify({'error': 'message required'}), 400 + + chat_history = body.get('history') or [] + + # Import AI chat service + from services.ai_chat import chat as ai_chat_fn + + ai_response = ai_chat_fn(user_message, chat_history) + + search_results = [] + + try: + search_query = ai_response.get('search_query') + if search_query: + session = Session() + try: + query_terms = [q.strip() for q in search_query.split('|') if q.strip()] + for qt in query_terms[:3]: + # Search parts in public catalog + rows = session.execute(text(""" + SELECT p.id_part, p.oem_part_number, p.name_part, + pg.name_part_group, pc.name_part_category + FROM parts p + LEFT JOIN part_groups pg ON p.group_id = pg.id_part_group + LEFT JOIN part_categories pc ON pg.category_id = pc.id_part_category + WHERE p.name_part ILIKE :q + OR p.oem_part_number ILIKE :q + OR pg.name_part_group ILIKE :q + ORDER BY p.name_part + LIMIT 5 + """), {'q': f'%{qt}%'}).mappings().all() + + for r in rows: + search_results.append({ + 'id_part': r['id_part'], + 'oem_part_number': r['oem_part_number'], + 'name_part': r['name_part'], + 'name_es': r['name_part_group'], + 'category': r['name_part_category'], + 'source': 'catalog' + }) + finally: + session.close() + except Exception: + pass # search failure is non-fatal + + return jsonify({ + 'response': ai_response.get('message', ''), + 'search_results': search_results, + 'vehicle': ai_response.get('vehicle') + }) + # ============================================================================ # Public Catalog API — No auth required diff --git a/pos/blueprints/chat_bp.py b/pos/blueprints/chat_bp.py index c56fb8d..281dbb3 100644 --- a/pos/blueprints/chat_bp.py +++ b/pos/blueprints/chat_bp.py @@ -38,8 +38,13 @@ def chat(): except Exception: pass - # Call AI with inventory context - ai_response = ai_chat.chat(user_message, history, inventory_context=inventory_context) + # Check for image (base64) — use vision model if present + image_base64 = (body.get("image") or "").strip() + + if image_base64: + ai_response = ai_chat.chat_with_image(user_message, image_base64, history, inventory_context=inventory_context) + else: + ai_response = ai_chat.chat(user_message, history, inventory_context=inventory_context) search_results = [] vehicle_match = None diff --git a/pos/services/ai_chat.py b/pos/services/ai_chat.py index 10b2955..e1f6cf4 100644 --- a/pos/services/ai_chat.py +++ b/pos/services/ai_chat.py @@ -148,6 +148,176 @@ def get_inventory_context(tenant_conn, branch_id=None): cur.close() +VISION_MODEL = "google/gemma-3-27b-it:free" + +VISION_SYSTEM_PROMPT = """Eres un experto en identificación de autopartes. El usuario te envía una foto de una parte automotriz. +Tu trabajo es: +1. Identificar que parte es (nombre en español e inglés) +2. Describir características visibles (material, desgaste, marca si es visible) +3. Sugerir términos de búsqueda para encontrarla en un catálogo + +IMPORTANTE: Responde SIEMPRE en formato JSON válido con esta estructura: +{ + "message": "Descripción de la parte identificada en español", + "search_query": "término de búsqueda EN INGLÉS para el catálogo", + "vehicle": null +} + +Ejemplos de partes comunes: +- Pastillas/balatas de freno = "Brake Pad" +- Disco de freno = "Brake Disc" +- Filtro de aceite = "Oil Filter" +- Bujía = "Spark Plug" +- Amortiguador = "Shock Absorber" +- Bomba de agua = "Water Pump" +- Sensor de oxígeno = "Oxygen Sensor" +""" + + +def chat_with_image(user_message, image_base64, conversation_history=None, inventory_context=None): + """Send a message with an image to a vision-capable AI model. + + Args: + user_message: The user's chat message. + image_base64: Base64-encoded image (with or without data URL prefix). + conversation_history: Previous messages in the conversation. + inventory_context: Optional inventory summary string. + """ + _validate_model(VISION_MODEL) + + system_content = VISION_SYSTEM_PROMPT + if inventory_context: + system_content = VISION_SYSTEM_PROMPT + "\n\n" + inventory_context + + # Ensure proper data URL format + if image_base64 and not image_base64.startswith('data:'): + image_base64 = 'data:image/jpeg;base64,' + image_base64 + + messages = [{"role": "system", "content": system_content}] + if conversation_history: + # Only add text-only history messages + for h in conversation_history: + if isinstance(h.get('content'), str): + messages.append(h) + + # Build multimodal user message + user_content = [ + {"type": "image_url", "image_url": {"url": image_base64}}, + {"type": "text", "text": user_message or "Identifica esta parte automotriz y sugiere términos de búsqueda."} + ] + messages.append({"role": "user", "content": user_content}) + + import time + max_retries = 3 + + for attempt in range(max_retries): + try: + resp = requests.post( + OPENROUTER_URL, + headers={ + "Authorization": f"Bearer {OPENROUTER_API_KEY}", + "Content-Type": "application/json", + }, + json={ + "model": VISION_MODEL, + "messages": messages, + "max_tokens": 500, + "temperature": 0.3, + }, + timeout=30, + ) + if resp.status_code == 429: + wait = (attempt + 1) * 5 + if attempt < max_retries - 1: + time.sleep(wait) + continue + return {"message": "El asistente esta ocupado. Intenta de nuevo en unos segundos.", "search_query": None, "vehicle": None} + resp.raise_for_status() + data = resp.json() + content = data["choices"][0]["message"]["content"] + + try: + stripped = content.strip() + if stripped.startswith("```"): + lines = stripped.split("\n") + json_str = "\n".join(lines[1:-1]) + parsed = json.loads(json_str) + else: + parsed = json.loads(stripped) + return parsed + except (json.JSONDecodeError, IndexError): + return {"message": content, "search_query": None, "vehicle": None} + except Exception as e: + if attempt < max_retries - 1: + continue + return { + "message": f"Error al analizar imagen: {str(e)}", + "search_query": None, + "vehicle": None, + } + + +def classify_part(part_number): + """Ask AI to identify a part by its OEM number.""" + _validate_model(MODEL) + + prompt = ( + f"Given auto part number '{part_number}', identify:\n" + f"1) What part it is (name in Spanish)\n" + f"2) Which brand makes it\n" + f"3) What vehicle it fits\n" + f"4) What category it belongs to (e.g. Frenos, Motor, Suspensión, Eléctrico, Filtros, Transmisión)\n" + f"Respond ONLY in valid JSON: {{\"name\": \"...\", \"brand\": \"...\", \"vehicle\": \"...\", \"category\": \"...\"}}" + ) + + messages = [ + {"role": "system", "content": "Eres un experto en autopartes. Responde SOLO en JSON válido, sin texto adicional."}, + {"role": "user", "content": prompt} + ] + + import time + max_retries = 3 + + for attempt in range(max_retries): + try: + resp = requests.post( + OPENROUTER_URL, + headers={ + "Authorization": f"Bearer {OPENROUTER_API_KEY}", + "Content-Type": "application/json", + }, + json={ + "model": MODEL, + "messages": messages, + "max_tokens": 300, + "temperature": 0.2, + }, + timeout=15, + ) + if resp.status_code == 429: + wait = (attempt + 1) * 5 + if attempt < max_retries - 1: + time.sleep(wait) + continue + return {"name": None, "brand": None, "vehicle": None, "category": None} + resp.raise_for_status() + data = resp.json() + content = data["choices"][0]["message"]["content"] + + stripped = content.strip() + if stripped.startswith("```"): + lines = stripped.split("\n") + json_str = "\n".join(lines[1:-1]) + parsed = json.loads(json_str) + else: + parsed = json.loads(stripped) + return parsed + except Exception: + if attempt < max_retries - 1: + continue + return {"name": None, "brand": None, "vehicle": None, "category": None} + + def chat(user_message, conversation_history=None, inventory_context=None): """Send a message to the AI and get a response with search suggestions. diff --git a/pos/static/js/chat.js b/pos/static/js/chat.js index bb6463c..d7bb979 100644 --- a/pos/static/js/chat.js +++ b/pos/static/js/chat.js @@ -200,8 +200,9 @@ container.insertBefore(div, typing); scrollToBottom(); - // Send to AI as a text description (vision model placeholder) - const photoPrompt = 'El usuario envio una foto de una parte automotriz. Describe que parte podria ser y sugiere busquedas.'; + // Send image to AI vision model for real analysis + var imageData = ev.target.result; // full data URL + var photoPrompt = 'Identifica esta parte automotriz y sugiere terminos de busqueda.'; history.push({ role: 'user', content: photoPrompt }); if (history.length > 20) history.splice(0, 2); @@ -209,7 +210,7 @@ document.getElementById('chatSend').disabled = true; showTyping(true); - const token = getToken(); + var token = getToken(); fetch('/pos/api/chat', { method: 'POST', headers: { @@ -218,6 +219,7 @@ }, body: JSON.stringify({ message: photoPrompt, + image: imageData, history: history.slice(-10) }) }) diff --git a/pos/static/js/config.js b/pos/static/js/config.js index 2659a74..f066dfe 100644 --- a/pos/static/js/config.js +++ b/pos/static/js/config.js @@ -499,6 +499,21 @@ const Config = (() => { // Bind UI events bindEvents(); + // Kiosk mode toggle + var kioskToggle = document.getElementById('cfg-kiosk-mode'); + if (kioskToggle && window.NexusKiosk) { + kioskToggle.checked = window.NexusKiosk.isEnabled(); + kioskToggle.addEventListener('change', function () { + if (this.checked) { + window.NexusKiosk.enable(); + toast('Modo Kiosko activado'); + } else { + window.NexusKiosk.disable(); + toast('Modo Kiosko desactivado'); + } + }); + } + // Load real data in parallel loadBranches(); loadEmployees(); diff --git a/pos/static/js/inventory.js b/pos/static/js/inventory.js index 21030f4..1e8367e 100644 --- a/pos/static/js/inventory.js +++ b/pos/static/js/inventory.js @@ -121,7 +121,48 @@ function showCreateModal() { document.getElementById('createModal').classList.add('is-open'); + // Attach AI classification on part number blur + var pnInput = document.getElementById('newPartNumber'); + if (pnInput && !pnInput._classifyBound) { + pnInput._classifyBound = true; + pnInput.addEventListener('blur', function () { + var pn = this.value.trim(); + if (pn.length < 3) return; + var nameInput = document.getElementById('newName'); + // Only auto-classify if name is still empty + if (nameInput && nameInput.value.trim()) return; + classifyPartNumber(pn); + }); + } } + + function classifyPartNumber(partNumber) { + var resultEl = document.getElementById('createResult'); + resultEl.innerHTML = 'Consultando IA...'; + apiFetch(API + '/classify/' + encodeURIComponent(partNumber)).then(function (data) { + if (!data) return; + if (data.name) { + document.getElementById('newName').value = data.name; + } + if (data.brand) { + document.getElementById('newBrand').value = data.brand; + } + // Show suggestion label + var parts = []; + if (data.name) parts.push(data.name); + if (data.brand) parts.push(data.brand); + if (data.vehicle) parts.push(data.vehicle); + if (data.category) parts.push(data.category); + if (parts.length > 0) { + resultEl.innerHTML = 'Sugerido por IA: ' + esc(parts.join(' | ')) + ''; + } else { + resultEl.innerHTML = 'IA no pudo identificar este numero de parte'; + } + }).catch(function () { + resultEl.innerHTML = ''; + }); + } + function closeCreateModal() { document.getElementById('createModal').classList.remove('is-open'); document.getElementById('createResult').innerHTML = ''; diff --git a/pos/static/js/kiosk.js b/pos/static/js/kiosk.js new file mode 100644 index 0000000..1db5e33 --- /dev/null +++ b/pos/static/js/kiosk.js @@ -0,0 +1,168 @@ +// /home/Autopartes/pos/static/js/kiosk.js +// Kiosk mode for Nexus POS — fullscreen, wake lock, auto-login, no right-click + +(function () { + 'use strict'; + + var STORAGE_KEY = 'pos_kiosk_mode'; + + // ─── Detection ─── + function isPWA() { + return window.matchMedia('(display-mode: standalone)').matches + || window.navigator.standalone === true; + } + + function isCapacitor() { + return typeof window.Capacitor !== 'undefined' && window.Capacitor.isNativePlatform && window.Capacitor.isNativePlatform(); + } + + function isKioskEnabled() { + // Enabled if explicitly set in localStorage, or if running as PWA/Capacitor + var pref = localStorage.getItem(STORAGE_KEY); + if (pref === 'true') return true; + if (pref === 'false') return false; + // Auto-detect + return isPWA() || isCapacitor(); + } + + // ─── Fullscreen ─── + var fullscreenRequested = false; + + function requestFullscreen() { + if (fullscreenRequested) return; + var el = document.documentElement; + var fn = el.requestFullscreen || el.webkitRequestFullscreen || el.mozRequestFullScreen || el.msRequestFullscreen; + if (fn) { + fn.call(el).catch(function () { /* user may have denied */ }); + fullscreenRequested = true; + } + } + + // ─── Wake Lock ─── + var wakeLock = null; + + async function acquireWakeLock() { + if (!('wakeLock' in navigator)) return; + try { + wakeLock = await navigator.wakeLock.request('screen'); + wakeLock.addEventListener('release', function () { + wakeLock = null; + }); + } catch (e) { + // Wake lock may fail if tab not visible + } + } + + function releaseWakeLock() { + if (wakeLock) { + wakeLock.release(); + wakeLock = null; + } + } + + // Re-acquire wake lock when page becomes visible again + document.addEventListener('visibilitychange', function () { + if (document.visibilityState === 'visible' && isKioskEnabled() && !wakeLock) { + acquireWakeLock(); + } + }); + + // ─── Auto-login ─── + function tryAutoLogin() { + var token = localStorage.getItem('pos_token'); + if (!token) return; + + // Check if we are on the login page + var isLoginPage = window.location.pathname.indexOf('/pos/login') !== -1; + if (!isLoginPage) return; + + // Validate token by trying to decode expiry (JWT is base64) + try { + var parts = token.split('.'); + if (parts.length !== 3) return; + var payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))); + var exp = payload.exp; + if (exp && (exp * 1000) > Date.now()) { + // Token still valid — skip login + window.location.href = '/pos/'; + } + } catch (e) { + // Invalid token, stay on login + } + } + + // ─── Activate kiosk mode ─── + function activate() { + // Prevent navigation away + window.addEventListener('beforeunload', function (e) { + if (isKioskEnabled()) { + e.preventDefault(); + e.returnValue = ''; + } + }); + + // Disable context menu + document.addEventListener('contextmenu', function (e) { + if (isKioskEnabled()) { + e.preventDefault(); + } + }); + + // Request fullscreen on first user interaction + var interactionEvents = ['click', 'touchstart', 'keydown']; + function onFirstInteraction() { + if (isKioskEnabled()) { + requestFullscreen(); + acquireWakeLock(); + } + interactionEvents.forEach(function (evt) { + document.removeEventListener(evt, onFirstInteraction); + }); + } + interactionEvents.forEach(function (evt) { + document.addEventListener(evt, onFirstInteraction, { once: false }); + }); + + // Auto-login if on login page + tryAutoLogin(); + } + + // ─── Public API ─── + window.NexusKiosk = { + isEnabled: isKioskEnabled, + isPWA: isPWA, + isCapacitor: isCapacitor, + enable: function () { + localStorage.setItem(STORAGE_KEY, 'true'); + requestFullscreen(); + acquireWakeLock(); + }, + disable: function () { + localStorage.setItem(STORAGE_KEY, 'false'); + releaseWakeLock(); + if (document.fullscreenElement) { + document.exitFullscreen().catch(function () {}); + } + }, + toggle: function () { + if (isKioskEnabled()) { + window.NexusKiosk.disable(); + } else { + window.NexusKiosk.enable(); + } + return isKioskEnabled(); + } + }; + + // ─── Init ─── + if (isKioskEnabled()) { + activate(); + } + + // Also activate if preference changes (e.g. toggled from config) + window.addEventListener('storage', function (e) { + if (e.key === STORAGE_KEY && e.newValue === 'true') { + activate(); + } + }); +})(); diff --git a/pos/templates/config.html b/pos/templates/config.html index c4ad92b..54f48a0 100644 --- a/pos/templates/config.html +++ b/pos/templates/config.html @@ -1699,6 +1699,16 @@ +
+
+ Modo Kiosko + Pantalla completa, bloqueo de pantalla activa, sin menú contextual (ideal para tablet/mostrador) +
+ +
@@ -1917,6 +1927,7 @@ + diff --git a/pos/templates/login.html b/pos/templates/login.html index 929cc52..ea11672 100644 --- a/pos/templates/login.html +++ b/pos/templates/login.html @@ -1361,6 +1361,7 @@ + diff --git a/pos/templates/pos.html b/pos/templates/pos.html index 8c05a8b..af674a1 100644 --- a/pos/templates/pos.html +++ b/pos/templates/pos.html @@ -1480,6 +1480,7 @@ JAVASCRIPT ================================================================ --> +