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 ``;
+}
+
+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 {
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)"}.
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('
{
+ const html = renderMessageHtml(' [[media: img_"onclick]] [IMAGE_URL: javascript:alert(1)]');
+ expect(html).toContain("<script>");
+ expect(html).not.toContain("