Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/admin/brand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/** "on" oculta la marca Forja en el panel (white-label / Modo Agencia). */
export function hidesForja(env?: { BRAND_HIDE_FORJA?: string } | null): boolean {
return (env?.BRAND_HIDE_FORJA ?? "").trim().toLowerCase() === "on";
}
36 changes: 30 additions & 6 deletions src/admin/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,47 @@ import { memberConfig } from "../../member/config.local";

// El Worker corre con reloj de sistema en UTC, así que `toLocaleString` /
// `toLocaleDateString` SIN `timeZone` explícito renderizan cada fecha del panel
// en UTC — un miembro en America/Costa_Rica vería las horas 6h adelantadas.
// Este helper ancla TODAS las fechas del panel a la zona horaria del negocio,
// declarada en member/config.local.ts (el dato ya existía; el panel no lo usaba).
const BUSINESS_TZ = memberConfig.timezone || "America/Mexico_City";
// en UTC — en México (UTC−6) las horas salen 6 h adelantadas.
// La zona sale de BOT_TIMEZONE (wrangler) y, si no está, de member/config.local.ts.

const FALLBACK_TZ = "America/Mexico_City";

function isIanaTimeZone(tz: string): boolean {
try {
Intl.DateTimeFormat("en-US", { timeZone: tz });
return true;
} catch {
return false;
}
}

/** Zona IANA del panel. BOT_TIMEZONE gana; si falta o es inválida, la del negocio. */
export function panelTimeZone(env?: { BOT_TIMEZONE?: string } | null): string {
const fromEnv = (env?.BOT_TIMEZONE ?? "").trim();
if (fromEnv && isIanaTimeZone(fromEnv)) return fromEnv;
const fromMember = (memberConfig.timezone ?? "").trim();
if (fromMember && isIanaTimeZone(fromMember)) return fromMember;
return FALLBACK_TZ;
}

/** Fecha + hora en la zona del negocio (reemplaza `new Date(x).toLocaleString("es-MX")`). */
export function fmtDateTime(
ts: number | string | Date,
opts: Intl.DateTimeFormatOptions = {},
env?: { BOT_TIMEZONE?: string } | null,
): string {
return new Date(ts).toLocaleString("es-MX", { timeZone: BUSINESS_TZ, ...opts });
const { timeZone: override, ...rest } = opts;
const timeZone = (typeof override === "string" && override) || panelTimeZone(env);
return new Date(ts).toLocaleString("es-MX", { ...rest, timeZone });
}

/** Solo fecha en la zona del negocio (reemplaza `new Date(x).toLocaleDateString("es-MX")`). */
export function fmtDate(
ts: number | string | Date,
opts: Intl.DateTimeFormatOptions = {},
env?: { BOT_TIMEZONE?: string } | null,
): string {
return new Date(ts).toLocaleDateString("es-MX", { timeZone: BUSINESS_TZ, ...opts });
const { timeZone: override, ...rest } = opts;
const timeZone = (typeof override === "string" && override) || panelTimeZone(env);
return new Date(ts).toLocaleDateString("es-MX", { ...rest, timeZone });
}
33 changes: 33 additions & 0 deletions src/admin/messageHtml.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Texto de un mensaje del hilo, listo para meter en HTML.
*
* El bot guarda la foto dentro del texto (`[IMAGE_URL: https://…]` en este
* repo, o `[[media: img_xxx]]` cuando viene de la galería). Sin este paso el
* panel enseña el marcador tal cual, aunque al cliente sí le haya llegado la foto.
*/

function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]!));
}

const MARKER_RE = /\[\[media:\s*([A-Za-z0-9_-]{1,80})\]\]|\[IMAGE_URL:\s*(https?:\/\/[^\s\]]+)\]/g;

function imgTag(src: string): string {
return `<img src="${escapeHtml(src)}" alt="imagen" style="max-width:100%;max-height:240px;height:auto;display:block;margin-top:6px">`;
}

export function renderMessageHtml(content: string): string {
let html = "";
let last = 0;
for (const match of content.matchAll(MARKER_RE)) {
const index = match.index ?? 0;
html += escapeHtml(content.slice(last, index));
const galleryId = match[1];
const imageUrl = match[2];
if (galleryId) html += imgTag(`/media/${galleryId}`);
else if (imageUrl) html += imgTag(imageUrl);
last = index + match[0].length;
}
html += escapeHtml(content.slice(last));
return html;
}
8 changes: 7 additions & 1 deletion src/admin/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { probeMessagesEndpoint } from "../http/egress";
import type { Env } from "../env";
import { adminAuth } from "./auth";
import { layout, renderUpgrade } from "./views/layout";
import { isPro } from "../config";
import { hiddenTabs, isPro } from "../config";
import { renderOverview } from "./views/overview";
import { renderStats } from "./views/stats";
import { renderCosts } from "./views/costs";
Expand Down Expand Up @@ -80,7 +80,13 @@ const PRO_GATE: Array<[string, string]> = [
["/admin/mejoras", "Mejoras"],
["/admin/campanas", "Campañas"],
];
function pathIsTab(path: string, id: string): boolean {
return path === `/${id}` || path.startsWith(`/${id}/`) || path === `/admin/${id}` || path.startsWith(`/admin/${id}/`);
}

adminApp.use("*", async (c, next) => {
const hidden = hiddenTabs(c.env);
if (hidden.some((id) => pathIsTab(c.req.path, id))) return c.redirect("/admin/overview");
if (isPro(c.env)) return next();
const path = c.req.path;
const hit = PRO_GATE.find(([pre]) => path === pre || path.startsWith(pre + "/"));
Expand Down
3 changes: 2 additions & 1 deletion src/admin/views/conexiones.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// paso 4 del onboarding (CLAUDE.md): conectar canales uno por uno y verlos
// ponerse verdes.
import type { Env } from "../../env";
import { hidesForja } from "../brand";
import { layout } from "./layout";

interface ChannelStatus {
Expand Down Expand Up @@ -166,7 +167,7 @@ export function renderConexiones(env: Env): string {
<div style="display:flex;flex-direction:column;gap:18px">
<div style="display:flex;flex-direction:column;gap:2px">
<h2 class="font-display font-semibold text-[15px] text-cream">Canales conectados: ${connected} de ${channels.length}</h2>
<p class="text-muted text-[12.5px]">Conecta los canales donde están tus clientes. Cuando un canal queda listo, su tarjeta se pone verde. Los secrets se configuran con <span class="font-mono">wrangler secret put NOMBRE</span> (o pídeselo a Claude Code).</p>
<p class="text-muted text-[12.5px]">Conecta los canales donde están tus clientes. Cuando un canal queda listo, su tarjeta se pone verde. Los secrets se configuran con <span class="font-mono">wrangler secret put NOMBRE</span>${hidesForja(env) ? "" : " (o pídeselo a Claude Code)"}.</p>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:14px">
${cards}
Expand Down
7 changes: 4 additions & 3 deletions src/admin/views/conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { costOfUsage, type ModelId } from "../../pricing";
import { channelLabel } from "../../channels/labels";
import { layout } from "./layout";
import { fmtDateTime } from "../format";
import { renderMessageHtml } from "../messageHtml";

/** Tiempo relativo corto en español (ej. "hace 5 min", "hace 2 h", "hace 3 d"). */
function ago(ms: number | null | undefined): string {
Expand Down Expand Up @@ -327,7 +328,7 @@ export async function renderThreadLive(env: Env, convId: string): Promise<string
.map((m) => {
const time = fmtDateTime(m.created_at, {
day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit",
});
}, env);

// Tool chips (what the bot DID this turn) render above the bubble; in a
// reversed column that means AFTER it in the DOM.
Expand All @@ -347,7 +348,7 @@ export async function renderThreadLive(env: Env, convId: string): Promise<string
if (m.role === "user") {
return `
<div style="display:flex;flex-direction:column;align-items:flex-start;gap:4px;max-width:78%">
<div style="background:var(--panel2);border:1px solid var(--line);padding:9px 13px;font-size:12.5px;line-height:1.5;white-space:pre-wrap;color:var(--cream)">${escapeHtml(m.content)}</div>
<div style="background:var(--panel2);border:1px solid var(--line);padding:9px 13px;font-size:12.5px;line-height:1.5;white-space:pre-wrap;color:var(--cream)">${renderMessageHtml(m.content)}</div>
<span style="font-size:9.5px;color:var(--dim)">${time}</span>
</div>`;
}
Expand All @@ -363,7 +364,7 @@ export async function renderThreadLive(env: Env, convId: string): Promise<string
return `
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;max-width:78%;margin-left:auto">
${chips}
<div style="${bubbleBg};padding:9px 13px;font-size:12.5px;line-height:1.5;white-space:pre-wrap;color:var(--cream)">${escapeHtml(m.content)}</div>
<div style="${bubbleBg};padding:9px 13px;font-size:12.5px;line-height:1.5;white-space:pre-wrap;color:var(--cream)">${renderMessageHtml(m.content)}</div>
<span style="font-size:9.5px;color:var(--dim)">${meta}</span>
</div>`;
})
Expand Down
27 changes: 19 additions & 8 deletions src/admin/views/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
// breadcrumb and page title are derived here.

import type { Env } from "../../env";
import { isPro, PRO_ONLY_TABS } from "../../config";
import { isPro, hiddenTabs, PRO_ONLY_TABS } from "../../config";
import { hidesForja } from "../brand";
import { getNiche } from "../../niches";
import type { NichePack } from "../../niches";

Expand Down Expand Up @@ -273,12 +274,20 @@ function applyNiche(item: Item, niche: NichePack | null): Item {
return { ...item, label: niche.navLabel, icon: niche.navIcon };
}

function sidebar(activeTab: string, pro: boolean, niche: NichePack | null): string {
function esc(s: string): string {
return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]!));
}

function sidebar(activeTab: string, pro: boolean, niche: NichePack | null, env?: Env): string {
const hidden = new Set(env ? hiddenTabs(env) : []);
const locked = (id: string) => !pro && (PRO_ONLY_TABS as readonly string[]).includes(id);
const brand = hidesForja(env) ? esc(env?.BUSINESS_NAME?.trim() || "Panel") : "Forja";
const sections = NAV.map((sec) => {
const hasActive = sec.items.some((i) => i.id === activeTab);
const visible = sec.items.filter((i) => !hidden.has(i.id));
if (visible.length === 0) return "";
const hasActive = visible.some((i) => i.id === activeTab);
const labelColor = hasActive ? "var(--accent)" : "var(--dim)";
const items = sec.items
const items = visible
.map((raw) => {
const i = applyNiche(raw, niche);
return locked(i.id) ? navItemLocked(i) : navItem(i, i.id === activeTab);
Expand All @@ -294,7 +303,7 @@ function sidebar(activeTab: string, pro: boolean, niche: NichePack | null): stri
<i data-lucide="terminal" width="18" height="18" style="color:var(--accent)"></i>
</div>
<div style="line-height:1.05">
<div style="font-family:'Space Grotesk';font-weight:700;font-size:15px;letter-spacing:-.02em">Forja</div>
<div style="font-family:'Space Grotesk';font-weight:700;font-size:15px;letter-spacing:-.02em">${brand}</div>
<div style="font-size:9.5px;letter-spacing:.22em;color:var(--dim);text-transform:uppercase">Panel · ${pro ? "Pro" : "Free"}</div>
</div>
</div>
Expand Down Expand Up @@ -333,7 +342,7 @@ export function layout(opts: { title: string; activeTab: string; body: string; e
</head>
<body class="scanlines">
<div class="shell">
${sidebar(opts.activeTab, pro, niche)}
${sidebar(opts.activeTab, pro, niche, opts.env)}
<div style="display:flex;flex-direction:column;min-width:0">
<header style="position:sticky;top:0;z-index:30;background:rgba(20,16,9,.9);backdrop-filter:blur(8px);border-bottom:1px solid var(--line);padding:14px 26px;display:flex;align-items:center;gap:20px">
<div style="min-width:0">
Expand Down Expand Up @@ -411,10 +420,12 @@ export function renderUpgrade(env: Env, feature?: string): string {
El panel <b style="color:var(--cream)">Pro</b> le suma el cerebro analítico y de crecimiento:
</p>
<div style="display:grid;gap:10px;margin-bottom:22px">${perks}</div>
<a href="https://horizontesia.com" target="_blank" rel="noopener" class="bigbtn"
${hidesForja(env)
? `<p style="font-size:13px;color:var(--muted);margin:0">Esta función no está en tu panel. Pídele a quien administra el bot que la active.</p>`
: `<a href="https://horizontesia.com" target="_blank" rel="noopener" class="bigbtn"
style="display:inline-flex;align-items:center;gap:8px;background:var(--accent);border:1px solid var(--accent);color:#1a1206;box-shadow:4px 4px 0 var(--linelit);padding:12px 20px;font-family:'Space Grotesk';font-weight:700;font-size:14px">
<i data-lucide="arrow-up-right" width="17" height="17"></i> Subir a Pro con la comunidad
</a>
</a>`}
</div>
</div>`;
return layout({ title: "Pro", activeTab: "overview", body, env });
Expand Down
4 changes: 2 additions & 2 deletions src/admin/views/leads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export async function renderLeads(env: Env): Promise<string> {
// Columnas: núcleo (fecha, nombre, contacto) + o bien las columnas del nicho
// (leídas de metadata) o bien el "Resumen" genérico + estado (re-etiquetado).
const cols: Col[] = [
{ h: "Fecha", w: "94px", cell: (l) => `<span class="text-dim">${fmtDate(l.created_at)}</span>` },
{ h: "Fecha", w: "94px", cell: (l) => `<span class="text-dim">${fmtDate(l.created_at, {}, env)}</span>` },
{ h: "Nombre", w: "minmax(120px,1.1fr)", cell: (l) => `<span class="text-cream" style="display:flex;align-items:center;gap:7px"><i data-lucide="chevron-right" width="13" height="13" class="chev" style="flex:none;transition:transform .12s ease"></i>${esc(l.name) || "(sin nombre)"}</span>` },
{ h: "Contacto", w: "minmax(110px,1fr)", cell: (l) => `<span class="text-muted">${esc(l.contact) || "—"}</span>` },
];
Expand Down Expand Up @@ -57,7 +57,7 @@ export async function renderLeads(env: Env): Promise<string> {
const rows = list
.map((l) => {
const meta = leadMetadata(l);
const fullDate = fmtDateTime(l.created_at);
const fullDate = fmtDateTime(l.created_at, {}, env);
const convLink = l.conversation_id
? `<a href="/admin/conversations?c=${encodeURIComponent(l.conversation_id)}" class="text-accent" style="display:inline-flex;align-items:center;gap:6px;font-size:12px;text-decoration:none">
<i data-lucide="messages-square" width="13" height="13"></i> Ver conversación completa
Expand Down
29 changes: 19 additions & 10 deletions src/admin/views/overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { InsightsRepo } from "../../db/insights";
import { SuggestionsRepo } from "../../db/suggestions";
import { channelLabel } from "../../channels/labels";
import { getNiche } from "../../niches";
import { hiddenTabs } from "../../config";
import { hidesForja } from "../brand";

function esc(s: string): string {
return s.replace(
Expand Down Expand Up @@ -252,9 +254,24 @@ export async function renderOverview(env: Env): Promise<string> {
<a href="/admin/mejoras" class="flex items-center gap-1 text-[11.5px] mt-2.5">ver todas <i data-lucide="arrow-right" width="13" height="13"></i></a>
</div>`;

const showMonthCost = !hiddenTabs(env).includes("costs");
const costFooter = hidesForja(env) ? "IA" : "Claude";
const monthCostCard = showMonthCost
? `
<div class="card bg-panel border border-line p-4 relative overflow-hidden" style="animation-delay:.14s">
<div class="absolute top-3 right-3 text-[9.5px] tracking-[.2em] text-dim uppercase">04</div>
<div class="flex items-center gap-2 text-muted">
<i data-lucide="coins" width="15" height="15"></i>
<span class="text-[11px] tracking-[.05em]">COSTO DEL MES</span>
</div>
<div class="glow font-display font-bold text-[38px] leading-none mt-3">$${totalCost.toFixed(2)}</div>
<div class="text-[11px] text-dim mt-2">${monthMsgs} mensajes · ${costFooter} · 30 días</div>
</div>`
: "";

const body = `
<div class="flex flex-col gap-[22px]">
<section class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-[14px]">
<section class="grid grid-cols-1 sm:grid-cols-2 ${showMonthCost ? "lg:grid-cols-4" : "lg:grid-cols-3"} gap-[14px]">
<div class="card bg-panel border border-line p-4 relative overflow-hidden" style="animation-delay:.02s">
<div class="absolute top-3 right-3 text-[9.5px] tracking-[.2em] text-dim uppercase">01</div>
<div class="flex items-center gap-2 text-muted">
Expand Down Expand Up @@ -285,15 +302,7 @@ export async function renderOverview(env: Env): Promise<string> {
<div class="text-[11px] text-dim mt-2">nuevos hoy</div>
</div>

<div class="card bg-panel border border-line p-4 relative overflow-hidden" style="animation-delay:.14s">
<div class="absolute top-3 right-3 text-[9.5px] tracking-[.2em] text-dim uppercase">04</div>
<div class="flex items-center gap-2 text-muted">
<i data-lucide="coins" width="15" height="15"></i>
<span class="text-[11px] tracking-[.05em]">COSTO DEL MES</span>
</div>
<div class="glow font-display font-bold text-[38px] leading-none mt-3">$${totalCost.toFixed(2)}</div>
<div class="text-[11px] text-dim mt-2">${monthMsgs} mensajes · Claude · 30 días</div>
</div>
${monthCostCard}
</section>

<section class="card bg-panel border border-line p-[18px]" style="animation-delay:.18s">
Expand Down
2 changes: 1 addition & 1 deletion src/admin/views/tickets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export async function renderTickets(env: Env): Promise<string> {

const list = open
.map((t) => {
const date = fmtDateTime(t.created_at);
const date = fmtDateTime(t.created_at, {}, env);
const pillColor = STATUS_PILL[t.status] ?? "var(--muted)";
return `<div class="tkcard bg-panel border border-line" style="padding:16px 18px;margin-bottom:12px">
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:8px">
Expand Down
14 changes: 14 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,20 @@ export const PRO_ONLY_TOOLS = [
// campañas — esos desbloquean con la comunidad.
export const PRO_ONLY_TABS = ["insights", "stats", "costs", "mejoras", "campanas"] as const;

// Pestañas que una agencia puede quitar del panel del cliente (env HIDDEN_TABS).
// Resumen queda siempre: es la página a la que redirige una ruta oculta.
export const HIDEABLE_TABS = ["insights", "stats", "costs", "mejoras", "campanas"] as const;

/** Ids validados de HIDDEN_TABS. Un id desconocido se ignora. */
export function hiddenTabs(env?: { HIDDEN_TABS?: string } | null): string[] {
const raw = env?.HIDDEN_TABS;
if (!raw) return [];
const allowed = HIDEABLE_TABS as readonly string[];
return [...new Set(
raw.split(",").map((s) => s.trim().toLowerCase()).filter((s) => allowed.includes(s)),
)];
}

export function isToolAvailable(env: Env, toolName: string): boolean {
if (!PRO_ONLY_TOOLS.includes(toolName as (typeof PRO_ONLY_TOOLS)[number])) return true;
return isPro(env);
Expand Down
8 changes: 8 additions & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ export interface Env {
BUSINESS_NAME: string;
BOT_LANGUAGE: string;
BOT_TIER: "free" | "pro";
// Zona del panel (IANA, ej. America/Mexico_City). Si falta, el panel usa
// member/config.local.ts. Sin zona explícita, el Worker formatea en UTC.
BOT_TIMEZONE?: string;
// "on" = panel de cliente white-label: no muestra la marca Forja ni "Claude Code".
BRAND_HIDE_FORJA?: string;
// Ids de pestañas separados por coma (ej. "costs,insights"). Desaparecen del
// nav, de su ruta y —si incluye costs— de la tarjeta "Costo del mes".
HIDDEN_TABS?: string;
// Nicho del bot (restaurante, inmobiliaria…). Selecciona el "niche pack" que
// re-etiqueta el dashboard, aporta el playbook del giro y sus columnas.
// Ausente/desconocido → pack genérico (comportamiento actual). Ver src/niches/.
Expand Down
Loading
Loading