feat: add campaigns, lists, and templates UI

This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2026-03-14 16:35:43 -03:00
parent 97d2a934a9
commit 516a38777c
9 changed files with 1199 additions and 177 deletions

View file

@ -357,8 +357,8 @@ pub async fn serve_suite(bot_name: Option<String>) -> impl IntoResponse {
match raw_html_res {
Ok(raw_html) => {
#[allow(unused_mut)] // Mutable required for feature-gated blocks
let mut html = raw_html;
let _ = &mut html; // Suppress unused_mut if no features are disabled
// Inject base tag and bot_name into the page
if let Some(head_end) = html.find("</head>") {
@ -563,7 +563,6 @@ pub async fn serve_suite(bot_name: Option<String>) -> impl IntoResponse {
}
}
#[allow(dead_code)]
pub fn remove_section(html: &str, section: &str) -> String {
let start_marker = format!("<!-- SECTION:{} -->", section);
let end_marker = format!("<!-- ENDSECTION:{} -->", section);
@ -840,6 +839,116 @@ async fn ws_proxy(
ws.on_upgrade(move |socket| handle_ws_proxy(socket, state, params_with_bot))
}
async fn handle_ws_proxy(
client_socket: WebSocket,
state: AppState,
params: WsQuery,
) {
let bot_name = params.bot_name.unwrap_or_else(|| "default".to_string());
let backend_url = format!(
"{}/ws/{}",
state
.client
.base_url()
.replace("https://", "wss://")
.replace("http://", "ws://"),
bot_name
);
info!("Proxying WebSocket to: {backend_url}");
let Ok(tls_connector) = native_tls::TlsConnector::builder()
.danger_accept_invalid_certs(true)
.danger_accept_invalid_hostnames(true)
.build()
else {
error!("Failed to build TLS connector for WebSocket proxy");
return;
};
let connector = tokio_tungstenite::Connector::NativeTls(tls_connector);
let backend_result =
connect_async_tls_with_config(&backend_url, None, false, Some(connector)).await;
let backend_socket: tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
> = match backend_result {
Ok((socket, _)) => socket,
Err(e) => {
error!("Failed to connect to backend WebSocket: {e}");
return;
}
};
info!("Connected to backend WebSocket");
let (mut client_tx, mut client_rx) = client_socket.split();
let (mut backend_tx, mut backend_rx) = backend_socket.split();
let client_to_backend = async {
while let Some(msg) = client_rx.next().await {
match msg {
Ok(AxumMessage::Text(text)) => {
if backend_tx.send(TungsteniteMessage::Text(text)).await.is_err() {
break;
}
}
Ok(AxumMessage::Binary(data)) => {
if backend_tx.send(TungsteniteMessage::Binary(data)).await.is_err() {
break;
}
}
Ok(AxumMessage::Ping(data)) => {
if backend_tx.send(TungsteniteMessage::Ping(data)).await.is_err() {
break;
}
}
Ok(AxumMessage::Pong(data)) => {
if backend_tx.send(TungsteniteMessage::Pong(data)).await.is_err() {
break;
}
}
Ok(AxumMessage::Close(_)) | Err(_) => break,
}
}
};
let backend_to_client = async {
while let Some(msg) = backend_rx.next().await {
match msg {
Ok(TungsteniteMessage::Text(text)) => {
if client_tx.send(AxumMessage::Text(text)).await.is_err() {
break;
}
}
Ok(TungsteniteMessage::Binary(data)) => {
if client_tx.send(AxumMessage::Binary(data)).await.is_err() {
break;
}
}
Ok(TungsteniteMessage::Ping(data)) => {
if client_tx.send(AxumMessage::Ping(data)).await.is_err() {
break;
}
}
Ok(TungsteniteMessage::Pong(data)) => {
if client_tx.send(AxumMessage::Pong(data)).await.is_err() {
break;
}
}
Ok(TungsteniteMessage::Close(_)) | Err(_) => break,
Ok(_) => {}
}
}
};
tokio::select! {
() = client_to_backend => info!("Client connection closed"),
() = backend_to_client => info!("Backend connection closed"),
}
}
async fn ws_task_progress_proxy(
ws: WebSocketUpgrade,
State(state): State<AppState>,
@ -983,88 +1092,41 @@ async fn handle_task_progress_ws_proxy(
}
}
#[allow(clippy::too_many_lines)]
async fn handle_ws_proxy(client_socket: WebSocket, state: AppState, params: WsQuery) {
let backend_url = format!(
"{}/ws?session_id={}&user_id={}&bot_name={}",
state
.client
.base_url()
.replace("https://", "wss://")
.replace("http://", "ws://"),
params.session_id,
params.user_id,
params.bot_name.unwrap_or_else(|| "default".to_string())
);
info!("Proxying WebSocket to: {backend_url}");
let Ok(tls_connector) = native_tls::TlsConnector::builder()
.danger_accept_invalid_certs(true)
.danger_accept_invalid_hostnames(true)
.build()
else {
error!("Failed to build TLS connector");
return;
};
let connector = tokio_tungstenite::Connector::NativeTls(tls_connector);
let backend_result =
connect_async_tls_with_config(&backend_url, None, false, Some(connector)).await;
let backend_socket: tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
> = match backend_result {
Ok((socket, _)) => socket,
Err(e) => {
error!("Failed to connect to backend WebSocket: {e}");
return;
}
};
info!("Connected to backend WebSocket");
let (mut client_tx, mut client_rx) = client_socket.split();
let (mut backend_tx, mut backend_rx) = backend_socket.split();
let client_to_backend = async {
async fn forward_client_to_backend(
client_rx: &mut futures_util::stream::SplitStream<WebSocket>,
backend_tx: &mut futures_util::stream::SplitSink<tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>, TungsteniteMessage>,
) {
while let Some(msg) = client_rx.next().await {
match msg {
Ok(AxumMessage::Text(text)) => {
let res: Result<(), tungstenite::Error> =
backend_tx.send(TungsteniteMessage::Text(text)).await;
if res.is_err() {
if backend_tx.send(TungsteniteMessage::Text(text)).await.is_err() {
break;
}
}
Ok(AxumMessage::Binary(data)) => {
let res: Result<(), tungstenite::Error> =
backend_tx.send(TungsteniteMessage::Binary(data)).await;
if res.is_err() {
if backend_tx.send(TungsteniteMessage::Binary(data)).await.is_err() {
break;
}
}
Ok(AxumMessage::Ping(data)) => {
let res: Result<(), tungstenite::Error> =
backend_tx.send(TungsteniteMessage::Ping(data)).await;
if res.is_err() {
if backend_tx.send(TungsteniteMessage::Ping(data)).await.is_err() {
break;
}
}
Ok(AxumMessage::Pong(data)) => {
let res: Result<(), tungstenite::Error> =
backend_tx.send(TungsteniteMessage::Pong(data)).await;
if res.is_err() {
if backend_tx.send(TungsteniteMessage::Pong(data)).await.is_err() {
break;
}
}
Ok(AxumMessage::Close(_)) | Err(_) => break,
}
}
};
}
let backend_to_client = async {
async fn forward_backend_to_client(
backend_rx: &mut futures_util::stream::SplitStream<tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>>,
client_tx: &mut futures_util::stream::SplitSink<WebSocket, AxumMessage>,
) {
while let Some(msg) = backend_rx.next().await {
match msg {
Ok(TungsteniteMessage::Text(text)) => {
@ -1091,12 +1153,6 @@ async fn handle_ws_proxy(client_socket: WebSocket, state: AppState, params: WsQu
Ok(_) => {}
}
}
};
tokio::select! {
() = client_to_backend => info!("Client connection closed"),
() = backend_to_client => info!("Backend connection closed"),
}
}
fn create_ws_router() -> Router<AppState> {

View file

@ -0,0 +1,292 @@
<!-- Marketing Campaigns - Multi-Channel Marketing -->
<link rel="stylesheet" href="/suite/crm/crm.css">
<script src="/suite/js/vendor/htmx.min.js"></script>
<script src="/suite/js/vendor/htmx-json-enc.js"></script>
<script src="/suite/js/security-bootstrap.js"></script>
<div class="crm-container">
<!-- Header -->
<header class="crm-header">
<div class="crm-header-left">
<h1 data-i18n="campaigns-title">Campaigns</h1>
<nav class="crm-tabs">
<button class="crm-tab active" data-view="all" data-i18n="campaigns-all">All Campaigns</button>
<button class="crm-tab" data-view="email" data-i18n="campaigns-email">Email</button>
<button class="crm-tab" data-view="whatsapp" data-i18n="campaigns-whatsapp">WhatsApp</button>
<button class="crm-tab" data-view="social" data-i18n="campaigns-social">Social</button>
</nav>
</div>
<div class="crm-header-right">
<button class="btn-primary" id="campaign-new-btn" onclick="showCampaignModal()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
<span data-i18n="campaign-new">New Campaign</span>
</button>
</div>
</header>
<!-- Campaigns Grid -->
<div id="campaigns-view" class="crm-view active">
<div class="campaigns-grid" id="campaignsList"
hx-get="/api/crm/campaigns"
hx-trigger="load"
hx-swap="innerHTML">
<!-- Campaigns loaded via HTMX -->
<div class="pipeline-column" style="grid-column: 1 / -1;">
<div style="padding: 40px; text-align: center; color: var(--text-secondary);">
Loading campaigns...
</div>
</div>
</div>
</div>
</div>
<!-- Create/Edit Campaign Modal -->
<div id="campaign-modal" class="crm-modal" style="display: none;">
<div class="crm-modal-overlay" onclick="hideCampaignModal()"></div>
<div class="crm-modal-content">
<div class="crm-modal-header">
<h2 id="campaign-modal-title" data-i18n="campaign-create">Create Campaign</h2>
<button class="crm-modal-close" onclick="hideCampaignModal()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<form id="campaign-form" hx-post="/api/crm/campaigns" hx-swap="none">
<div class="crm-form-group">
<label for="campaign-name" data-i18n="campaign-name">Campaign Name</label>
<input type="text" id="campaign-name" name="name" required
placeholder="e.g., Welcome Series">
</div>
<div class="crm-form-group">
<label for="campaign-channel" data-i18n="campaign-channel">Channel</label>
<select id="campaign-channel" name="channel" required>
<option value="email">📧 Email</option>
<option value="whatsapp">💬 WhatsApp</option>
<option value="instagram">📸 Instagram</option>
<option value="facebook">📘 Facebook</option>
<option value="multi">🔄 Multi-Channel</option>
</select>
</div>
<div class="crm-form-group">
<label for="campaign-budget" data-i18n="campaign-budget">Budget (optional)</label>
<input type="number" id="campaign-budget" name="budget" step="0.01"
placeholder="0.00">
</div>
<div class="crm-form-group">
<label for="campaign-schedule" data-i18n="campaign-schedule">Schedule (optional)</label>
<input type="datetime-local" id="campaign-schedule" name="scheduled_at">
</div>
<div class="crm-form-actions">
<button type="button" class="btn-secondary" onclick="hideCampaignModal()" data-i18n="cancel">Cancel</button>
<button type="submit" class="btn-primary" data-i18n="campaign-save">Create Campaign</button>
</div>
</form>
</div>
</div>
<style>
.campaigns-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
padding: 24px;
}
.campaign-card {
background: var(--surface, #1a1a1a);
border: 1px solid var(--border, #333);
border-radius: 8px;
padding: 16px;
transition: all 0.15s ease;
}
.campaign-card:hover {
border-color: var(--accent, #d4f505);
transform: translateY(-2px);
}
.campaign-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.campaign-card-title {
font-size: 15px;
font-weight: 600;
color: var(--text, #f8fafc);
margin: 0;
}
.campaign-status {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}
.campaign-status.draft {
background: rgba(251, 191, 36, 0.15);
color: #fbbf24;
}
.campaign-status.scheduled {
background: rgba(96, 165, 250, 0.15);
color: #60a5fa;
}
.campaign-status.running {
background: rgba(52, 211, 153, 0.15);
color: #34d399;
}
.campaign-status.completed {
background: rgba(156, 163, 175, 0.15);
color: #9ca3af;
}
.campaign-channels {
display: flex;
gap: 6px;
margin-bottom: 12px;
}
.campaign-channel-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
font-size: 12px;
color: var(--text-secondary, #888);
}
.campaign-metrics {
display: flex;
gap: 16px;
padding-top: 12px;
border-top: 1px solid var(--border, #333);
}
.campaign-metric {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.campaign-metric-value {
font-size: 18px;
font-weight: 700;
color: var(--accent, #d4f505);
}
.campaign-metric-label {
font-size: 11px;
color: var(--text-secondary, #888);
text-transform: uppercase;
margin-top: 2px;
}
.campaign-actions {
display: flex;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border, #333);
}
.campaign-action-btn {
flex: 1;
padding: 6px 12px;
background: transparent;
border: 1px solid var(--border, #333);
border-radius: 4px;
color: var(--text-secondary, #888);
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
}
.campaign-action-btn:hover {
background: var(--surface-hover, rgba(255, 255, 255, 0.05));
color: var(--text, #f8fafc);
border-color: var(--text-secondary, #888);
}
.campaign-action-btn.primary {
background: var(--accent, #d4f505);
color: var(--bg, #0a0a0a);
border-color: var(--accent, #d4f505);
}
.campaign-action-btn.primary:hover {
opacity: 0.9;
}
</style>
<script>
function showCampaignModal(campaignId = null) {
const modal = document.getElementById('campaign-modal');
const title = document.getElementById('campaign-modal-title');
if (campaignId) {
title.textContent = 'Edit Campaign';
} else {
title.textContent = 'Create Campaign';
}
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
}
function hideCampaignModal() {
const modal = document.getElementById('campaign-modal');
modal.style.display = 'none';
document.body.style.overflow = '';
document.getElementById('campaign-form').reset();
}
document.getElementById('campaign-form').addEventListener('htmx:afterRequest', function(e) {
if (e.detail.successful) {
hideCampaignModal();
document.getElementById('campaignsList').dispatchEvent(new Event('refresh'));
}
});
// Tab switching
document.querySelectorAll('.crm-tab[data-view]').forEach(tab => {
tab.addEventListener('click', function() {
document.querySelectorAll('.crm-tab[data-view]').forEach(t => t.classList.remove('active'));
this.classList.add('active');
const view = this.dataset.view;
filterCampaigns(view);
});
});
function filterCampaigns(view) {
const cards = document.querySelectorAll('.campaign-card');
cards.forEach(card => {
if (view === 'all') {
card.style.display = '';
} else {
const channels = card.querySelector('.campaign-channels');
if (channels && channels.textContent.toLowerCase().includes(view)) {
card.style.display = '';
} else {
card.style.display = 'none';
}
}
});
}
</script>

View file

@ -5,8 +5,8 @@
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg-primary, var(--surface, #0a0a0a));
color: var(--text, #f8fafc);
background: var(--bg);
color: var(--text);
}
/* Header */
@ -55,7 +55,7 @@
.crm-tab.active {
background: var(--accent, var(--color1, #d4f505));
color: #000;
color: var(--bg);
}
.crm-header-right {
@ -120,7 +120,7 @@
gap: 6px;
padding: 8px 16px;
background: var(--accent, #d4f505);
color: #000;
color: var(--bg);
border: none;
border-radius: 6px;
font-size: 13px;
@ -168,15 +168,15 @@
/* Accent Button */
.btn-accent {
background: var(--accent, #d4f505);
color: #000;
border-color: var(--accent, #d4f505);
background: var(--accent);
color: var(--bg, var(--bg));
border-color: var(--accent);
font-weight: 600;
}
.btn-accent:hover {
filter: brightness(1.1);
box-shadow: 0 0 12px var(--accent, rgba(212, 245, 5, 0.4));
box-shadow: 0 0 12px var(--accent);
}
/* Views */
@ -259,7 +259,7 @@
/* Pipeline Card */
.pipeline-card {
padding: 12px;
background: var(--bg-primary, #0a0a0a);
background: var(--bg, #0a0a0a);
border: 1px solid var(--border, #2a2a2a);
border-radius: 8px;
cursor: grab;
@ -321,7 +321,7 @@
height: 18px;
border-radius: 50%;
background: var(--accent, #d4f505);
color: #000;
color: var(--bg);
font-size: 10px;
font-weight: 600;
display: flex;
@ -376,7 +376,7 @@
flex-direction: column;
gap: 4px;
padding: 12px 16px;
background: var(--bg-primary, #0a0a0a);
background: var(--bg, #0a0a0a);
border: 1px solid var(--border, #2a2a2a);
border-radius: 8px;
min-width: 150px;
@ -510,7 +510,7 @@
.action-btn.primary {
background: var(--accent, #d4f505);
border-color: var(--accent, #d4f505);
color: #000;
color: var(--bg);
}
.action-btn.primary:hover {
@ -547,7 +547,7 @@
width: 100%;
max-width: 560px;
max-height: 90vh;
background: var(--bg-primary, #0a0a0a);
background: var(--bg, #0a0a0a);
border: 1px solid var(--border, #2a2a2a);
border-radius: 12px;
box-shadow: var(--shadow-xl);
@ -671,7 +671,7 @@
.crm-form-btn.primary {
background: var(--accent, #d4f505);
border: 1px solid var(--accent, #d4f505);
color: #000;
color: var(--bg);
}
.crm-form-btn.primary:hover {
@ -801,3 +801,94 @@
padding: 20px;
text-align: center;
}
/* Campaigns Grid */
.campaigns-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
padding: 24px;
}
.campaign-card {
background: var(--surface, #1a1a1a);
border: 1px solid var(--border, #333);
border-radius: 8px;
padding: 16px;
transition: all 0.15s ease;
}
.campaign-card:hover {
border-color: var(--accent, #d4f505);
transform: translateY(-2px);
}
.campaign-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.campaign-card-title {
font-size: 15px;
font-weight: 600;
color: var(--text, #f8fafc);
margin: 0;
}
.campaign-status {
display: inline-flex;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}
.campaign-status.draft { background: rgba(251, 191, 36, 0.15); color: #fbbf24; }
.campaign-status.scheduled { background: rgba(96, 165, 250, 0.15); color: #60a5fa; }
.campaign-status.running { background: rgba(52, 211, 153, 0.15); color: #34d399; }
.campaign-status.completed { background: rgba(156, 163, 175, 0.15); color: #9ca3af; }
.campaign-channels {
display: flex;
gap: 6px;
margin-bottom: 12px;
}
.campaign-channel-tag {
display: inline-flex;
padding: 3px 8px;
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
font-size: 12px;
color: var(--text-secondary, #888);
}
.campaign-metrics {
display: flex;
gap: 16px;
padding-top: 12px;
border-top: 1px solid var(--border, #333);
}
.campaign-metric {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.campaign-metric-value {
font-size: 18px;
font-weight: 700;
color: var(--accent, #d4f505);
}
.campaign-metric-label {
font-size: 11px;
color: var(--text-secondary, #888);
text-transform: uppercase;
margin-top: 2px;
}

View file

@ -1,40 +1,6 @@
<!-- CRM - Customer Relationship Management -->
<!-- Dynamics nomenclature: Lead → Opportunity → Account/Contact -->
<style>
[data-theme="sentient"] {
--bg: #0a0a0a;
--surface: #161616;
--surface-hover: #1e1e1e;
--border: #2a2a2a;
--text: #ffffff;
--text-secondary: #888888;
--text-muted: #444444;
--primary: #3b82f6;
--primary-hover: #2563eb;
--primary-light: rgba(59, 130, 246, 0.1);
--error: #ef4444;
--success: #22c55e;
--warning: #f59e0b;
--bg-primary: #0a0a0a;
}
:root {
--bg: #0a0a0a;
--surface: #161616;
--surface-hover: #1e1e1e;
--border: #2a2a2a;
--text: #ffffff;
--text-secondary: #888888;
--text-muted: #444444;
--primary: #3b82f6;
--primary-hover: #2563eb;
--primary-light: rgba(59, 130, 246, 0.1);
--error: #ef4444;
--success: #22c55e;
--warning: #f59e0b;
--bg-primary: #0a0a0a;
}
</style>
<link rel="stylesheet" href="/suite/crm/crm.css">
<script src="/suite/js/vendor/htmx.min.js"></script>
<script src="/suite/js/vendor/htmx-json-enc.js"></script>
@ -51,6 +17,7 @@
<button class="crm-tab" data-view="opportunities" data-i18n="crm-opportunities">Opportunities</button>
<button class="crm-tab" data-view="accounts" data-i18n="crm-accounts">Accounts</button>
<button class="crm-tab" data-view="contacts" data-i18n="crm-contacts">Contacts</button>
<button class="crm-tab" data-view="campaigns" data-i18n="crm-campaigns">Campaigns</button>
</nav>
</div>
<div class="crm-header-right">
@ -274,6 +241,18 @@
</tbody>
</table>
</div>
<!-- Campaigns View -->
<div id="crm-campaigns-view" class="crm-view">
<div class="campaigns-grid" id="crmCampaignsList"
hx-get="/api/crm/campaigns"
hx-trigger="load"
hx-swap="innerHTML">
<div style="grid-column: 1 / -1; padding: 40px; text-align: center; color: var(--text-secondary);">
Loading campaigns...
</div>
</div>
</div>
</div>
<!-- Modal for forms -->

View file

@ -462,6 +462,79 @@
<span class="desktop-icon-label">CRM</span>
</div>
<div
class="desktop-icon"
data-app-id="campaigns"
data-app-title="Campaigns"
hx-get="/suite/campaigns/campaigns.html"
hx-swap="none"
>
<div class="app-icon">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
</div>
<span class="desktop-icon-label">Campaigns</span>
</div>
<div
class="desktop-icon"
data-app-id="lists"
data-app-title="Lists"
hx-get="/suite/lists/lists.html"
hx-swap="none"
>
<div class="app-icon">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" />
<line x1="3" y1="12" x2="3.01" y2="12" />
<line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
</div>
<span class="desktop-icon-label">Lists</span>
</div>
<div
class="desktop-icon"
data-app-id="templates"
data-app-title="Templates"
hx-get="/suite/templates/templates.html"
hx-swap="none"
>
<div class="app-icon">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="9" y1="21" x2="9" y2="9" />
</svg>
</div>
<span class="desktop-icon-label">Templates</span>
</div>
<div
class="desktop-icon"
data-app-id="tasks"
@ -581,6 +654,54 @@
<span class="desktop-icon-label">Editor</span>
</div>
<div
class="desktop-icon"
data-app-id="designer"
data-app-title="Designer"
hx-get="/suite/designer.html"
hx-swap="none"
>
<div class="app-icon">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 19l7-7 3 3-7 7-3-3z"></path>
<path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"></path>
<path d="M2 2l7.586 7.586"></path>
<circle cx="11" cy="11" r="2"></circle>
</svg>
</div>
<span class="desktop-icon-label">Designer</span>
</div>
<div
class="desktop-icon"
data-app-id="bas-editor"
data-app-title="BASIC"
hx-get="/suite/partials/vibe.html?mode=bas"
hx-swap="none"
>
<div class="app-icon">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="4 17 10 11 4 5"></polyline>
<line x1="12" y1="19" y2="20"></line>
</svg>
</div>
<span class="desktop-icon-label">BASIC</span>
</div>
<div
class="desktop-icon"
data-app-id="browser"

View file

@ -1,6 +1,9 @@
const botCoderTerminal = {
term: null,
ws: null,
sessionId: null,
reconnectAttempts: 0,
maxReconnectAttempts: 5,
init: function() {
if (!window.Terminal) {
@ -13,50 +16,126 @@ const botCoderTerminal = {
theme: {
background: '#0f172a',
foreground: '#f8fafc',
cursor: '#3b82f6'
cursor: '#3b82f6',
selectionBackground: 'rgba(59, 130, 246, 0.4)',
black: '#1e1e1e',
red: '#ef4444',
green: '#22c55e',
yellow: '#eab308',
blue: '#3b82f6',
magenta: '#a855f7',
cyan: '#06b6d4',
white: '#f8fafc',
brightBlack: '#64748b',
brightRed: '#f87171',
brightGreen: '#4ade80',
brightYellow: '#facc15',
brightBlue: '#60a5fa',
brightMagenta: '#c084fc',
brightCyan: '#22d3ee',
brightWhite: '#ffffff'
},
fontFamily: 'Consolas, "Courier New", monospace',
fontFamily: '"Fira Code", Consolas, "Courier New", monospace',
fontSize: 13,
cursorBlink: true
cursorBlink: true,
cursorStyle: 'block',
allowProposedApi: true,
scrollback: 10000
});
this.term.open(document.getElementById('xtermContainer'));
this.term.write('Welcome to BotCoder Interactive Shell\r\n');
this.term.write('$ ');
// Basic echo for demo
this.term.onData(e => {
if (e === '\r') {
this.term.write('\r\n$ ');
} else if (e === '\u007f') { // Backspace
this.term.write('\b \b');
} else {
this.term.write(e);
this.term.onData(data => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(data);
}
});
this.term.onResize(({ cols, rows }) => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(`resize ${cols} ${rows}`);
}
});
// Initialize WebSocket (mock endpoint)
this.connect();
},
generateSessionId: function() {
return 'term-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
},
connect: function() {
// ws = new WebSocket('ws://localhost:8080/ws/terminal/session-123');
// ws.onmessage = (msg) => this.term.write(msg.data);
this.sessionId = this.generateSessionId();
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/terminal/ws?session_id=${this.sessionId}`;
this.term.write('\x1b[36mConnecting to isolated terminal...\x1b[0m\r\n');
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
this.reconnectAttempts = 0;
this.term.write('\x1b[32m✓ Connected to isolated container terminal\x1b[0m\r\n\r\n');
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'connected') {
this.term.write(`\x1b[33mContainer: ${data.container}\x1b[0m\r\n`);
this.term.write(`\x1b[90mSession: ${data.session_id}\x1b[0m\r\n\r\n`);
} else if (data.type === 'system') {
this.term.write(`\x1b[90m${data.message}\x1b[0m`);
} else if (data.type === 'error') {
this.term.write(`\x1b[31mError: ${data.message}\x1b[0m\r\n`);
}
} catch (e) {
this.term.write(event.data);
}
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
this.term.write('\x1b[31mConnection error. Attempting to reconnect...\x1b[0m\r\n');
};
this.ws.onclose = () => {
this.term.write('\x1b[33m\x1b[1mDisconnected from terminal.\x1b[0m\r\n');
this.term.write('\x1b[90mType "reconnect" to start a new session\x1b[0m\r\n');
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
setTimeout(() => this.connect(), 2000 * this.reconnectAttempts);
}
};
},
newTerminal: function() {
alert("New terminal tab created!");
if (this.ws) {
this.ws.close();
}
this.connect();
},
closeTerminal: function() {
alert("Terminal tab closed!");
if (this.ws) {
this.ws.send('\\exit');
this.ws.close();
}
},
clearTerminal: function() {
if (this.term) {
this.term.clear();
this.term.write('$ ');
}
},
reconnect: function() {
this.reconnectAttempts = 0;
if (this.ws) {
this.ws.close();
}
this.connect();
}
};
@ -64,3 +143,5 @@ document.addEventListener('DOMContentLoaded', () => botCoderTerminal.init());
if (document.readyState === 'complete' || document.readyState === 'interactive') {
botCoderTerminal.init();
}
window.botCoderTerminal = botCoderTerminal;

181
ui/suite/lists/lists.html Normal file
View file

@ -0,0 +1,181 @@
<!-- Marketing Lists -->
<link rel="stylesheet" href="/suite/crm/crm.css">
<script src="/suite/js/vendor/htmx.min.js"></script>
<script src="/suite/js/vendor/htmx-json-enc.js"></script>
<script src="/suite/js/security-bootstrap.js"></script>
<div class="crm-container">
<header class="crm-header">
<div class="crm-header-left">
<h1 data-i18n="lists-title">Marketing Lists</h1>
<nav class="crm-tabs">
<button class="crm-tab active" data-view="all" data-i18n="lists-all">All Lists</button>
<button class="crm-tab" data-view="static" data-i18n="lists-static">Static</button>
<button class="crm-tab" data-view="dynamic" data-i18n="lists-dynamic">Dynamic</button>
</nav>
</div>
<div class="crm-header-right">
<button class="btn-primary" onclick="showListModal()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
<span>New List</span>
</button>
</div>
</header>
<div class="lists-grid" id="listsList"
hx-get="/api/crm/lists"
hx-trigger="load"
hx-swap="innerHTML">
<div style="grid-column: 1 / -1; padding: 40px; text-align: center; color: var(--text-secondary);">
Loading lists...
</div>
</div>
</div>
<!-- Create/Edit List Modal -->
<div id="list-modal" class="crm-modal" style="display: none;">
<div class="crm-modal-overlay" onclick="hideListModal()"></div>
<div class="crm-modal-content">
<div class="crm-modal-header">
<h2 id="list-modal-title">Create List</h2>
<button class="crm-modal-close" onclick="hideListModal()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<form id="list-form" hx-post="/api/crm/lists" hx-swap="none">
<div class="crm-form-group">
<label>List Name</label>
<input type="text" name="name" required placeholder="e.g., Active Customers">
</div>
<div class="crm-form-group">
<label>List Type</label>
<select name="list_type" required>
<option value="static">Static</option>
<option value="dynamic">Dynamic</option>
</select>
</div>
<div class="crm-form-group">
<label>Query (for dynamic lists)</label>
<textarea name="query_text" rows="3" placeholder="e.g., status = 'active' AND country = 'US'"></textarea>
</div>
<div class="crm-form-actions">
<button type="button" class="btn-secondary" onclick="hideListModal()">Cancel</button>
<button type="submit" class="btn-primary">Create List</button>
</div>
</form>
</div>
</div>
<style>
.lists-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
padding: 24px;
}
.list-card {
background: var(--surface, #1a1a1a);
border: 1px solid var(--border, #333);
border-radius: 8px;
padding: 16px;
transition: all 0.15s ease;
}
.list-card:hover {
border-color: var(--accent, #d4f505);
transform: translateY(-2px);
}
.list-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
}
.list-card-title {
font-size: 15px;
font-weight: 600;
color: var(--text, #f8fafc);
margin: 0;
}
.list-type {
display: inline-flex;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}
.list-type.static {
background: rgba(96, 165, 250, 0.15);
color: #60a5fa;
}
.list-type.dynamic {
background: rgba(167, 139, 250, 0.15);
color: #a78bfa;
}
.list-stats {
display: flex;
gap: 16px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border, #333);
}
.list-stat {
display: flex;
flex-direction: column;
}
.list-stat-value {
font-size: 18px;
font-weight: 700;
color: var(--accent, #d4f505);
}
.list-stat-label {
font-size: 11px;
color: var(--text-secondary, #888);
text-transform: uppercase;
}
</style>
<script>
function showListModal(listId = null) {
const modal = document.getElementById('list-modal');
const title = document.getElementById('list-modal-title');
title.textContent = listId ? 'Edit List' : 'Create List';
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
}
function hideListModal() {
document.getElementById('list-modal').style.display = 'none';
document.body.style.overflow = '';
document.getElementById('list-form').reset();
}
document.getElementById('list-form').addEventListener('htmx:afterRequest', function(e) {
if (e.detail.successful) {
hideListModal();
document.getElementById('listsList').dispatchEvent(new Event('refresh'));
}
});
document.querySelectorAll('.crm-tab[data-view]').forEach(tab => {
tab.addEventListener('click', function() {
document.querySelectorAll('.crm-tab[data-view]').forEach(t => t.classList.remove('active'));
this.classList.add('active');
});
});
</script>

View file

@ -259,6 +259,27 @@
</svg>
Accounts
</button>
<button
class="tab-btn"
role="tab"
aria-selected="false"
hx-get="/api/ui/sources/api-keys"
hx-target="#content-area"
hx-swap="innerHTML"
onclick="setActiveTab(this)"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"></path>
</svg>
API Keys
</button>
</nav>
<!-- Main Content Area -->

View file

@ -0,0 +1,200 @@
<!-- Marketing Templates -->
<link rel="stylesheet" href="/suite/crm/crm.css">
<script src="/suite/js/vendor/htmx.min.js"></script>
<script src="/suite/js/vendor/htmx-json-enc.js"></script>
<script src="/suite/js/security-bootstrap.js"></script>
<div class="crm-container">
<header class="crm-header">
<div class="crm-header-left">
<h1 data-i18n="templates-title">Content Templates</h1>
<nav class="crm-tabs">
<button class="crm-tab active" data-view="all">All Templates</button>
<button class="crm-tab" data-view="email">Email</button>
<button class="crm-tab" data-view="whatsapp">WhatsApp</button>
<button class="crm-tab" data-view="social">Social</button>
</nav>
</div>
<div class="crm-header-right">
<button class="btn-primary" onclick="showTemplateModal()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
<span>New Template</span>
</button>
</div>
</header>
<div class="templates-grid" id="templatesList"
hx-get="/api/crm/templates"
hx-trigger="load"
hx-swap="innerHTML">
<div style="grid-column: 1 / -1; padding: 40px; text-align: center; color: var(--text-secondary);">
Loading templates...
</div>
</div>
</div>
<!-- Create/Edit Template Modal -->
<div id="template-modal" class="crm-modal" style="display: none;">
<div class="crm-modal-overlay" onclick="hideTemplateModal()"></div>
<div class="crm-modal-content" style="max-width: 600px;">
<div class="crm-modal-header">
<h2 id="template-modal-title">Create Template</h2>
<button class="crm-modal-close" onclick="hideTemplateModal()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<form id="template-form" hx-post="/api/crm/templates" hx-swap="none">
<div class="crm-form-group">
<label>Template Name</label>
<input type="text" name="name" required placeholder="e.g., Welcome Email">
</div>
<div class="crm-form-group">
<label>Channel</label>
<select name="channel" required>
<option value="email">Email</option>
<option value="whatsapp">WhatsApp</option>
<option value="instagram">Instagram</option>
<option value="facebook">Facebook</option>
</select>
</div>
<div class="crm-form-group">
<label>Subject (for email)</label>
<input type="text" name="subject" placeholder="e.g., Welcome to {{company}}!">
</div>
<div class="crm-form-group">
<label>Body</label>
<textarea name="body" rows="6" placeholder="Write your message here. Use {{variable}} for personalization."></textarea>
</div>
<div class="crm-form-group">
<label>AI Prompt (optional)</label>
<textarea name="ai_prompt" rows="3" placeholder="Describe how AI should generate content..."></textarea>
</div>
<div class="crm-form-actions">
<button type="button" class="btn-secondary" onclick="hideTemplateModal()">Cancel</button>
<button type="submit" class="btn-primary">Create Template</button>
</div>
</form>
</div>
</div>
<style>
.templates-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
padding: 24px;
}
.template-card {
background: var(--surface, #1a1a1a);
border: 1px solid var(--border, #333);
border-radius: 8px;
padding: 16px;
transition: all 0.15s ease;
}
.template-card:hover {
border-color: var(--accent, #d4f505);
transform: translateY(-2px);
}
.template-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
}
.template-card-title {
font-size: 15px;
font-weight: 600;
color: var(--text, #f8fafc);
margin: 0;
}
.template-channel {
display: inline-flex;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}
.template-channel.email { background: rgba(96, 165, 250, 0.15); color: #60a5fa; }
.template-channel.whatsapp { background: rgba(52, 211, 153, 0.15); color: #34d399; }
.template-channel.instagram { background: rgba(236, 72, 153, 0.15); color: #ec4899; }
.template-channel.facebook { background: rgba(59, 130, 246, 0.15); color: #3b82f6; }
.template-preview {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border, #333);
font-size: 13px;
color: var(--text-secondary, #888);
max-height: 80px;
overflow: hidden;
text-overflow: ellipsis;
}
.template-status {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
}
.template-approved {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.template-approved.yes {
background: rgba(52, 211, 153, 0.15);
color: #34d399;
}
.template-approved.no {
background: rgba(251, 191, 36, 0.15);
color: #fbbf24;
}
</style>
<script>
function showTemplateModal(templateId = null) {
const modal = document.getElementById('template-modal');
const title = document.getElementById('template-modal-title');
title.textContent = templateId ? 'Edit Template' : 'Create Template';
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
}
function hideTemplateModal() {
document.getElementById('template-modal').style.display = 'none';
document.body.style.overflow = '';
document.getElementById('template-form').reset();
}
document.getElementById('template-form').addEventListener('htmx:afterRequest', function(e) {
if (e.detail.successful) {
hideTemplateModal();
document.getElementById('templatesList').dispatchEvent(new Event('refresh'));
}
});
document.querySelectorAll('.crm-tab[data-view]').forEach(tab => {
tab.addEventListener('click', function() {
document.querySelectorAll('.crm-tab[data-view]').forEach(t => t.classList.remove('active'));
this.classList.add('active');
});
});
</script>