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:
14
pos/app.py
14
pos/app.py
@@ -45,6 +45,12 @@ def create_app():
|
||||
from blueprints.chat_bp import 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
|
||||
@app.route('/pos/health')
|
||||
def health():
|
||||
@@ -99,6 +105,14 @@ def create_app():
|
||||
def pos_reports():
|
||||
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>')
|
||||
def pos_static(filename):
|
||||
return send_from_directory('static', filename)
|
||||
|
||||
308
pos/blueprints/whatsapp_bp.py
Normal file
308
pos/blueprints/whatsapp_bp.py
Normal 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()
|
||||
@@ -24,3 +24,8 @@ OPENROUTER_API_KEY = os.environ.get(
|
||||
"OPENROUTER_API_KEY",
|
||||
"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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")
|
||||
|
||||
@@ -14,6 +14,8 @@ MIGRATIONS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
MIGRATIONS = {
|
||||
'v1.0': 'v1.0_initial.sql',
|
||||
'v1.1': 'v1.1_pos_tables.sql',
|
||||
'v1.3': 'v1.3_fleet.sql',
|
||||
'v1.4': 'v1.4_whatsapp.sql',
|
||||
}
|
||||
|
||||
|
||||
|
||||
18
pos/migrations/v1.4_whatsapp.sql
Normal file
18
pos/migrations/v1.4_whatsapp.sql
Normal 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);
|
||||
307
pos/services/whatsapp_service.py
Normal file
307
pos/services/whatsapp_service.py
Normal 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}
|
||||
@@ -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: '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: '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: [
|
||||
{ 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
257
pos/static/js/whatsapp.js
Normal 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' ? '→ ' : '';
|
||||
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
538
pos/templates/whatsapp.html
Normal 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>
|
||||
Reference in New Issue
Block a user