Files
contexta_be/app/services/widget.py
belviskhoremk 92d4c2fc5e feat: add appointments, campaigns, admin, storage, tests and various updates
- Add new routers: admin, appointments, campaigns
- Add storage service and logging config
- Add migrations directory and test suite with pytest config
- Add supabase_migration_features.sql
- Update models, dependencies, config, and existing routers
- Remove whatsapp_service (deleted)
- Update pyproject.toml and uv.lock dependencies

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:11:58 +00:00

183 lines
7.4 KiB
Python

"""
Widget JS generator.
Produces a self-contained, framework-agnostic JavaScript bundle served at
GET /widget.js. Embed on any page with:
<script
src="https://api.yoursite.com/widget.js"
data-chatbot="<chatbot-id>">
</script>
Works on vanilla HTML, WordPress, Webflow, Shopify, Next.js (_document),
and any framework where you control the HTML shell.
For React/Vue projects that want a native component, host-side devs can call
window.Contexta.open() / .close() / .toggle()
from their own button, or await the dedicated npm package (@contexta/widget).
Design decisions
----------------
- All CSS is ID-scoped (#ctxa-*) to avoid colliding with host-page styles.
- The iframe src is set lazily on first open — zero network cost until use.
- document.currentScript is captured synchronously (before any async code)
so it works even when the host page has many script tags.
- z-index 2147483647 is the highest a browser will honour.
- sandbox attribute restricts the iframe while still allowing forms/popups.
"""
_TEMPLATE = r"""(function () {
'use strict';
/* ── Double-init guard ─────────────────────────────────────────────── */
if (window.__ctxa) return;
/* ── Read chatbot ID from <script data-chatbot="..."> ──────────────── */
var _cur = document.currentScript;
var _id = _cur && _cur.getAttribute('data-chatbot');
if (!_id) {
var _all = document.querySelectorAll('script[data-chatbot]');
if (_all.length) _id = _all[_all.length - 1].getAttribute('data-chatbot');
}
if (!_id) {
console.warn('[Contexta] widget loaded but no data-chatbot attribute found.');
return;
}
var _base = '__APP_URL__';
var _url = _base + '/chat/' + _id;
/* ── Styles ─────────────────────────────────────────────────────────── */
var _css = [
'#ctxa-btn{',
'position:fixed;bottom:24px;right:24px;',
'width:56px;height:56px;border-radius:50%;',
'background:linear-gradient(135deg,#6366f1,#4f46e5);',
'border:none;cursor:pointer;padding:0;',
'box-shadow:0 4px 20px rgba(99,102,241,.45);',
'display:flex;align-items:center;justify-content:center;',
'z-index:2147483646;',
'transition:transform .2s ease,box-shadow .2s ease;',
'outline:none;',
'}',
'#ctxa-btn:hover{transform:scale(1.1);box-shadow:0 6px 28px rgba(99,102,241,.55)}',
'#ctxa-btn:focus-visible{outline:2px solid #6366f1;outline-offset:3px}',
/* Icon animations */
'#ctxa-ico-chat,#ctxa-ico-x{position:absolute;transition:opacity .15s ease,transform .2s ease}',
'#ctxa-ico-x{opacity:0;transform:rotate(-90deg)}',
'#ctxa-btn.open #ctxa-ico-chat{opacity:0;transform:rotate(90deg)}',
'#ctxa-btn.open #ctxa-ico-x{opacity:1;transform:rotate(0)}',
/* Panel */
'#ctxa-panel{',
'position:fixed;bottom:92px;right:24px;',
'width:380px;height:600px;',
'max-height:calc(100dvh - 120px);',
'border-radius:20px;overflow:hidden;background:#fff;',
'box-shadow:0 20px 60px rgba(0,0,0,.18),0 0 0 1px rgba(0,0,0,.07);',
'z-index:2147483647;',
'opacity:0;transform:translateY(12px) scale(.97);pointer-events:none;',
'transition:opacity .22s cubic-bezier(.4,0,.2,1),transform .22s cubic-bezier(.4,0,.2,1);',
'}',
'#ctxa-panel.open{opacity:1;transform:translateY(0) scale(1);pointer-events:all}',
'#ctxa-frame{width:100%;height:100%;border:none;display:block;background:#fff}',
/* Mobile: full-screen */
'@media(max-width:480px){',
'#ctxa-panel{bottom:0;right:0;left:0;width:100%;height:100%;',
'max-height:100dvh;border-radius:0;box-shadow:none}',
'#ctxa-btn{bottom:16px;right:16px}',
'}',
].join('');
var _style = document.createElement('style');
_style.textContent = _css;
document.head.appendChild(_style);
/* ── Button ─────────────────────────────────────────────────────────── */
var _btn = document.createElement('button');
_btn.id = 'ctxa-btn';
_btn.setAttribute('aria-label', 'Open chat');
_btn.setAttribute('aria-expanded', 'false');
_btn.innerHTML = (
'<svg id="ctxa-ico-chat" width="24" height="24" viewBox="0 0 24 24" fill="none">' +
'<path d="M12 2C6.477 2 2 6.2 2 11.4c0 2.8 1.26 5.3 3.26 7.04L4 22l4.2-1.75' +
'A11.1 11.1 0 0 0 12 20.8c5.523 0 10-4.2 10-9.4S17.523 2 12 2z" fill="white"/>' +
'</svg>' +
'<svg id="ctxa-ico-x" width="20" height="20" viewBox="0 0 24 24" fill="none">' +
'<path d="M18 6L6 18M6 6l12 12" stroke="white" stroke-width="2.5"' +
' stroke-linecap="round" stroke-linejoin="round"/>' +
'</svg>'
);
/* ── Panel + lazy iframe ─────────────────────────────────────────────── */
var _panel = document.createElement('div');
_panel.id = 'ctxa-panel';
_panel.setAttribute('role', 'dialog');
_panel.setAttribute('aria-label', 'Chat');
var _frame = document.createElement('iframe');
_frame.id = 'ctxa-frame';
_frame.title = 'Contexta chat';
_frame.setAttribute('allow', 'clipboard-write');
/* sandbox: scripts + same-origin needed for the React app to run */
_frame.setAttribute('sandbox',
'allow-scripts allow-same-origin allow-forms allow-popups allow-top-navigation-by-user-activation'
);
_panel.appendChild(_frame);
/* ── Mount after DOM ready ───────────────────────────────────────────── */
function _mount() {
document.body.appendChild(_btn);
document.body.appendChild(_panel);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _mount);
} else {
_mount();
}
/* ── Open / Close ────────────────────────────────────────────────────── */
var _isOpen = false;
var _loaded = false;
function _open() {
if (!_loaded) { _frame.src = _url; _loaded = true; }
_isOpen = true;
_panel.classList.add('open');
_btn.classList.add('open');
_btn.setAttribute('aria-expanded', 'true');
_btn.setAttribute('aria-label', 'Close chat');
}
function _close() {
_isOpen = false;
_panel.classList.remove('open');
_btn.classList.remove('open');
_btn.setAttribute('aria-expanded', 'false');
_btn.setAttribute('aria-label', 'Open chat');
}
_btn.addEventListener('click', function () { _isOpen ? _close() : _open(); });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && _isOpen) _close();
});
/* ── Public API ──────────────────────────────────────────────────────── */
window.__ctxa = true;
window.Contexta = {
open: _open,
close: _close,
toggle: function () { _isOpen ? _close() : _open(); },
};
}());
"""
def generate_widget_js(app_url: str) -> str:
"""Return the widget bundle with the frontend app URL baked in."""
return _TEMPLATE.replace('__APP_URL__', app_url.rstrip('/'))