feat(pos): add kiosk mode, AI vision, AI part classification, public chatbot (#19 #25 #30 #29)

- Kiosk mode: fullscreen, wake lock, auto-login, context menu block, PWA/Capacitor detection
- AI vision: camera photos analyzed by Gemma 3 27B vision model via OpenRouter
- AI part classification: auto-suggest name/brand/category when entering part number
- Public catalog chatbot: /api/chat endpoint with rate limiting, chat widget on catalog page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 04:18:37 +00:00
parent 4cc2c66208
commit 5a88d7c7ff
13 changed files with 942 additions and 5 deletions

View File

@@ -375,5 +375,9 @@
<script src="/catalog-public.js"></script>
<!-- AI Chat Widget -->
<link rel="stylesheet" href="/chat-public.css">
<script src="/chat-public.js"></script>
</body>
</html>

237
dashboard/chat-public.css Normal file
View File

@@ -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;
}
}

191
dashboard/chat-public.js Normal file
View File

@@ -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 = '&#x1F4AC;';
fab.setAttribute('aria-label', 'Abrir asistente IA');
// Chat panel
var panel = document.createElement('div');
panel.className = 'chat-panel';
panel.id = 'chatPanel';
panel.innerHTML =
'<div class="chat-header">' +
'<h3>Asistente — Buscar partes</h3>' +
'<button class="chat-header-close" id="chatClose" aria-label="Cerrar">&times;</button>' +
'</div>' +
'<div class="chat-messages" id="chatMessages">' +
'<div class="chat-msg ai">Hola, soy el asistente de Nexus Autoparts. Dime que refaccion buscas y te ayudo a encontrarla en el catalogo.</div>' +
'<div class="chat-typing" id="chatTyping"><span></span><span></span><span></span></div>' +
'</div>' +
'<div class="chat-input-area">' +
'<textarea class="chat-input" id="chatInput" placeholder="Ej: Balatas para Tsuru 2015..." rows="1"></textarea>' +
'<button class="chat-send-btn" id="chatSend" aria-label="Enviar">&#9654;</button>' +
'</div>';
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 =
'<div class="part-number">' + esc(partNum) + '</div>' +
'<div class="part-name">' + esc(name) + (brand ? ' <span style="color:var(--color-text-muted);">(' + esc(brand) + ')</span>' : '') + '</div>';
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();
}
})();

View File

@@ -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

View File

@@ -38,7 +38,12 @@ def chat():
except Exception:
pass
# Call AI with 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 = []

View File

@@ -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.

View File

@@ -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)
})
})

View File

@@ -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();

View File

@@ -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 = '<span style="color:var(--color-text-muted);">Consultando IA...</span>';
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 = '<span style="color:var(--color-accent);font-size:var(--text-caption);">Sugerido por IA: ' + esc(parts.join(' | ')) + '</span>';
} else {
resultEl.innerHTML = '<span style="color:var(--color-text-muted);font-size:var(--text-caption);">IA no pudo identificar este numero de parte</span>';
}
}).catch(function () {
resultEl.innerHTML = '';
});
}
function closeCreateModal() {
document.getElementById('createModal').classList.remove('is-open');
document.getElementById('createResult').innerHTML = '';

168
pos/static/js/kiosk.js Normal file
View File

@@ -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();
}
});
})();

View File

@@ -1699,6 +1699,16 @@
<span class="toggle__slider"></span>
</label>
</div>
<div class="toggle-row">
<div class="toggle-row__info">
<span class="toggle-row__label">Modo Kiosko</span>
<span class="toggle-row__desc">Pantalla completa, bloqueo de pantalla activa, sin menú contextual (ideal para tablet/mostrador)</span>
</div>
<label class="toggle">
<input type="checkbox" id="cfg-kiosk-mode" />
<span class="toggle__slider"></span>
</label>
</div>
</div>
</div>
@@ -1917,6 +1927,7 @@
<script src="/pos/static/js/i18n.js"></script>
<script src="/pos/static/js/app-init.js"></script>
<script src="/pos/static/js/sidebar.js"></script>
<script src="/pos/static/js/kiosk.js"></script>
<script src="/pos/static/js/config.js"></script>
<script src="/pos/static/js/sync-engine.js"></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>

View File

@@ -1361,6 +1361,7 @@
</script>
<script src="/pos/static/js/kiosk.js"></script>
<script src="/pos/static/js/sync-engine.js"></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>

View File

@@ -1480,6 +1480,7 @@
JAVASCRIPT
================================================================ -->
<script src="/pos/static/js/i18n.js"></script>
<script src="/pos/static/js/kiosk.js"></script>
<script src="/pos/static/js/app-init.js"></script>
<script src="/pos/static/js/push.js"></script>
<script src="/pos/static/js/printer.js"></script>