feat(pos): WhatsApp Business API integration — send/receive messages, quotations

Add full WhatsApp Cloud API integration for Nexus POS:
- Service layer (whatsapp_service.py): send text, templates, quotations,
  order confirmations, stock alerts; process incoming webhooks with AI auto-reply
- Blueprint (whatsapp_bp.py): public webhook endpoints for Meta verification +
  incoming messages; authenticated endpoints for send, send-quote, conversations
- Conversation UI (whatsapp.html + whatsapp.js): split-panel messenger with
  conversation list, chat bubbles, send input, quote sending; both themes
- Migration v1.4: whatsapp_messages table with phone/direction/status indexes
- Config: WHATSAPP_TOKEN, WHATSAPP_PHONE_ID, WHATSAPP_VERIFY_TOKEN env vars
- Sidebar: WhatsApp nav item under Gestion with message-bubble icon
- Ready for Meta Business credentials (infrastructure complete, no API keys needed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-04 02:42:17 +00:00
parent 840790a4d0
commit c645bc03f3
9 changed files with 1451 additions and 0 deletions

View File

@@ -45,6 +45,12 @@ def create_app():
from blueprints.chat_bp import chat_bp from blueprints.chat_bp import chat_bp
app.register_blueprint(chat_bp) app.register_blueprint(chat_bp)
from blueprints.fleet_bp import fleet_bp
app.register_blueprint(fleet_bp)
from blueprints.whatsapp_bp import whatsapp_bp
app.register_blueprint(whatsapp_bp)
# Health check # Health check
@app.route('/pos/health') @app.route('/pos/health')
def health(): def health():
@@ -99,6 +105,14 @@ def create_app():
def pos_reports(): def pos_reports():
return render_template('reports.html') return render_template('reports.html')
@app.route('/pos/fleet')
def pos_fleet():
return render_template('fleet.html')
@app.route('/pos/whatsapp')
def pos_whatsapp():
return render_template('whatsapp.html')
@app.route('/pos/static/<path:filename>') @app.route('/pos/static/<path:filename>')
def pos_static(filename): def pos_static(filename):
return send_from_directory('static', filename) return send_from_directory('static', filename)

View File

@@ -0,0 +1,308 @@
# /home/Autopartes/pos/blueprints/whatsapp_bp.py
"""WhatsApp Business API blueprint.
Endpoints:
GET /pos/api/whatsapp/webhook — Meta webhook verification (public)
POST /pos/api/whatsapp/webhook — Receive incoming messages (public)
POST /pos/api/whatsapp/send — Send message to phone (auth)
POST /pos/api/whatsapp/send-quote/<id> — Send quotation via WhatsApp (auth)
GET /pos/api/whatsapp/conversations — List recent conversations (auth)
GET /pos/api/whatsapp/conversations/<phone> — Conversation history (auth)
"""
import logging
from flask import Blueprint, request, jsonify, g
from middleware import require_auth
from tenant_db import get_tenant_conn
from config import WHATSAPP_VERIFY_TOKEN
from services import whatsapp_service
logger = logging.getLogger(__name__)
whatsapp_bp = Blueprint("whatsapp", __name__, url_prefix="/pos/api/whatsapp")
# ── Webhook (PUBLIC — no auth, Meta must be able to call these) ──────────
@whatsapp_bp.route("/webhook", methods=["GET"])
def webhook_verify():
"""Meta webhook verification challenge.
Meta sends: hub.mode=subscribe&hub.verify_token=<token>&hub.challenge=<challenge>
We must return the challenge value if the verify_token matches.
"""
mode = request.args.get("hub.mode", "")
token = request.args.get("hub.verify_token", "")
challenge = request.args.get("hub.challenge", "")
if mode == "subscribe" and token == WHATSAPP_VERIFY_TOKEN:
logger.info("WhatsApp webhook verified")
return challenge, 200
else:
logger.warning("WhatsApp webhook verification failed — token mismatch")
return "Forbidden", 403
@whatsapp_bp.route("/webhook", methods=["POST"])
def webhook_receive():
"""Receive incoming WhatsApp messages from Meta.
Stores in DB (using tenant_id=1 as default for webhook context)
and triggers auto-response via AI chatbot if available.
"""
payload = request.get_json(silent=True)
if not payload:
return "OK", 200
result = whatsapp_service.process_incoming(payload)
# Store incoming message in DB if it was a real message
if result.get("type") == "message" and result.get("phone"):
_store_message(
phone=result["phone"],
direction="incoming",
message_text=result.get("text", ""),
message_type=result.get("message_type", "text"),
wa_message_id=result.get("wa_message_id", ""),
status="received",
)
# Store auto-reply if one was generated
if result.get("auto_reply"):
_store_message(
phone=result["phone"],
direction="outgoing",
message_text=result["auto_reply"],
message_type="text",
wa_message_id=result.get("auto_reply_wa_id", ""),
status="sent",
)
# Always return 200 to Meta (otherwise they retry)
return "OK", 200
def _store_message(phone, direction, message_text, message_type="text",
wa_message_id="", status="sent", related_type=None,
related_id=None, tenant_id=None):
"""Store a WhatsApp message in the tenant DB."""
tid = tenant_id or getattr(g, "tenant_id", None)
if not tid:
# Webhook context — try tenant 1 as default
tid = 1
try:
conn = get_tenant_conn(tid)
cur = conn.cursor()
cur.execute("""
INSERT INTO whatsapp_messages
(phone, direction, message_text, message_type, wa_message_id,
status, related_type, related_id)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", (phone, direction, message_text, message_type, wa_message_id,
status, related_type, related_id))
msg_id = cur.fetchone()[0]
conn.commit()
cur.close()
conn.close()
return msg_id
except Exception:
logger.exception("Failed to store WhatsApp message")
return None
# ── Authenticated endpoints ──────────────────────────────────────────────
@whatsapp_bp.route("/send", methods=["POST"])
@require_auth()
def send_message():
"""Send a text message to a phone number."""
body = request.get_json(force=True)
phone = (body.get("phone") or "").strip()
text = (body.get("message") or "").strip()
if not phone or not text:
return jsonify({"error": "phone and message required"}), 400
result = whatsapp_service.send_message(phone, text)
if "error" not in result:
_store_message(
phone=phone,
direction="outgoing",
message_text=text,
wa_message_id=result.get("wa_message_id", ""),
status="sent",
tenant_id=g.tenant_id,
)
return jsonify(result)
@whatsapp_bp.route("/send-quote/<int:quote_id>", methods=["POST"])
@require_auth()
def send_quote(quote_id):
"""Send a quotation via WhatsApp.
Expects JSON body with 'phone'. Looks up the quote from the DB.
"""
body = request.get_json(force=True)
phone = (body.get("phone") or "").strip()
if not phone:
return jsonify({"error": "phone required"}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
try:
# Load quotation header
cur.execute("""
SELECT id, folio, customer_id, subtotal, tax, total, status, notes
FROM sales
WHERE id = %s AND sale_type = 'quotation'
""", (quote_id,))
quote = cur.fetchone()
if not quote:
return jsonify({"error": "Quotation not found"}), 404
# Load quotation items
cur.execute("""
SELECT si.quantity, si.unit_price, si.discount_pct, si.subtotal,
i.name, i.part_number
FROM sale_items si
JOIN inventory i ON i.id = si.inventory_id
WHERE si.sale_id = %s
""", (quote_id,))
items = []
for r in cur.fetchall():
items.append({
"quantity": r[0],
"unit_price": float(r[1]) if r[1] else 0,
"name": f"{r[4]} ({r[5]})" if r[5] else r[4],
})
# Load tenant business name
from tenant_db import get_master_conn
master = get_master_conn()
mcur = master.cursor()
mcur.execute("SELECT name FROM tenants WHERE id = %s", (g.tenant_id,))
trow = mcur.fetchone()
biz_name = trow[0] if trow else "Nexus Autoparts"
mcur.close()
master.close()
quote_data = {
"business_name": biz_name,
"quote_number": quote[1] or str(quote[0]),
"items": items,
"subtotal": float(quote[3]) if quote[3] else 0,
"tax": float(quote[4]) if quote[4] else 0,
"total": float(quote[5]) if quote[5] else 0,
"validity_days": 7,
"notes": quote[7],
}
result = whatsapp_service.send_quote(phone, quote_data)
if "error" not in result:
# Build the same formatted text the service would have sent
_store_message(
phone=phone,
direction="outgoing",
message_text=f"[Cotizacion #{quote_data['quote_number']}] Total: ${quote_data['total']:,.2f}",
wa_message_id=result.get("wa_message_id", ""),
status="sent",
related_type="quotation",
related_id=quote_id,
tenant_id=g.tenant_id,
)
return jsonify(result)
finally:
cur.close()
conn.close()
@whatsapp_bp.route("/conversations", methods=["GET"])
@require_auth()
def list_conversations():
"""List recent conversations (grouped by phone number, latest message first)."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
try:
cur.execute("""
SELECT DISTINCT ON (phone)
phone, direction, message_text, message_type, status, created_at
FROM whatsapp_messages
ORDER BY phone, created_at DESC
""")
rows = cur.fetchall()
conversations = []
for r in rows:
conversations.append({
"phone": r[0],
"last_direction": r[1],
"last_message": (r[2] or "")[:120],
"last_type": r[3],
"last_status": r[4],
"last_at": r[5].isoformat() if r[5] else None,
})
# Sort by most recent first
conversations.sort(key=lambda c: c["last_at"] or "", reverse=True)
return jsonify({"conversations": conversations})
finally:
cur.close()
conn.close()
@whatsapp_bp.route("/conversations/<phone>", methods=["GET"])
@require_auth()
def conversation_history(phone):
"""Get full message history with a specific phone number."""
limit = min(int(request.args.get("limit", 100)), 500)
offset = int(request.args.get("offset", 0))
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
try:
cur.execute("""
SELECT id, phone, direction, message_text, message_type,
wa_message_id, status, related_type, related_id, created_at
FROM whatsapp_messages
WHERE phone = %s
ORDER BY created_at ASC
LIMIT %s OFFSET %s
""", (phone, limit, offset))
messages = []
for r in cur.fetchall():
messages.append({
"id": r[0],
"phone": r[1],
"direction": r[2],
"message_text": r[3],
"message_type": r[4],
"wa_message_id": r[5],
"status": r[6],
"related_type": r[7],
"related_id": r[8],
"created_at": r[9].isoformat() if r[9] else None,
})
# Also get total count
cur.execute("SELECT COUNT(*) FROM whatsapp_messages WHERE phone = %s", (phone,))
total = cur.fetchone()[0]
return jsonify({"messages": messages, "total": total})
finally:
cur.close()
conn.close()

View File

@@ -24,3 +24,8 @@ OPENROUTER_API_KEY = os.environ.get(
"OPENROUTER_API_KEY", "OPENROUTER_API_KEY",
"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" "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"
) )
# WhatsApp Business Cloud API
WHATSAPP_TOKEN = os.environ.get("WHATSAPP_TOKEN", "")
WHATSAPP_PHONE_ID = os.environ.get("WHATSAPP_PHONE_ID", "")
WHATSAPP_VERIFY_TOKEN = os.environ.get("WHATSAPP_VERIFY_TOKEN", "nexus-wa-verify-2026")

View File

@@ -14,6 +14,8 @@ MIGRATIONS_DIR = os.path.dirname(os.path.abspath(__file__))
MIGRATIONS = { MIGRATIONS = {
'v1.0': 'v1.0_initial.sql', 'v1.0': 'v1.0_initial.sql',
'v1.1': 'v1.1_pos_tables.sql', 'v1.1': 'v1.1_pos_tables.sql',
'v1.3': 'v1.3_fleet.sql',
'v1.4': 'v1.4_whatsapp.sql',
} }

View File

@@ -0,0 +1,18 @@
-- v1.4_whatsapp.sql — WhatsApp message history
-- Applied per-tenant database
CREATE TABLE IF NOT EXISTS whatsapp_messages (
id SERIAL PRIMARY KEY,
phone VARCHAR(20) NOT NULL,
direction VARCHAR(10) NOT NULL, -- 'incoming' or 'outgoing'
message_text TEXT,
message_type VARCHAR(20) DEFAULT 'text',
wa_message_id VARCHAR(100),
status VARCHAR(20) DEFAULT 'sent',
related_type VARCHAR(50), -- 'quotation', 'sale', 'alert'
related_id INTEGER,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_wa_messages_phone ON whatsapp_messages(phone);
CREATE INDEX IF NOT EXISTS idx_wa_messages_created ON whatsapp_messages(created_at DESC);

View File

@@ -0,0 +1,307 @@
"""WhatsApp Business API service via Meta Cloud API.
Handles sending and receiving WhatsApp messages.
Uses Meta's Cloud API (free for 1,000 service conversations/month).
Setup:
1. Create a Meta Business account at business.facebook.com
2. Create a WhatsApp Business app at developers.facebook.com
3. Get a permanent access token and Phone Number ID
4. Set environment variables:
WHATSAPP_TOKEN=<your-permanent-token>
WHATSAPP_PHONE_ID=<your-phone-number-id>
WHATSAPP_VERIFY_TOKEN=<your-chosen-verify-string>
5. Configure the webhook URL in Meta Developer Console:
https://<your-domain>/pos/api/whatsapp/webhook
"""
import logging
import requests
from config import WHATSAPP_TOKEN, WHATSAPP_PHONE_ID
logger = logging.getLogger(__name__)
API_VERSION = "v18.0"
BASE_URL = f"https://graph.facebook.com/{API_VERSION}"
def _headers():
return {
"Authorization": f"Bearer {WHATSAPP_TOKEN}",
"Content-Type": "application/json",
}
def is_configured():
"""Return True if WhatsApp credentials are set."""
return bool(WHATSAPP_TOKEN and WHATSAPP_PHONE_ID)
# ── Sending ──────────────────────────────────────────────────────────────
def send_message(to_phone, message_text):
"""Send a plain-text message via WhatsApp Cloud API.
Args:
to_phone: recipient phone in E.164 format (e.g. '5215512345678')
message_text: text content
Returns:
dict with 'wa_message_id' on success or 'error' on failure.
"""
if not is_configured():
return {"error": "WhatsApp not configured — set WHATSAPP_TOKEN and WHATSAPP_PHONE_ID"}
url = f"{BASE_URL}/{WHATSAPP_PHONE_ID}/messages"
payload = {
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": to_phone,
"type": "text",
"text": {"preview_url": False, "body": message_text},
}
try:
resp = requests.post(url, json=payload, headers=_headers(), timeout=15)
data = resp.json()
if resp.status_code in (200, 201):
wa_id = data.get("messages", [{}])[0].get("id", "")
return {"wa_message_id": wa_id}
else:
err = data.get("error", {}).get("message", resp.text)
logger.error("WhatsApp send failed: %s", err)
return {"error": err}
except Exception as e:
logger.exception("WhatsApp send exception")
return {"error": str(e)}
def send_template(to_phone, template_name, parameters=None):
"""Send a pre-approved template message (for initiating conversations).
Args:
to_phone: E.164 phone number
template_name: approved template name (e.g. 'order_confirmation')
parameters: list of {"type": "text", "text": "value"} dicts
"""
if not is_configured():
return {"error": "WhatsApp not configured"}
url = f"{BASE_URL}/{WHATSAPP_PHONE_ID}/messages"
components = []
if parameters:
components.append({
"type": "body",
"parameters": parameters,
})
payload = {
"messaging_product": "whatsapp",
"to": to_phone,
"type": "template",
"template": {
"name": template_name,
"language": {"code": "es_MX"},
"components": components,
},
}
try:
resp = requests.post(url, json=payload, headers=_headers(), timeout=15)
data = resp.json()
if resp.status_code in (200, 201):
wa_id = data.get("messages", [{}])[0].get("id", "")
return {"wa_message_id": wa_id}
else:
err = data.get("error", {}).get("message", resp.text)
logger.error("WhatsApp template send failed: %s", err)
return {"error": err}
except Exception as e:
logger.exception("WhatsApp template send exception")
return {"error": str(e)}
def send_quote(to_phone, quote_data):
"""Send a formatted quotation to a customer.
Args:
to_phone: E.164 phone number
quote_data: dict with keys: business_name, quote_number, items[], subtotal,
tax, total, validity_days, notes (optional)
"""
biz = quote_data.get("business_name", "Nexus Autoparts")
qnum = quote_data.get("quote_number", "")
items = quote_data.get("items", [])
subtotal = quote_data.get("subtotal", 0)
tax = quote_data.get("tax", 0)
total = quote_data.get("total", 0)
validity = quote_data.get("validity_days", 7)
lines = [
f"*{biz}*",
f"Cotizacion #{qnum}",
"─────────────────",
]
for it in items:
name = it.get("name", "Articulo")
qty = it.get("quantity", 1)
price = it.get("unit_price", 0)
lines.append(f" {qty}x {name} — ${price:,.2f}")
lines.append("─────────────────")
lines.append(f"Subtotal: ${subtotal:,.2f}")
lines.append(f"IVA: ${tax:,.2f}")
lines.append(f"*Total: ${total:,.2f}*")
lines.append(f"\nVigencia: {validity} dias")
notes = quote_data.get("notes")
if notes:
lines.append(f"Nota: {notes}")
return send_message(to_phone, "\n".join(lines))
def send_order_confirmation(to_phone, sale_data):
"""Send order / sale confirmation.
Args:
to_phone: E.164 phone number
sale_data: dict with keys: business_name, folio, items[], total, payment_method
"""
biz = sale_data.get("business_name", "Nexus Autoparts")
folio = sale_data.get("folio", "")
total = sale_data.get("total", 0)
method = sale_data.get("payment_method", "efectivo")
lines = [
f"*{biz}*",
f"Confirmacion de venta #{folio}",
"─────────────────",
]
for it in sale_data.get("items", []):
name = it.get("name", "Articulo")
qty = it.get("quantity", 1)
lines.append(f" {qty}x {name}")
lines.append("─────────────────")
lines.append(f"*Total: ${total:,.2f}*")
lines.append(f"Pago: {method}")
lines.append("\nGracias por su compra!")
return send_message(to_phone, "\n".join(lines))
def send_stock_alert(to_phone, alert_data):
"""Send stock alert to owner/manager.
Args:
to_phone: E.164 phone
alert_data: dict with keys: items[] (each has name, current_stock, min_stock)
"""
lines = [
"*ALERTA DE INVENTARIO*",
"Los siguientes articulos estan bajos en stock:",
"",
]
for it in alert_data.get("items", []):
name = it.get("name", "?")
current = it.get("current_stock", 0)
minimum = it.get("min_stock", 0)
lines.append(f" {name}: {current} uds (min: {minimum})")
lines.append("\nRevisa el inventario en tu POS.")
return send_message(to_phone, "\n".join(lines))
# ── Incoming webhook processing ──────────────────────────────────────────
def process_incoming(webhook_data):
"""Process incoming WhatsApp webhook message.
Routes to AI chatbot for auto-response or queues for employee.
Args:
webhook_data: the full webhook payload from Meta
Returns:
dict with parsed message info: phone, text, wa_message_id, response (if auto-replied)
"""
result = {"handled": False}
try:
entry = webhook_data.get("entry", [])
if not entry:
return result
changes = entry[0].get("changes", [])
if not changes:
return result
value = changes[0].get("value", {})
# Handle status updates (delivered, read, etc.)
statuses = value.get("statuses", [])
if statuses:
return {
"type": "status_update",
"statuses": statuses,
"handled": True,
}
messages = value.get("messages", [])
if not messages:
return result
msg = messages[0]
phone = msg.get("from", "")
wa_message_id = msg.get("id", "")
msg_type = msg.get("type", "text")
text = ""
if msg_type == "text":
text = msg.get("text", {}).get("body", "")
elif msg_type == "interactive":
interactive = msg.get("interactive", {})
if interactive.get("type") == "button_reply":
text = interactive.get("button_reply", {}).get("title", "")
elif interactive.get("type") == "list_reply":
text = interactive.get("list_reply", {}).get("title", "")
else:
text = f"[{msg_type} message]"
# Contact info
contacts = value.get("contacts", [])
contact_name = ""
if contacts:
profile = contacts[0].get("profile", {})
contact_name = profile.get("name", "")
result = {
"type": "message",
"phone": phone,
"contact_name": contact_name,
"text": text,
"message_type": msg_type,
"wa_message_id": wa_message_id,
"handled": True,
}
# Attempt AI auto-response if the ai_chat service is available
try:
from services.ai_chat import chat as ai_chat_fn
ai_result = ai_chat_fn(text, [])
ai_reply = ai_result.get("message", "")
if ai_reply:
send_result = send_message(phone, ai_reply)
result["auto_reply"] = ai_reply
result["auto_reply_wa_id"] = send_result.get("wa_message_id", "")
except Exception:
# AI not available — message stays in queue for employee
logger.debug("AI auto-reply not available, message queued for employee")
return result
except Exception as e:
logger.exception("Error processing incoming WhatsApp message")
return {"error": str(e), "handled": False}

View File

@@ -24,6 +24,8 @@
{ name: 'Facturación', href: '/pos/invoicing', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>' }, { name: 'Facturación', href: '/pos/invoicing', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>' },
{ name: 'Contabilidad', href: '/pos/accounting', icon: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>' }, { name: 'Contabilidad', href: '/pos/accounting', icon: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>' },
{ name: 'Reportes', href: '/pos/reports', icon: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>' }, { name: 'Reportes', href: '/pos/reports', icon: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>' },
{ name: 'Flotillas', href: '/pos/fleet', icon: '<path d="M1 13h22M1 13l2-6h6l2 6M9 7h6l2 6M15 13l2-6M5 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4zM19 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"/>' },
{ name: 'WhatsApp', href: '/pos/whatsapp', icon: '<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>' },
]}, ]},
{ label: 'Sistema', items: [ { label: 'Sistema', items: [
{ name: 'Configuración', href: '/pos/config', icon: '<circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>' }, { name: 'Configuración', href: '/pos/config', icon: '<circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>' },

257
pos/static/js/whatsapp.js Normal file
View File

@@ -0,0 +1,257 @@
/**
* whatsapp.js — WhatsApp Business conversation UI
*
* Left panel: conversation list (phone numbers + last message preview)
* Right panel: chat view with message bubbles
* Bottom: text input + send button
* Toolbar: "Enviar Cotizacion" button
*/
(function () {
'use strict';
var token = localStorage.getItem('pos_token');
if (!token) { window.location.href = '/pos/login'; return; }
var API = '/pos/api/whatsapp';
var activePhone = null;
var pollTimer = null;
// ── Helpers ──────────────────────────────────────────────────────────
function authHeaders() {
return { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
}
function api(method, path, body) {
var opts = { method: method, headers: authHeaders() };
if (body) opts.body = JSON.stringify(body);
return fetch(API + path, opts).then(function (r) {
if (r.status === 401) { window.location.href = '/pos/login'; }
return r.json();
});
}
function escHtml(s) {
var d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}
function fmtTime(iso) {
if (!iso) return '';
var d = new Date(iso);
var now = new Date();
var isToday = d.toDateString() === now.toDateString();
if (isToday) {
return d.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' });
}
return d.toLocaleDateString('es-MX', { day: '2-digit', month: 'short' }) +
' ' + d.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' });
}
function fmtPhone(phone) {
if (!phone) return '';
// Format Mexican numbers nicely: 52 1 55 1234 5678
if (phone.length === 13 && phone.startsWith('521')) {
return '+52 1 ' + phone.slice(3, 5) + ' ' + phone.slice(5, 9) + ' ' + phone.slice(9);
}
if (phone.length === 12 && phone.startsWith('52')) {
return '+52 ' + phone.slice(2, 4) + ' ' + phone.slice(4, 8) + ' ' + phone.slice(8);
}
return '+' + phone;
}
// ── DOM refs ────────────────────────────────────────────────────────
var convList = document.getElementById('convList');
var chatMessages = document.getElementById('chatMessages');
var chatHeader = document.getElementById('chatHeaderPhone');
var chatInput = document.getElementById('chatInput');
var sendBtn = document.getElementById('sendBtn');
var newChatBtn = document.getElementById('newChatBtn');
var emptyState = document.getElementById('emptyState');
var chatPanel = document.getElementById('chatPanel');
var statusDot = document.getElementById('statusDot');
var statusText = document.getElementById('statusText');
// ── Load conversations ──────────────────────────────────────────────
function loadConversations() {
api('GET', '/conversations').then(function (data) {
var convs = data.conversations || [];
if (convs.length === 0) {
convList.innerHTML = '<div class="conv-empty">No hay conversaciones</div>';
return;
}
var html = '';
convs.forEach(function (c) {
var isActive = c.phone === activePhone;
var dirIcon = c.last_direction === 'outgoing' ? '&rarr; ' : '';
html += '<div class="conv-item' + (isActive ? ' is-active' : '') + '" data-phone="' + escHtml(c.phone) + '">'
+ '<div class="conv-item__phone">' + escHtml(fmtPhone(c.phone)) + '</div>'
+ '<div class="conv-item__preview">' + dirIcon + escHtml(c.last_message) + '</div>'
+ '<div class="conv-item__time">' + fmtTime(c.last_at) + '</div>'
+ '</div>';
});
convList.innerHTML = html;
// Click handlers
convList.querySelectorAll('.conv-item').forEach(function (el) {
el.addEventListener('click', function () {
openConversation(el.getAttribute('data-phone'));
});
});
}).catch(function () {
convList.innerHTML = '<div class="conv-empty">Error cargando conversaciones</div>';
});
}
// ── Open a conversation ─────────────────────────────────────────────
function openConversation(phone) {
activePhone = phone;
chatHeader.textContent = fmtPhone(phone);
emptyState.style.display = 'none';
chatPanel.style.display = 'flex';
// Highlight in list
convList.querySelectorAll('.conv-item').forEach(function (el) {
el.classList.toggle('is-active', el.getAttribute('data-phone') === phone);
});
loadMessages(phone);
startPolling();
}
function loadMessages(phone) {
api('GET', '/conversations/' + encodeURIComponent(phone)).then(function (data) {
var msgs = data.messages || [];
renderMessages(msgs);
});
}
function renderMessages(msgs) {
var html = '';
msgs.forEach(function (m) {
var cls = m.direction === 'outgoing' ? 'msg-bubble--out' : 'msg-bubble--in';
var statusBadge = '';
if (m.direction === 'outgoing' && m.status) {
statusBadge = '<span class="msg-status">' + escHtml(m.status) + '</span>';
}
html += '<div class="msg-bubble ' + cls + '">'
+ '<div class="msg-bubble__text">' + escHtml(m.message_text).replace(/\n/g, '<br>') + '</div>'
+ '<div class="msg-bubble__meta">' + fmtTime(m.created_at) + ' ' + statusBadge + '</div>'
+ '</div>';
});
chatMessages.innerHTML = html || '<div class="chat-empty">Sin mensajes</div>';
chatMessages.scrollTop = chatMessages.scrollHeight;
}
// ── Send message ────────────────────────────────────────────────────
function doSend() {
var text = chatInput.value.trim();
if (!text || !activePhone) return;
chatInput.value = '';
sendBtn.disabled = true;
api('POST', '/send', { phone: activePhone, message: text }).then(function (res) {
sendBtn.disabled = false;
if (res.error) {
alert('Error: ' + res.error);
} else {
loadMessages(activePhone);
loadConversations();
}
}).catch(function () {
sendBtn.disabled = false;
alert('Error de red al enviar mensaje');
});
}
sendBtn.addEventListener('click', doSend);
chatInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
doSend();
}
});
// ── New conversation ────────────────────────────────────────────────
newChatBtn.addEventListener('click', function () {
var phone = prompt('Numero de telefono (formato: 5215512345678):');
if (phone) {
phone = phone.replace(/[\s\-\+\(\)]/g, '');
openConversation(phone);
loadConversations();
}
});
// ── Send quotation modal ────────────────────────────────────────────
var quoteBtn = document.getElementById('sendQuoteBtn');
if (quoteBtn) {
quoteBtn.addEventListener('click', function () {
if (!activePhone) { alert('Selecciona una conversacion primero'); return; }
var quoteId = prompt('ID de la cotizacion a enviar:');
if (!quoteId) return;
api('POST', '/send-quote/' + quoteId, { phone: activePhone }).then(function (res) {
if (res.error) {
alert('Error: ' + res.error);
} else {
loadMessages(activePhone);
loadConversations();
}
});
});
}
// ── Polling for new messages ────────────────────────────────────────
function startPolling() {
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(function () {
if (activePhone) loadMessages(activePhone);
loadConversations();
}, 10000); // every 10s
}
// ── Connection status indicator ─────────────────────────────────────
function checkStatus() {
// Check if we can reach the API (proxy for "connected")
fetch(API + '/conversations', { headers: authHeaders() })
.then(function (r) {
if (r.ok) {
statusDot.className = 'status-dot status-dot--ok';
statusText.textContent = 'Conectado';
} else {
statusDot.className = 'status-dot status-dot--warn';
statusText.textContent = 'Sin credenciales';
}
})
.catch(function () {
statusDot.className = 'status-dot status-dot--error';
statusText.textContent = 'Desconectado';
});
}
// ── Init ────────────────────────────────────────────────────────────
loadConversations();
checkStatus();
setInterval(checkStatus, 30000);
// ── User info for sidebar ───────────────────────────────────────────
try {
var payload = JSON.parse(atob(token.split('.')[1]));
window.POS_USER = {
name: payload.name || 'Usuario',
roleLabel: (payload.role || '').charAt(0).toUpperCase() + (payload.role || '').slice(1),
initials: (payload.name || 'U').split(' ').map(function(w){return w[0]}).join('').slice(0,2).toUpperCase()
};
} catch(e) {}
})();

538
pos/templates/whatsapp.html Normal file
View File

@@ -0,0 +1,538 @@
<!DOCTYPE html>
<html lang="es" data-theme="industrial">
<head>
<script>/*pos_theme_early*/(function(){var t=localStorage.getItem("pos_theme")||"industrial";document.documentElement.setAttribute("data-theme",t);})()</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WhatsApp — Nexus Autoparts POS</title>
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
<style>
/* =========================================================================
BASE RESET & SHELL
========================================================================= */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
}
body {
font-family: var(--font-body);
font-size: var(--text-body-sm);
color: var(--color-text-primary);
background-color: var(--color-bg-base);
transition: background-color var(--duration-normal) var(--ease-in-out),
color var(--duration-normal) var(--ease-in-out);
overflow: hidden;
}
[data-theme="modern"] body {
background-image: radial-gradient(
circle,
var(--dot-grid-color) 1px,
transparent 1px
);
background-size: var(--dot-grid-size) var(--dot-grid-size);
}
/* =========================================================================
LAYOUT: sidebar offset + full-height messenger
========================================================================= */
.page-shell {
display: flex;
height: 100vh;
overflow: hidden;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* =========================================================================
TOP BAR
========================================================================= */
.top-bar {
display: flex;
align-items: center;
justify-content: space-between;
height: 48px;
padding: 0 var(--space-4);
background: var(--color-bg-elevated);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.top-bar__title {
font-family: var(--font-heading);
font-size: var(--text-body);
font-weight: var(--font-weight-bold);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--color-text-primary);
display: flex;
align-items: center;
gap: var(--space-2);
}
.top-bar__title svg {
color: #25D366;
}
.top-bar__actions {
display: flex;
gap: var(--space-2);
align-items: center;
}
.top-bar__status {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--text-caption);
color: var(--color-text-muted);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-text-muted);
}
.status-dot--ok { background: var(--color-success); }
.status-dot--warn { background: var(--color-warning); }
.status-dot--error { background: var(--color-error); }
/* =========================================================================
MESSENGER LAYOUT: conversations list + chat panel
========================================================================= */
.messenger {
flex: 1;
display: flex;
overflow: hidden;
}
/* ─── Left panel: conversation list ──────────────────────────────── */
.conv-panel {
width: 320px;
min-width: 260px;
display: flex;
flex-direction: column;
border-right: 1px solid var(--color-border);
background: var(--color-bg-elevated);
}
.conv-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.conv-panel__title {
font-weight: var(--font-weight-semibold);
font-size: var(--text-body-sm);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
}
.conv-panel__list {
flex: 1;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb, #444) var(--scrollbar-track, #222);
}
.conv-item {
display: flex;
flex-direction: column;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border);
cursor: pointer;
transition: background var(--duration-fast) var(--ease-in-out);
}
.conv-item:hover {
background: var(--color-surface-2, rgba(255,255,255,0.04));
}
.conv-item.is-active {
background: var(--color-primary-muted, rgba(245,166,35,0.12));
border-left: 3px solid var(--color-primary);
}
.conv-item__phone {
font-weight: var(--font-weight-semibold);
font-size: var(--text-body-sm);
color: var(--color-text-primary);
}
.conv-item__preview {
font-size: var(--text-caption);
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.conv-item__time {
font-size: 0.6875rem;
color: var(--color-text-muted);
margin-top: 2px;
}
.conv-empty {
padding: var(--space-6);
text-align: center;
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
/* ─── Right panel: chat view ─────────────────────────────────────── */
.chat-panel {
flex: 1;
display: flex;
flex-direction: column;
background: var(--color-bg-base);
}
.chat-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
background: var(--color-bg-elevated);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.chat-panel__phone {
font-weight: var(--font-weight-semibold);
font-size: var(--text-body);
color: var(--color-text-primary);
}
.chat-panel__actions {
display: flex;
gap: var(--space-2);
}
.chat-panel__messages {
flex: 1;
overflow-y: auto;
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-2);
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb, #444) var(--scrollbar-track, #222);
}
/* ─── Message bubbles ────────────────────────────────────────────── */
.msg-bubble {
max-width: 70%;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md, 8px);
word-wrap: break-word;
line-height: 1.5;
}
.msg-bubble--in {
align-self: flex-start;
background: var(--color-surface-2, rgba(255,255,255,0.06));
color: var(--color-text-primary);
border-bottom-left-radius: var(--radius-xs, 2px);
}
[data-theme="modern"] .msg-bubble--in {
background: var(--color-neutral-100);
}
.msg-bubble--out {
align-self: flex-end;
background: var(--color-primary-muted, rgba(245,166,35,0.15));
color: var(--color-text-primary);
border-bottom-right-radius: var(--radius-xs, 2px);
}
[data-theme="modern"] .msg-bubble--out {
background: #dcf8c6;
color: #111;
}
.msg-bubble__text {
font-size: var(--text-body-sm);
}
.msg-bubble__meta {
font-size: 0.6875rem;
color: var(--color-text-muted);
margin-top: 4px;
text-align: right;
}
.msg-status {
display: inline-block;
font-size: 0.625rem;
padding: 1px 4px;
border-radius: var(--radius-xs, 2px);
background: var(--color-surface-3, rgba(255,255,255,0.04));
color: var(--color-text-muted);
margin-left: 4px;
}
.chat-empty {
text-align: center;
color: var(--color-text-muted);
padding: var(--space-8);
}
/* ─── Chat input bar ─────────────────────────────────────────────── */
.chat-input-bar {
display: flex;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--color-border);
background: var(--color-bg-elevated);
flex-shrink: 0;
}
.chat-input-bar textarea {
flex: 1;
resize: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-md, 8px);
background: var(--color-bg-base);
color: var(--color-text-primary);
padding: var(--space-2) var(--space-3);
font-family: var(--font-body);
font-size: var(--text-body-sm);
line-height: 1.4;
min-height: 40px;
max-height: 100px;
}
.chat-input-bar textarea:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-muted, rgba(245,166,35,0.2));
}
.chat-input-bar textarea::placeholder {
color: var(--color-text-muted);
}
/* ─── Buttons ────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 4px);
background: var(--color-bg-elevated);
color: var(--color-text-primary);
font-family: var(--font-body);
font-size: var(--text-body-sm);
font-weight: var(--font-weight-semibold);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-in-out);
white-space: nowrap;
}
.btn:hover {
background: var(--color-surface-2, rgba(255,255,255,0.06));
}
.btn--primary {
background: var(--color-primary);
color: var(--color-text-inverse, #fff);
border-color: var(--color-primary);
}
.btn--primary:hover {
opacity: 0.9;
}
.btn--primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn--sm {
padding: 4px 8px;
font-size: var(--text-caption);
}
.btn--whatsapp {
background: #25D366;
color: #fff;
border-color: #25D366;
}
.btn--whatsapp:hover {
background: #1da851;
}
/* ─── Empty state (no conversation selected) ─────────────────────── */
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--color-text-muted);
gap: var(--space-4);
}
.empty-state svg {
opacity: 0.3;
}
.empty-state__text {
font-size: var(--text-body);
text-align: center;
}
.empty-state__hint {
font-size: var(--text-caption);
color: var(--color-text-muted);
}
/* ─── Industrial theme cuts ──────────────────────────────────────── */
[data-theme="industrial"] .btn--primary {
clip-path: polygon(0 0, calc(100% - 6px) 0, 100% 6px, 100% 100%, 0 100%);
}
[data-theme="industrial"] .msg-bubble--out {
clip-path: polygon(0 0, 100% 0, 100% calc(100% - 6px), calc(100% - 6px) 100%, 0 100%);
}
/* ─── Responsive ─────────────────────────────────────────────────── */
@media (max-width: 768px) {
.conv-panel { width: 100%; min-width: 0; }
.chat-panel { display: none; }
.messenger.has-active-chat .conv-panel { display: none; }
.messenger.has-active-chat .chat-panel { display: flex; }
}
</style>
</head>
<body>
<div class="page-shell">
<!-- Sidebar injected by sidebar.js -->
<aside class="sidebar"></aside>
<div class="main-content pos-main-offset">
<!-- Top bar -->
<div class="top-bar">
<div class="top-bar__title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
</svg>
WhatsApp Business
</div>
<div class="top-bar__actions">
<div class="top-bar__status">
<span class="status-dot" id="statusDot"></span>
<span id="statusText">Verificando...</span>
</div>
</div>
</div>
<!-- Messenger -->
<div class="messenger" id="messenger">
<!-- Left: Conversation list -->
<div class="conv-panel">
<div class="conv-panel__header">
<span class="conv-panel__title">Conversaciones</span>
<button class="btn btn--sm btn--whatsapp" id="newChatBtn" title="Nueva conversacion">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Nuevo
</button>
</div>
<div class="conv-panel__list" id="convList">
<div class="conv-empty">Cargando...</div>
</div>
</div>
<!-- Right: Chat view (hidden until a conversation is selected) -->
<div class="empty-state" id="emptyState">
<svg width="80" height="80" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
</svg>
<div class="empty-state__text">Selecciona una conversacion<br>o inicia una nueva</div>
<div class="empty-state__hint">Los mensajes de WhatsApp aparecen aqui en tiempo real</div>
</div>
<div class="chat-panel" id="chatPanel" style="display:none">
<div class="chat-panel__header">
<span class="chat-panel__phone" id="chatHeaderPhone"></span>
<div class="chat-panel__actions">
<button class="btn btn--sm" id="sendQuoteBtn" title="Enviar cotizacion por WhatsApp">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
</svg>
Enviar Cotizacion
</button>
</div>
</div>
<div class="chat-panel__messages" id="chatMessages"></div>
<div class="chat-input-bar">
<textarea id="chatInput" placeholder="Escribe un mensaje..." rows="1"></textarea>
<button class="btn btn--primary" id="sendBtn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
Enviar
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Theme engine -->
<script>
function posSetTheme(t){document.documentElement.setAttribute('data-theme',t);localStorage.setItem('pos_theme',t);}
function posLogout(){localStorage.removeItem('pos_token');window.location.href='/pos/login';}
</script>
<!-- Sidebar -->
<script src="/pos/static/js/whatsapp.js"></script>
<script src="/pos/static/js/sidebar.js"></script>
</body>
</html>