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 {
|
match raw_html_res {
|
||||||
Ok(raw_html) => {
|
Ok(raw_html) => {
|
||||||
#[allow(unused_mut)] // Mutable required for feature-gated blocks
|
|
||||||
let mut html = raw_html;
|
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
|
// Inject base tag and bot_name into the page
|
||||||
if let Some(head_end) = html.find("</head>") {
|
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 {
|
pub fn remove_section(html: &str, section: &str) -> String {
|
||||||
let start_marker = format!("<!-- SECTION:{} -->", section);
|
let start_marker = format!("<!-- SECTION:{} -->", section);
|
||||||
let end_marker = format!("<!-- ENDSECTION:{} -->", 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))
|
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(
|
async fn ws_task_progress_proxy(
|
||||||
ws: WebSocketUpgrade,
|
ws: WebSocketUpgrade,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
|
@ -983,88 +1092,41 @@ async fn handle_task_progress_ws_proxy(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
async fn forward_client_to_backend(
|
||||||
async fn handle_ws_proxy(client_socket: WebSocket, state: AppState, params: WsQuery) {
|
client_rx: &mut futures_util::stream::SplitStream<WebSocket>,
|
||||||
let backend_url = format!(
|
backend_tx: &mut futures_util::stream::SplitSink<tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>, TungsteniteMessage>,
|
||||||
"{}/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 {
|
while let Some(msg) = client_rx.next().await {
|
||||||
match msg {
|
match msg {
|
||||||
Ok(AxumMessage::Text(text)) => {
|
Ok(AxumMessage::Text(text)) => {
|
||||||
let res: Result<(), tungstenite::Error> =
|
if backend_tx.send(TungsteniteMessage::Text(text)).await.is_err() {
|
||||||
backend_tx.send(TungsteniteMessage::Text(text)).await;
|
|
||||||
if res.is_err() {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(AxumMessage::Binary(data)) => {
|
Ok(AxumMessage::Binary(data)) => {
|
||||||
let res: Result<(), tungstenite::Error> =
|
if backend_tx.send(TungsteniteMessage::Binary(data)).await.is_err() {
|
||||||
backend_tx.send(TungsteniteMessage::Binary(data)).await;
|
|
||||||
if res.is_err() {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(AxumMessage::Ping(data)) => {
|
Ok(AxumMessage::Ping(data)) => {
|
||||||
let res: Result<(), tungstenite::Error> =
|
if backend_tx.send(TungsteniteMessage::Ping(data)).await.is_err() {
|
||||||
backend_tx.send(TungsteniteMessage::Ping(data)).await;
|
|
||||||
if res.is_err() {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(AxumMessage::Pong(data)) => {
|
Ok(AxumMessage::Pong(data)) => {
|
||||||
let res: Result<(), tungstenite::Error> =
|
if backend_tx.send(TungsteniteMessage::Pong(data)).await.is_err() {
|
||||||
backend_tx.send(TungsteniteMessage::Pong(data)).await;
|
|
||||||
if res.is_err() {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(AxumMessage::Close(_)) | 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 {
|
while let Some(msg) = backend_rx.next().await {
|
||||||
match msg {
|
match msg {
|
||||||
Ok(TungsteniteMessage::Text(text)) => {
|
Ok(TungsteniteMessage::Text(text)) => {
|
||||||
|
|
@ -1091,12 +1153,6 @@ async fn handle_ws_proxy(client_socket: WebSocket, state: AppState, params: WsQu
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
tokio::select! {
|
|
||||||
() = client_to_backend => info!("Client connection closed"),
|
|
||||||
() = backend_to_client => info!("Backend connection closed"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_ws_router() -> Router<AppState> {
|
fn create_ws_router() -> Router<AppState> {
|
||||||
|
|
|
||||||
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--bg-primary, var(--surface, #0a0a0a));
|
background: var(--bg);
|
||||||
color: var(--text, #f8fafc);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header */
|
/* Header */
|
||||||
|
|
@ -55,7 +55,7 @@
|
||||||
|
|
||||||
.crm-tab.active {
|
.crm-tab.active {
|
||||||
background: var(--accent, var(--color1, #d4f505));
|
background: var(--accent, var(--color1, #d4f505));
|
||||||
color: #000;
|
color: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.crm-header-right {
|
.crm-header-right {
|
||||||
|
|
@ -120,7 +120,7 @@
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
background: var(--accent, #d4f505);
|
background: var(--accent, #d4f505);
|
||||||
color: #000;
|
color: var(--bg);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|
@ -168,15 +168,15 @@
|
||||||
|
|
||||||
/* Accent Button */
|
/* Accent Button */
|
||||||
.btn-accent {
|
.btn-accent {
|
||||||
background: var(--accent, #d4f505);
|
background: var(--accent);
|
||||||
color: #000;
|
color: var(--bg, var(--bg));
|
||||||
border-color: var(--accent, #d4f505);
|
border-color: var(--accent);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-accent:hover {
|
.btn-accent:hover {
|
||||||
filter: brightness(1.1);
|
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 */
|
/* Views */
|
||||||
|
|
@ -259,7 +259,7 @@
|
||||||
/* Pipeline Card */
|
/* Pipeline Card */
|
||||||
.pipeline-card {
|
.pipeline-card {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: var(--bg-primary, #0a0a0a);
|
background: var(--bg, #0a0a0a);
|
||||||
border: 1px solid var(--border, #2a2a2a);
|
border: 1px solid var(--border, #2a2a2a);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
|
|
@ -321,7 +321,7 @@
|
||||||
height: 18px;
|
height: 18px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--accent, #d4f505);
|
background: var(--accent, #d4f505);
|
||||||
color: #000;
|
color: var(--bg);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -376,7 +376,7 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
background: var(--bg-primary, #0a0a0a);
|
background: var(--bg, #0a0a0a);
|
||||||
border: 1px solid var(--border, #2a2a2a);
|
border: 1px solid var(--border, #2a2a2a);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
min-width: 150px;
|
min-width: 150px;
|
||||||
|
|
@ -510,7 +510,7 @@
|
||||||
.action-btn.primary {
|
.action-btn.primary {
|
||||||
background: var(--accent, #d4f505);
|
background: var(--accent, #d4f505);
|
||||||
border-color: var(--accent, #d4f505);
|
border-color: var(--accent, #d4f505);
|
||||||
color: #000;
|
color: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn.primary:hover {
|
.action-btn.primary:hover {
|
||||||
|
|
@ -547,7 +547,7 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 560px;
|
max-width: 560px;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
background: var(--bg-primary, #0a0a0a);
|
background: var(--bg, #0a0a0a);
|
||||||
border: 1px solid var(--border, #2a2a2a);
|
border: 1px solid var(--border, #2a2a2a);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: var(--shadow-xl);
|
box-shadow: var(--shadow-xl);
|
||||||
|
|
@ -671,7 +671,7 @@
|
||||||
.crm-form-btn.primary {
|
.crm-form-btn.primary {
|
||||||
background: var(--accent, #d4f505);
|
background: var(--accent, #d4f505);
|
||||||
border: 1px solid var(--accent, #d4f505);
|
border: 1px solid var(--accent, #d4f505);
|
||||||
color: #000;
|
color: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.crm-form-btn.primary:hover {
|
.crm-form-btn.primary:hover {
|
||||||
|
|
@ -801,3 +801,94 @@
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
text-align: center;
|
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 -->
|
<!-- CRM - Customer Relationship Management -->
|
||||||
<!-- Dynamics nomenclature: Lead → Opportunity → Account/Contact -->
|
<!-- 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">
|
<link rel="stylesheet" href="/suite/crm/crm.css">
|
||||||
<script src="/suite/js/vendor/htmx.min.js"></script>
|
<script src="/suite/js/vendor/htmx.min.js"></script>
|
||||||
<script src="/suite/js/vendor/htmx-json-enc.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="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="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="contacts" data-i18n="crm-contacts">Contacts</button>
|
||||||
|
<button class="crm-tab" data-view="campaigns" data-i18n="crm-campaigns">Campaigns</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="crm-header-right">
|
<div class="crm-header-right">
|
||||||
|
|
@ -274,6 +241,18 @@
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Modal for forms -->
|
<!-- Modal for forms -->
|
||||||
|
|
|
||||||
|
|
@ -462,6 +462,79 @@
|
||||||
<span class="desktop-icon-label">CRM</span>
|
<span class="desktop-icon-label">CRM</span>
|
||||||
</div>
|
</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
|
<div
|
||||||
class="desktop-icon"
|
class="desktop-icon"
|
||||||
data-app-id="tasks"
|
data-app-id="tasks"
|
||||||
|
|
@ -581,6 +654,54 @@
|
||||||
<span class="desktop-icon-label">Editor</span>
|
<span class="desktop-icon-label">Editor</span>
|
||||||
</div>
|
</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
|
<div
|
||||||
class="desktop-icon"
|
class="desktop-icon"
|
||||||
data-app-id="browser"
|
data-app-id="browser"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
const botCoderTerminal = {
|
const botCoderTerminal = {
|
||||||
term: null,
|
term: null,
|
||||||
ws: null,
|
ws: null,
|
||||||
|
sessionId: null,
|
||||||
|
reconnectAttempts: 0,
|
||||||
|
maxReconnectAttempts: 5,
|
||||||
|
|
||||||
init: function() {
|
init: function() {
|
||||||
if (!window.Terminal) {
|
if (!window.Terminal) {
|
||||||
|
|
@ -13,50 +16,126 @@ const botCoderTerminal = {
|
||||||
theme: {
|
theme: {
|
||||||
background: '#0f172a',
|
background: '#0f172a',
|
||||||
foreground: '#f8fafc',
|
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,
|
fontSize: 13,
|
||||||
cursorBlink: true
|
cursorBlink: true,
|
||||||
|
cursorStyle: 'block',
|
||||||
|
allowProposedApi: true,
|
||||||
|
scrollback: 10000
|
||||||
});
|
});
|
||||||
|
|
||||||
this.term.open(document.getElementById('xtermContainer'));
|
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(data => {
|
||||||
this.term.onData(e => {
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||||
if (e === '\r') {
|
this.ws.send(data);
|
||||||
this.term.write('\r\n$ ');
|
}
|
||||||
} else if (e === '\u007f') { // Backspace
|
});
|
||||||
this.term.write('\b \b');
|
|
||||||
} else {
|
this.term.onResize(({ cols, rows }) => {
|
||||||
this.term.write(e);
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.send(`resize ${cols} ${rows}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize WebSocket (mock endpoint)
|
|
||||||
this.connect();
|
this.connect();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
generateSessionId: function() {
|
||||||
|
return 'term-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
|
||||||
|
},
|
||||||
|
|
||||||
connect: function() {
|
connect: function() {
|
||||||
// ws = new WebSocket('ws://localhost:8080/ws/terminal/session-123');
|
this.sessionId = this.generateSessionId();
|
||||||
// ws.onmessage = (msg) => this.term.write(msg.data);
|
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() {
|
newTerminal: function() {
|
||||||
alert("New terminal tab created!");
|
if (this.ws) {
|
||||||
|
this.ws.close();
|
||||||
|
}
|
||||||
|
this.connect();
|
||||||
},
|
},
|
||||||
|
|
||||||
closeTerminal: function() {
|
closeTerminal: function() {
|
||||||
alert("Terminal tab closed!");
|
if (this.ws) {
|
||||||
|
this.ws.send('\\exit');
|
||||||
|
this.ws.close();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
clearTerminal: function() {
|
clearTerminal: function() {
|
||||||
if (this.term) {
|
if (this.term) {
|
||||||
this.term.clear();
|
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') {
|
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
||||||
botCoderTerminal.init();
|
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>
|
</svg>
|
||||||
Accounts
|
Accounts
|
||||||
</button>
|
</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>
|
</nav>
|
||||||
|
|
||||||
<!-- Main Content Area -->
|
<!-- 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