feat: add campaigns, lists, and templates UI
This commit is contained in:
parent
97d2a934a9
commit
516a38777c
9 changed files with 1199 additions and 177 deletions
|
|
@ -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,119 +1092,66 @@ 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 {
|
||||
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() {
|
||||
break;
|
||||
}
|
||||
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)) => {
|
||||
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() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(AxumMessage::Ping(data)) => {
|
||||
let res: Result<(), tungstenite::Error> =
|
||||
backend_tx.send(TungsteniteMessage::Ping(data)).await;
|
||||
if res.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(AxumMessage::Pong(data)) => {
|
||||
let res: Result<(), tungstenite::Error> =
|
||||
backend_tx.send(TungsteniteMessage::Pong(data)).await;
|
||||
if res.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(AxumMessage::Binary(data)) => {
|
||||
if backend_tx.send(TungsteniteMessage::Binary(data)).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(_) => {}
|
||||
}
|
||||
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,
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
() = client_to_backend => info!("Client connection closed"),
|
||||
() = backend_to_client => info!("Backend connection closed"),
|
||||
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)) => {
|
||||
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(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
292
ui/suite/campaigns/campaigns.html
Normal file
292
ui/suite/campaigns/campaigns.html
Normal 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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
181
ui/suite/lists/lists.html
Normal 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>
|
||||
|
|
@ -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 -->
|
||||
|
|
|
|||
200
ui/suite/templates/templates.html
Normal file
200
ui/suite/templates/templates.html
Normal 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>
|
||||
Loading…
Add table
Reference in a new issue