- 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:
@@ -375,5 +375,9 @@
|
|||||||
|
|
||||||
<script src="/catalog-public.js"></script>
|
<script src="/catalog-public.js"></script>
|
||||||
|
|
||||||
|
<!-- AI Chat Widget -->
|
||||||
|
<link rel="stylesheet" href="/chat-public.css">
|
||||||
|
<script src="/chat-public.js"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
237
dashboard/chat-public.css
Normal file
237
dashboard/chat-public.css
Normal 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
191
dashboard/chat-public.js
Normal 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 = '💬';
|
||||||
|
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">×</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">▶</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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -249,6 +249,97 @@ def admin_js():
|
|||||||
def enhanced_search_js():
|
def enhanced_search_js():
|
||||||
return send_from_directory('.', '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
|
# Public Catalog API — No auth required
|
||||||
|
|||||||
@@ -38,8 +38,13 @@ def chat():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Call AI with inventory context
|
# Check for image (base64) — use vision model if present
|
||||||
ai_response = ai_chat.chat(user_message, history, inventory_context=inventory_context)
|
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 = []
|
search_results = []
|
||||||
vehicle_match = None
|
vehicle_match = None
|
||||||
|
|||||||
@@ -148,6 +148,176 @@ def get_inventory_context(tenant_conn, branch_id=None):
|
|||||||
cur.close()
|
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):
|
def chat(user_message, conversation_history=None, inventory_context=None):
|
||||||
"""Send a message to the AI and get a response with search suggestions.
|
"""Send a message to the AI and get a response with search suggestions.
|
||||||
|
|
||||||
|
|||||||
@@ -200,8 +200,9 @@
|
|||||||
container.insertBefore(div, typing);
|
container.insertBefore(div, typing);
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
|
|
||||||
// Send to AI as a text description (vision model placeholder)
|
// Send image to AI vision model for real analysis
|
||||||
const photoPrompt = 'El usuario envio una foto de una parte automotriz. Describe que parte podria ser y sugiere busquedas.';
|
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 });
|
history.push({ role: 'user', content: photoPrompt });
|
||||||
if (history.length > 20) history.splice(0, 2);
|
if (history.length > 20) history.splice(0, 2);
|
||||||
|
|
||||||
@@ -209,7 +210,7 @@
|
|||||||
document.getElementById('chatSend').disabled = true;
|
document.getElementById('chatSend').disabled = true;
|
||||||
showTyping(true);
|
showTyping(true);
|
||||||
|
|
||||||
const token = getToken();
|
var token = getToken();
|
||||||
fetch('/pos/api/chat', {
|
fetch('/pos/api/chat', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -218,6 +219,7 @@
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
message: photoPrompt,
|
message: photoPrompt,
|
||||||
|
image: imageData,
|
||||||
history: history.slice(-10)
|
history: history.slice(-10)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -499,6 +499,21 @@ const Config = (() => {
|
|||||||
// Bind UI events
|
// Bind UI events
|
||||||
bindEvents();
|
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
|
// Load real data in parallel
|
||||||
loadBranches();
|
loadBranches();
|
||||||
loadEmployees();
|
loadEmployees();
|
||||||
|
|||||||
@@ -121,7 +121,48 @@
|
|||||||
|
|
||||||
function showCreateModal() {
|
function showCreateModal() {
|
||||||
document.getElementById('createModal').classList.add('is-open');
|
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() {
|
function closeCreateModal() {
|
||||||
document.getElementById('createModal').classList.remove('is-open');
|
document.getElementById('createModal').classList.remove('is-open');
|
||||||
document.getElementById('createResult').innerHTML = '';
|
document.getElementById('createResult').innerHTML = '';
|
||||||
|
|||||||
168
pos/static/js/kiosk.js
Normal file
168
pos/static/js/kiosk.js
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -1699,6 +1699,16 @@
|
|||||||
<span class="toggle__slider"></span>
|
<span class="toggle__slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1917,6 +1927,7 @@
|
|||||||
<script src="/pos/static/js/i18n.js"></script>
|
<script src="/pos/static/js/i18n.js"></script>
|
||||||
<script src="/pos/static/js/app-init.js"></script>
|
<script src="/pos/static/js/app-init.js"></script>
|
||||||
<script src="/pos/static/js/sidebar.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/config.js"></script>
|
||||||
<script src="/pos/static/js/sync-engine.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>
|
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||||
|
|||||||
@@ -1361,6 +1361,7 @@
|
|||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script src="/pos/static/js/kiosk.js"></script>
|
||||||
<script src="/pos/static/js/sync-engine.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>
|
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1480,6 +1480,7 @@
|
|||||||
JAVASCRIPT
|
JAVASCRIPT
|
||||||
================================================================ -->
|
================================================================ -->
|
||||||
<script src="/pos/static/js/i18n.js"></script>
|
<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/app-init.js"></script>
|
||||||
<script src="/pos/static/js/push.js"></script>
|
<script src="/pos/static/js/push.js"></script>
|
||||||
<script src="/pos/static/js/printer.js"></script>
|
<script src="/pos/static/js/printer.js"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user