diff --git a/src/admin/brand.ts b/src/admin/brand.ts new file mode 100644 index 00000000..bf978139 --- /dev/null +++ b/src/admin/brand.ts @@ -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"; +} diff --git a/src/admin/format.ts b/src/admin/format.ts index 720a6d77..56a51314 100644 --- a/src/admin/format.ts +++ b/src/admin/format.ts @@ -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 }); } diff --git a/src/admin/messageHtml.ts b/src/admin/messageHtml.ts new file mode 100644 index 00000000..e69b8191 --- /dev/null +++ b/src/admin/messageHtml.ts @@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]!)); +} + +const MARKER_RE = /\[\[media:\s*([A-Za-z0-9_-]{1,80})\]\]|\[IMAGE_URL:\s*(https?:\/\/[^\s\]]+)\]/g; + +function imgTag(src: string): string { + return `imagen`; +} + +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; +} diff --git a/src/admin/routes.ts b/src/admin/routes.ts index 05219fb4..eda2e93d 100644 --- a/src/admin/routes.ts +++ b/src/admin/routes.ts @@ -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"; @@ -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 + "/")); diff --git a/src/admin/views/conexiones.ts b/src/admin/views/conexiones.ts index 933275a6..03f050b7 100644 --- a/src/admin/views/conexiones.ts +++ b/src/admin/views/conexiones.ts @@ -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 { @@ -166,7 +167,7 @@ export function renderConexiones(env: Env): string {

Canales conectados: ${connected} de ${channels.length}

-

Conecta los canales donde están tus clientes. Cuando un canal queda listo, su tarjeta se pone verde. Los secrets se configuran con wrangler secret put NOMBRE (o pídeselo a Claude Code).

+

Conecta los canales donde están tus clientes. Cuando un canal queda listo, su tarjeta se pone verde. Los secrets se configuran con wrangler secret put NOMBRE${hidesForja(env) ? "" : " (o pídeselo a Claude Code)"}.

${cards} diff --git a/src/admin/views/conversations.ts b/src/admin/views/conversations.ts index ead74d5d..d557f7b7 100644 --- a/src/admin/views/conversations.ts +++ b/src/admin/views/conversations.ts @@ -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 { @@ -327,7 +328,7 @@ export async function renderThreadLive(env: Env, convId: string): Promise { 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. @@ -347,7 +348,7 @@ export async function renderThreadLive(env: Env, convId: string): Promise -
${escapeHtml(m.content)}
+
${renderMessageHtml(m.content)}
${time}
`; } @@ -363,7 +364,7 @@ export async function renderThreadLive(env: Env, convId: string): Promise ${chips} -
${escapeHtml(m.content)}
+
${renderMessageHtml(m.content)}
${meta}
`; }) diff --git a/src/admin/views/layout.ts b/src/admin/views/layout.ts index 4f65f513..3896b88e 100644 --- a/src/admin/views/layout.ts +++ b/src/admin/views/layout.ts @@ -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"; @@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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); @@ -294,7 +303,7 @@ function sidebar(activeTab: string, pro: boolean, niche: NichePack | null): stri
-
Forja
+
${brand}
Panel · ${pro ? "Pro" : "Free"}
@@ -333,7 +342,7 @@ export function layout(opts: { title: string; activeTab: string; body: string; e
- ${sidebar(opts.activeTab, pro, niche)} + ${sidebar(opts.activeTab, pro, niche, opts.env)}
@@ -411,10 +420,12 @@ export function renderUpgrade(env: Env, feature?: string): string { El panel Pro le suma el cerebro analítico y de crecimiento:

${perks}
- Esta función no está en tu panel. Pídele a quien administra el bot que la active.

` + : `
Subir a Pro con la comunidad - + `}
`; return layout({ title: "Pro", activeTab: "overview", body, env }); diff --git a/src/admin/views/leads.ts b/src/admin/views/leads.ts index 42be2cbf..8b94170b 100644 --- a/src/admin/views/leads.ts +++ b/src/admin/views/leads.ts @@ -27,7 +27,7 @@ export async function renderLeads(env: Env): Promise { // 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) => `${fmtDate(l.created_at)}` }, + { h: "Fecha", w: "94px", cell: (l) => `${fmtDate(l.created_at, {}, env)}` }, { h: "Nombre", w: "minmax(120px,1.1fr)", cell: (l) => `${esc(l.name) || "(sin nombre)"}` }, { h: "Contacto", w: "minmax(110px,1fr)", cell: (l) => `${esc(l.contact) || "—"}` }, ]; @@ -57,7 +57,7 @@ export async function renderLeads(env: Env): Promise { 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 ? ` Ver conversación completa diff --git a/src/admin/views/overview.ts b/src/admin/views/overview.ts index ff052828..9cb03e9b 100644 --- a/src/admin/views/overview.ts +++ b/src/admin/views/overview.ts @@ -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( @@ -252,9 +254,24 @@ export async function renderOverview(env: Env): Promise { ver todas
`; + const showMonthCost = !hiddenTabs(env).includes("costs"); + const costFooter = hidesForja(env) ? "IA" : "Claude"; + const monthCostCard = showMonthCost + ? ` +
+
04
+
+ + COSTO DEL MES +
+
$${totalCost.toFixed(2)}
+
${monthMsgs} mensajes · ${costFooter} · 30 días
+
` + : ""; + const body = `
-
+
01
@@ -285,15 +302,7 @@ export async function renderOverview(env: Env): Promise {
nuevos hoy
-
-
04
-
- - COSTO DEL MES -
-
$${totalCost.toFixed(2)}
-
${monthMsgs} mensajes · Claude · 30 días
-
+ ${monthCostCard}
diff --git a/src/admin/views/tickets.ts b/src/admin/views/tickets.ts index 5ac538bf..7b85a623 100644 --- a/src/admin/views/tickets.ts +++ b/src/admin/views/tickets.ts @@ -15,7 +15,7 @@ export async function renderTickets(env: Env): Promise { 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 `
diff --git a/src/config.ts b/src/config.ts index 14be5b31..cb111add 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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); diff --git a/src/env.ts b/src/env.ts index f67c4678..751bf3cd 100644 --- a/src/env.ts +++ b/src/env.ts @@ -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/. diff --git a/test/admin/dashboard-tier.test.ts b/test/admin/dashboard-tier.test.ts index bbdac719..a1e2c6a4 100644 --- a/test/admin/dashboard-tier.test.ts +++ b/test/admin/dashboard-tier.test.ts @@ -43,4 +43,22 @@ describe("dashboard tier gating (nav)", () => { expect(html).toContain("horizontesia.com"); expect(html).toContain("Subir a Pro"); }); + + it("BRAND_HIDE_FORJA quita la marca Forja del sidebar y el enlace a la comunidad", () => { + const env = { BOT_TIER: "pro", BRAND_HIDE_FORJA: "on", BUSINESS_NAME: "NatBell" } as unknown as Env; + const html = layout({ title: "Test", activeTab: "overview", body: "

body

", env }); + expect(html).toContain("NatBell"); + expect(html).not.toContain(">Forja<"); + const upgrade = renderUpgrade(env, "Costos"); + expect(upgrade).not.toContain("horizontesia.com"); + expect(upgrade).toContain("quien administra el bot"); + }); + + it("HIDDEN_TABS=costs saca Costos del menú", () => { + const env = { BOT_TIER: "pro", HIDDEN_TABS: "costs, no-existe" } as unknown as Env; + const html = layout({ title: "Test", activeTab: "overview", body: "

body

", env }); + expect(html).not.toContain('href="/admin/costs"'); + expect(html).toContain('href="/admin/conversations"'); + expect(html).toContain('href="/admin/insights"'); + }); }); diff --git a/test/admin/format.test.ts b/test/admin/format.test.ts new file mode 100644 index 00000000..b173c136 --- /dev/null +++ b/test/admin/format.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { fmtDateTime, panelTimeZone } from "../../src/admin/format"; + +// 2026-10-08 18:00 UTC. México (sin horario de verano) es UTC−6 → 12:00. +const UTC_18 = Date.UTC(2026, 9, 8, 18, 0, 0); +const HOUR: Intl.DateTimeFormatOptions = { + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", +}; + +describe("fechas del panel", () => { + it("usa la zona del negocio cuando no hay BOT_TIMEZONE", () => { + expect(panelTimeZone(null)).toBe("America/Mexico_City"); + expect(fmtDateTime(UTC_18, HOUR)).toContain("12:00"); + expect(fmtDateTime(UTC_18, HOUR)).not.toContain("18:00"); + }); + + it("BOT_TIMEZONE gana sobre la zona compilada del member", () => { + expect(panelTimeZone({ BOT_TIMEZONE: "UTC" })).toBe("UTC"); + expect(fmtDateTime(UTC_18, HOUR, { BOT_TIMEZONE: "UTC" })).toContain("18:00"); + expect(fmtDateTime(UTC_18, HOUR, { BOT_TIMEZONE: "America/Mexico_City" })).toContain("12:00"); + }); + + it("una zona inválida no tira el panel: cae a la del negocio", () => { + expect(panelTimeZone({ BOT_TIMEZONE: "No/Existe" })).toBe("America/Mexico_City"); + expect(fmtDateTime(UTC_18, HOUR, { BOT_TIMEZONE: "No/Existe" })).toContain("12:00"); + }); +}); diff --git a/test/admin/inbox.test.ts b/test/admin/inbox.test.ts index a4313325..8cdbd967 100644 --- a/test/admin/inbox.test.ts +++ b/test/admin/inbox.test.ts @@ -100,6 +100,22 @@ describe("inbox — page and fragments", () => { expect(html).toContain("$0.00"); // turn cost, 4-decimal format expect(html).toContain("🟢 bot activo"); }); + + it("un mensaje con marcador de galería se ve como imagen, no como código", async () => { + const conv = await convs.getOrCreate("whatsapp", "u-media", "Ana"); + await msgs.append(conv.id, "user", "Te mando la foto\n[[media: img_porta]]"); + + const res = await adminApp.request( + `/conversations/thread/${encodeURIComponent(conv.id)}`, + { headers: AUTH }, + env, + ); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain(' { diff --git a/test/admin/messageHtml.test.ts b/test/admin/messageHtml.test.ts new file mode 100644 index 00000000..ea9fa612 --- /dev/null +++ b/test/admin/messageHtml.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { renderMessageHtml } from "../../src/admin/messageHtml"; + +describe("hilo del panel — marcadores de media", () => { + it("pinta [[media: id]] como imagen y no como texto", () => { + const html = renderMessageHtml("Mira\n[[media: img_abc]]"); + expect(html).toContain(' { + const html = renderMessageHtml("foto\n[IMAGE_URL: https://cdn.example/a.png]"); + expect(html).toContain(' { + const html = renderMessageHtml(' [[media: img_"onclick]] [IMAGE_URL: javascript:alert(1)]'); + expect(html).toContain("<script>"); + expect(html).not.toContain("