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
|
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)
|
||||||
|
|||||||
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",
|
"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")
|
||||||
|
|||||||
@@ -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',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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: '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
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