@organizapp/site-kit 0.0.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +171 -7
  2. package/dist/assertion/site-assertion.d.ts +35 -0
  3. package/dist/assertion/site-assertion.js +49 -0
  4. package/dist/chat/chat-reply.d.ts +22 -0
  5. package/dist/chat/chat-reply.js +1 -0
  6. package/dist/chat/send-chat-message.d.ts +32 -0
  7. package/dist/chat/send-chat-message.js +61 -0
  8. package/dist/chat/whatsapp-href.d.ts +7 -0
  9. package/dist/chat/whatsapp-href.js +10 -0
  10. package/dist/crypto/hmac.d.ts +5 -0
  11. package/dist/crypto/hmac.js +47 -0
  12. package/dist/form/index.d.ts +7 -0
  13. package/dist/form/index.js +7 -0
  14. package/dist/form/inquiry-form.d.ts +23 -0
  15. package/dist/form/inquiry-form.js +43 -0
  16. package/dist/form/site-chat.d.ts +29 -0
  17. package/dist/form/site-chat.js +42 -0
  18. package/dist/index.d.ts +16 -1
  19. package/dist/index.js +11 -4
  20. package/dist/inquiry/form-seal.d.ts +18 -0
  21. package/dist/inquiry/form-seal.js +34 -0
  22. package/dist/inquiry/form-state.d.ts +36 -0
  23. package/dist/inquiry/form-state.js +16 -0
  24. package/dist/inquiry/handle-inquiry-form.d.ts +21 -0
  25. package/dist/inquiry/handle-inquiry-form.js +55 -0
  26. package/dist/inquiry/idempotency-key.d.ts +2 -0
  27. package/dist/inquiry/idempotency-key.js +24 -0
  28. package/dist/inquiry/inquiry-fields.d.ts +25 -0
  29. package/dist/inquiry/inquiry-fields.js +1 -0
  30. package/dist/inquiry/marketing-opt-in.d.ts +11 -0
  31. package/dist/inquiry/marketing-opt-in.js +16 -0
  32. package/dist/inquiry/site-environment.d.ts +19 -0
  33. package/dist/inquiry/site-environment.js +44 -0
  34. package/dist/inquiry/submit-inquiry.d.ts +30 -0
  35. package/dist/inquiry/submit-inquiry.js +50 -0
  36. package/dist/inquiry/turnstile.d.ts +6 -0
  37. package/dist/inquiry/turnstile.js +25 -0
  38. package/dist/profile/get-site-profile.d.ts +24 -0
  39. package/dist/profile/get-site-profile.js +41 -0
  40. package/dist/profile/revalidate-handler.d.ts +19 -0
  41. package/dist/profile/revalidate-handler.js +27 -0
  42. package/dist/profile/site-profile.d.ts +26 -0
  43. package/dist/profile/site-profile.js +21 -0
  44. package/package.json +12 -1
package/README.md CHANGED
@@ -1,16 +1,180 @@
1
1
  # @organizapp/site-kit
2
2
 
3
3
  El kit de los sitios de la web pública de Organizapp: lo que se repite en cada
4
- sitio y es delicado (los datos del negocio, el formulario de consulta, la
5
- revalidación). Lo usan los proyectos que salen de la plantilla
4
+ sitio y es delicado. Lo usan los proyectos que salen de la plantilla
6
5
  `organizapp-site-template`.
7
6
 
8
- **Vacío en esta versión**: existe para dejar probada la publicación. Las piezas
9
- llegan con la siguiente etapa.
7
+ **0.4.0: el chat del Asistente**, además de los datos del negocio y el aviso de
8
+ cambio (0.3.0, que no llegó a publicarse sola) y del formulario de consulta con
9
+ la casilla de novedades versionada (0.2.0). La 0.1.0 no se publicó:
10
+ su casilla aceptaba cualquier texto.
10
11
 
11
12
  No depende de nada interno de Organizapp y no lleva secretos: las credenciales
12
- de cada sitio van en las variables de entorno de su proyecto. Se publica solo
13
- desde el workflow `site-kit-publish` del repositorio, sin procedencia (npm solo
14
- la firma desde un repositorio público).
13
+ de cada sitio van en las variables de entorno de su proyecto. React es del sitio
14
+ (`peerDependencies`). Se publica solo desde el workflow `site-kit-publish` del
15
+ repositorio, sin procedencia (npm solo la firma desde un repositorio público).
16
+
17
+ ## Las variables del sitio
18
+
19
+ Ninguna lleva `NEXT_PUBLIC_`: no pueden llegar al navegador.
20
+
21
+ | Variable | Qué |
22
+ | --- | --- |
23
+ | `ORGANIZAPP_API_URL` | la URL de la API de Organizapp, por `https` |
24
+ | `ORGANIZAPP_SITE_ID` | el id del sitio, el que muestra el panel |
25
+ | `ORGANIZAPP_SITE_SECRET` | el secreto de la API, que el panel muestra una sola vez |
26
+ | `ORGANIZAPP_REVALIDATION_SECRET` | el secreto del aviso, que el panel muestra junto con el de la API |
27
+ | `TURNSTILE_SECRET_KEY` | opcional: solo si el sitio prende Turnstile |
28
+
29
+ ## Dos entradas
30
+
31
+ - `@organizapp/site-kit`: lo del **servidor** (firma con el secreto). Nunca se
32
+ importa desde un Client Component.
33
+ - `@organizapp/site-kit/form`: el formulario y el chat, que corren en el
34
+ navegador y no conocen ningún secreto.
35
+
36
+ ## El formulario
37
+
38
+ La página de contacto se dibuja **por pedido** (`dynamic = "force-dynamic"`):
39
+ el sello de hora se firma al dibujarla, y una página estática llevaría el de la
40
+ hora del build.
41
+
42
+ ```tsx
43
+ // app/contacto/page.tsx
44
+ import { createFormSeal } from "@organizapp/site-kit";
45
+ import { InquiryForm } from "@organizapp/site-kit/form";
46
+ import { sendInquiry } from "./actions";
47
+
48
+ export const dynamic = "force-dynamic";
49
+
50
+ export default async function Contacto() {
51
+ const seal = await createFormSeal({ secret: process.env.ORGANIZAPP_SITE_SECRET ?? "", nowMs: Date.now() });
52
+ return <InquiryForm action={sendInquiry} seal={seal} contact="either" policyHref="/privacidad" businessName="Estudio Sur" />;
53
+ }
54
+ ```
55
+
56
+ ```ts
57
+ // app/contacto/actions.ts
58
+ "use server";
59
+ import { headers } from "next/headers";
60
+ import { clientIpFromHeaders, handleInquiryForm, siteConnectionFromEnv, type InquiryFormState } from "@organizapp/site-kit";
61
+
62
+ export async function sendInquiry(_state: InquiryFormState, data: FormData): Promise<InquiryFormState> {
63
+ return handleInquiryForm(data, {
64
+ connection: siteConnectionFromEnv(),
65
+ clientIp: clientIpFromHeaders(await headers()),
66
+ sourcePath: "/contacto",
67
+ policyVersion: "2026-10",
68
+ });
69
+ }
70
+ ```
71
+
72
+ `contact` repite lo que el sitio eligió en el CRM (`either`, `phone`, `email`,
73
+ `both`): el formulario marca los campos, y la API lo exige igual.
74
+
75
+ **La casilla de novedades** dice «Quiero recibir novedades de {businessName}
76
+ por WhatsApp.» y arranca sin marcar, como pide la política de opt-in de Meta.
77
+ El texto lo fija el kit y no el sitio: marcada, viaja con
78
+ `MARKETING_OPT_IN_VERSION`, que el CRM guarda como prueba de qué se aceptó. Con
79
+ teléfono, ese «sí» hace que la persona pueda recibir campañas por WhatsApp
80
+ cuando sea contacto del negocio. Cambiar el texto es cambiar la versión.
81
+
82
+ **Qué hace antes de llamar a la API**, en orden:
83
+
84
+ 1. **Campo trampa**: un campo fuera de la pantalla (no `display:none`), fuera del
85
+ tab y del árbol de accesibilidad. Lleno, es un bot.
86
+ 2. **Sello de hora**: menos de 2 segundos desde que se dibujó, es un bot; más de
87
+ 24 horas, se pide recargar la página.
88
+ 3. **Turnstile**, solo si está prendido.
89
+
90
+ Lo que delata a un bot recibe **las mismas gracias** que un envío bueno y no
91
+ llega a la API: no aprende qué lo delató ni gasta el cupo del sitio.
92
+
93
+ **La clave de idempotencia** sale del sello y de lo escrito: el mismo envío
94
+ repetido (un doble clic, una red cortada) es un duplicado y no otra consulta;
95
+ corregir un campo después de un error es otro envío.
96
+
97
+ ## Los datos del negocio
98
+
99
+ `getSiteProfile` lee nombre, eslogan, teléfonos, emails, WhatsApp, dirección,
100
+ mapa, horario y redes. **No cachea**: la firma cambia en cada pedido y la caché
101
+ de `fetch` de Next usa los headers en su clave. El sitio cachea el resultado con
102
+ la etiqueta `SITE_PROFILE_TAG`, y la ruta del aviso la invalida:
103
+
104
+ ```ts
105
+ // lib/profile.ts
106
+ import { unstable_cache } from "next/cache";
107
+ import { SITE_PROFILE_TAG, getSiteProfile, siteConnectionFromEnv } from "@organizapp/site-kit";
108
+
109
+ export const siteProfile = unstable_cache(
110
+ () => getSiteProfile(siteConnectionFromEnv()), ["organizapp-site-profile"], { tags: [SITE_PROFILE_TAG] },
111
+ );
112
+
113
+ // app/api/organizapp/revalidate/route.ts
114
+ import { revalidateTag } from "next/cache";
115
+ import { createRevalidateHandler, revalidationFromEnv } from "@organizapp/site-kit";
116
+
117
+ export const POST = createRevalidateHandler({ connection: revalidationFromEnv(), revalidate: revalidateTag });
118
+ ```
119
+
120
+ Sin datos guardados en el CRM es `not_set`, y ante un fallo el sitio muestra lo
121
+ último que cacheó. Los teléfonos vienen en dígitos con código de país: el sitio
122
+ elige cómo mostrarlos (`tel:+<número>`, `https://wa.me/<número>`).
123
+
124
+ ## El chat del Asistente
125
+
126
+ `<SiteChat>` atiende con el Asistente del negocio, **solo cuando está en vivo**
127
+ en el CRM. Si el Asistente deriva, o el chat no está, el widget muestra lo que
128
+ el sitio le pase en `handoff` (el formulario de consulta) y el enlace a
129
+ WhatsApp. No hay chat en vivo con una persona.
130
+
131
+ La conversación la guarda Organizapp por un día después del último mensaje; el
132
+ navegador no guarda nada. El sitio solo pone una cookie `httpOnly` con el token
133
+ de la sesión, desde su Server Action:
134
+
135
+ ```ts
136
+ // app/chat-actions.ts
137
+ "use server";
138
+ import { cookies, headers } from "next/headers";
139
+ import { CHAT_COOKIE, CHAT_COOKIE_OPTIONS, clientIpFromHeaders, sendChatMessage, siteConnectionFromEnv } from "@organizapp/site-kit";
140
+ import type { SiteChatReply } from "@organizapp/site-kit/form";
141
+
142
+ export async function sendChat(input: { text: string }): Promise<SiteChatReply> {
143
+ const clientIp = clientIpFromHeaders(await headers());
144
+ if (clientIp === null) return { kind: "unavailable" };
145
+ const jar = await cookies();
146
+ const { sessionToken, reply } = await sendChatMessage(siteConnectionFromEnv(), {
147
+ sessionToken: jar.get(CHAT_COOKIE)?.value ?? null, text: input.text, clientIp,
148
+ });
149
+ if (sessionToken !== null) jar.set(CHAT_COOKIE, sessionToken, CHAT_COOKIE_OPTIONS);
150
+ return reply;
151
+ }
152
+ ```
153
+
154
+ ```tsx
155
+ // en la página, con el formulario de consulta como salida
156
+ <SiteChat action={sendChat} assistantName="Sofía" whatsapp={profile.whatsapp}
157
+ handoff={<InquiryForm action={sendInquiry} seal={seal} contact="either" policyHref="/privacidad" businessName="Estudio Sur" />} />
158
+ ```
159
+
160
+ Una sesión admite 30 mensajes; quien escribe de más recibe `busy` («Probá en
161
+ unos minutos»). El widget arranca cerrado, detrás de un botón.
162
+
163
+ ## Turnstile, apagado
164
+
165
+ Se prende por sitio si aparece spam: `turnstileSiteKey` en `<InquiryForm>` y
166
+ `turnstileSecret: process.env.TURNSTILE_SECRET_KEY` en `handleInquiryForm`. La
167
+ CSP del sitio tiene que dejar `https://challenges.cloudflare.com` en `script-src`
168
+ y `frame-src`.
169
+
170
+ ## Para quien mantiene el kit
171
+
172
+ - La aserción repite el formato de `packages/service-auth` (que el kit no puede
173
+ importar): si cambia uno, cambia el otro.
174
+ - Los imports relativos llevan `.js`: Node no resuelve `../x` en un paquete ESM.
175
+ Lo fija `package-contract.test.ts`.
176
+ - El contrato de la API: `docs/domain/website/inquiries.md` (consultas),
177
+ `docs/domain/website/profile.md` (datos y aviso) y
178
+ `docs/domain/website/chat.md` (chat).
15
179
 
16
180
  Licencia propietaria: ver `LICENSE`.
@@ -0,0 +1,35 @@
1
+ /**
2
+ * La aserción con la que el servidor del sitio se presenta ante la API de
3
+ * consultas. Repite el formato de `packages/service-auth/src/assertion/service-assertion.ts`
4
+ * —el kit no puede importar nada interno—: si cambia uno, cambia el otro.
5
+ *
6
+ * `v1.<payload base64url>.<firma base64url>`, con el payload
7
+ * `{ aud, tid, pid, iat, exp, n, sid, ip }`. Sin tenant ni perfil: el CRM saca el
8
+ * negocio del sitio, nunca de lo que diga el pedido. Vale 60 segundos.
9
+ *
10
+ * Cuatro audiencias: `website:inquiry` y `website:chat` (una consulta o un
11
+ * mensaje del chat, con la IP de quien escribió), `website:profile` (leer los
12
+ * datos, sin visitante: IP vacía) y `website:revalidate`, la que firma el CRM
13
+ * con el secreto de revalidación.
14
+ */
15
+ export type SiteAudience = "website:inquiry" | "website:profile" | "website:revalidate" | "website:chat";
16
+ export declare const SITE_HEADER = "x-organizapp-site";
17
+ export declare const ASSERTION_HEADER = "x-organizapp-assertion";
18
+ export declare function signSiteAssertion(input: {
19
+ readonly secret: string;
20
+ readonly siteId: string;
21
+ readonly clientIp: string;
22
+ readonly nowSeconds: number;
23
+ readonly audience?: Exclude<SiteAudience, "website:revalidate">;
24
+ }): Promise<string>;
25
+ /**
26
+ * Verifica una aserción que firmó el CRM para este sitio: la firma primero, y
27
+ * recién después se lee el payload. Vencida, de otra audiencia o de otro sitio
28
+ * es `false`, sin distinguir por qué.
29
+ */
30
+ export declare function verifySiteAssertion(header: string, expected: {
31
+ readonly secret: string;
32
+ readonly audience: SiteAudience;
33
+ readonly siteId: string;
34
+ readonly nowSeconds: number;
35
+ }): Promise<boolean>;
@@ -0,0 +1,49 @@
1
+ import { base64UrlJson, hmacSign, hmacVerify } from "../crypto/hmac.js";
2
+ const VERSION = "v1";
3
+ const TTL_SECONDS = 60;
4
+ export const SITE_HEADER = "x-organizapp-site";
5
+ export const ASSERTION_HEADER = "x-organizapp-assertion";
6
+ export async function signSiteAssertion(input) {
7
+ const payload = base64UrlJson({
8
+ aud: input.audience ?? "website:inquiry",
9
+ tid: null,
10
+ pid: null,
11
+ iat: input.nowSeconds,
12
+ exp: input.nowSeconds + TTL_SECONDS,
13
+ n: crypto.randomUUID(),
14
+ sid: input.siteId,
15
+ ip: input.clientIp,
16
+ });
17
+ const signed = `${VERSION}.${payload}`;
18
+ return `${signed}.${await hmacSign(input.secret, signed)}`;
19
+ }
20
+ function payloadOf(text) {
21
+ if (!/^[A-Za-z0-9_-]+$/u.test(text))
22
+ return null;
23
+ try {
24
+ const value = JSON.parse(atob(text.replace(/-/gu, "+").replace(/_/gu, "/")));
25
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ /**
32
+ * Verifica una aserción que firmó el CRM para este sitio: la firma primero, y
33
+ * recién después se lee el payload. Vencida, de otra audiencia o de otro sitio
34
+ * es `false`, sin distinguir por qué.
35
+ */
36
+ export async function verifySiteAssertion(header, expected) {
37
+ const [version, payload, signature, extra] = header.split(".");
38
+ if (version !== VERSION || payload === undefined || signature === undefined || extra !== undefined)
39
+ return false;
40
+ if (!(await hmacVerify(expected.secret, `${VERSION}.${payload}`, signature)))
41
+ return false;
42
+ const claims = payloadOf(payload);
43
+ if (claims === null || typeof claims["iat"] !== "number" || typeof claims["exp"] !== "number")
44
+ return false;
45
+ return claims["aud"] === expected.audience
46
+ && claims["sid"] === expected.siteId
47
+ && claims["exp"] >= expected.nowSeconds
48
+ && claims["exp"] - claims["iat"] <= TTL_SECONDS;
49
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Lo que el widget muestra después de un mensaje. Lo comparten el servidor del
3
+ * sitio (`sendChatMessage`) y el navegador (`<SiteChat>`), así que no importa
4
+ * nada: es solo la forma.
5
+ *
6
+ * - `answer`: la respuesta del Asistente.
7
+ * - `handoff`: el Asistente deriva. El widget ofrece dejar una consulta y
8
+ * escribir por WhatsApp; no hay chat en vivo con una persona.
9
+ * - `busy`: quien escribe mandó demasiados mensajes seguidos.
10
+ * - `unavailable`: el chat no está (apagado, caído o sin red). El widget ofrece
11
+ * lo mismo que una derivación.
12
+ */
13
+ export type SiteChatReply = {
14
+ readonly kind: "answer";
15
+ readonly text: string;
16
+ } | {
17
+ readonly kind: "handoff";
18
+ } | {
19
+ readonly kind: "busy";
20
+ } | {
21
+ readonly kind: "unavailable";
22
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,32 @@
1
+ import type { SiteConnection } from "../inquiry/inquiry-fields.js";
2
+ import type { SiteChatReply } from "./chat-reply.js";
3
+ /**
4
+ * Manda un mensaje del chat del Asistente (W-4) a Organizapp. Corre en el
5
+ * SERVIDOR del sitio: firma con `ORGANIZAPP_SITE_SECRET` y la IP de quien
6
+ * escribe, como las consultas.
7
+ *
8
+ * La conversación vive en Organizapp, no en el navegador: el sitio guarda solo
9
+ * el token de la sesión, en la cookie `CHAT_COOKIE` con `CHAT_COOKIE_OPTIONS`
10
+ * (`httpOnly`, así el JavaScript de la página no lo lee). `sessionToken` es el
11
+ * que hay que volver a guardar; `null` es «dejá la cookie como está».
12
+ */
13
+ export declare const CHAT_COOKIE = "organizapp_chat";
14
+ export declare const CHAT_COOKIE_OPTIONS: {
15
+ readonly httpOnly: true;
16
+ readonly secure: true;
17
+ readonly sameSite: "lax";
18
+ readonly path: "/";
19
+ readonly maxAge: 86400;
20
+ };
21
+ export interface ChatMessageResult {
22
+ readonly sessionToken: string | null;
23
+ readonly reply: SiteChatReply;
24
+ }
25
+ export declare function sendChatMessage(connection: SiteConnection, input: {
26
+ readonly sessionToken: string | null;
27
+ readonly text: string;
28
+ readonly clientIp: string;
29
+ }, deps?: {
30
+ readonly fetch?: typeof fetch;
31
+ readonly nowSeconds?: number;
32
+ }): Promise<ChatMessageResult>;
@@ -0,0 +1,61 @@
1
+ import { ASSERTION_HEADER, SITE_HEADER, signSiteAssertion } from "../assertion/site-assertion.js";
2
+ /**
3
+ * Manda un mensaje del chat del Asistente (W-4) a Organizapp. Corre en el
4
+ * SERVIDOR del sitio: firma con `ORGANIZAPP_SITE_SECRET` y la IP de quien
5
+ * escribe, como las consultas.
6
+ *
7
+ * La conversación vive en Organizapp, no en el navegador: el sitio guarda solo
8
+ * el token de la sesión, en la cookie `CHAT_COOKIE` con `CHAT_COOKIE_OPTIONS`
9
+ * (`httpOnly`, así el JavaScript de la página no lo lee). `sessionToken` es el
10
+ * que hay que volver a guardar; `null` es «dejá la cookie como está».
11
+ */
12
+ export const CHAT_COOKIE = "organizapp_chat";
13
+ export const CHAT_COOKIE_OPTIONS = { httpOnly: true, secure: true, sameSite: "lax", path: "/", maxAge: 86_400 };
14
+ const TIMEOUT_MS = 30_000;
15
+ const PATH = "/api/sites/v1/chat";
16
+ const UNAVAILABLE = { sessionToken: null, reply: { kind: "unavailable" } };
17
+ function readReply(value) {
18
+ if (typeof value !== "object" || value === null)
19
+ return UNAVAILABLE;
20
+ const { sessionToken, reply } = value;
21
+ if (typeof sessionToken !== "string" || typeof reply !== "object" || reply === null)
22
+ return UNAVAILABLE;
23
+ const { kind, text } = reply;
24
+ if (kind === "handoff")
25
+ return { sessionToken, reply: { kind } };
26
+ if (kind === "answer" && typeof text === "string")
27
+ return { sessionToken, reply: { kind, text } };
28
+ return UNAVAILABLE;
29
+ }
30
+ export async function sendChatMessage(connection, input, deps = {}) {
31
+ const assertion = await signSiteAssertion({
32
+ secret: connection.siteSecret,
33
+ siteId: connection.siteId,
34
+ clientIp: input.clientIp,
35
+ audience: "website:chat",
36
+ nowSeconds: deps.nowSeconds ?? Math.floor(Date.now() / 1000),
37
+ });
38
+ let response;
39
+ try {
40
+ response = await (deps.fetch ?? fetch)(`${connection.apiUrl.replace(/\/+$/u, "")}${PATH}`, {
41
+ method: "POST",
42
+ headers: { "content-type": "application/json", [SITE_HEADER]: connection.siteId, [ASSERTION_HEADER]: assertion },
43
+ body: JSON.stringify({ sessionToken: input.sessionToken, text: input.text }),
44
+ signal: AbortSignal.timeout(TIMEOUT_MS),
45
+ cache: "no-store",
46
+ });
47
+ }
48
+ catch {
49
+ return UNAVAILABLE;
50
+ }
51
+ if (response.status === 429)
52
+ return { sessionToken: null, reply: { kind: "busy" } };
53
+ if (response.status !== 200)
54
+ return UNAVAILABLE;
55
+ try {
56
+ return readReply(await response.json());
57
+ }
58
+ catch {
59
+ return UNAVAILABLE;
60
+ }
61
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * `https://wa.me/<solo dígitos>`, o `null` si no hay número. Vive fuera de
3
+ * `<SiteChat>` porque ese módulo es de cliente, y una función de un módulo de
4
+ * cliente no se puede llamar desde un Server Component (el pie del sitio, por
5
+ * ejemplo).
6
+ */
7
+ export declare function whatsappHref(phone: string | null): string | null;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `https://wa.me/<solo dígitos>`, o `null` si no hay número. Vive fuera de
3
+ * `<SiteChat>` porque ese módulo es de cliente, y una función de un módulo de
4
+ * cliente no se puede llamar desde un Server Component (el pie del sitio, por
5
+ * ejemplo).
6
+ */
7
+ export function whatsappHref(phone) {
8
+ const digits = (phone ?? "").replace(/\D/gu, "");
9
+ return digits === "" ? null : `https://wa.me/${digits}`;
10
+ }
@@ -0,0 +1,5 @@
1
+ export declare function toBase64Url(bytes: Uint8Array): string;
2
+ export declare function hmacBytes(secret: string, text: string): Promise<Uint8Array>;
3
+ export declare function hmacSign(secret: string, text: string): Promise<string>;
4
+ export declare function hmacVerify(secret: string, text: string, signature: string): Promise<boolean>;
5
+ export declare function base64UrlJson(value: unknown): string;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * HMAC-SHA256 con Web Crypto: corre igual en Node y en Edge, y `subtle.verify`
3
+ * compara en tiempo constante. Lo usan la aserción y el sello del formulario,
4
+ * cada uno con su propio prefijo para que una firma de uno no valga en el otro.
5
+ */
6
+ const encoder = new TextEncoder();
7
+ const MIN_KEY_LENGTH = 16;
8
+ export function toBase64Url(bytes) {
9
+ let binary = "";
10
+ for (const byte of bytes)
11
+ binary += String.fromCharCode(byte);
12
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
13
+ }
14
+ function fromBase64Url(text) {
15
+ if (!/^[A-Za-z0-9_-]+$/.test(text))
16
+ return null;
17
+ try {
18
+ const binary = atob(text.replace(/-/g, "+").replace(/_/g, "/"));
19
+ const bytes = new Uint8Array(binary.length);
20
+ for (let index = 0; index < binary.length; index += 1)
21
+ bytes[index] = binary.charCodeAt(index);
22
+ return bytes;
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ async function hmacKey(secret) {
29
+ if (secret.length < MIN_KEY_LENGTH)
30
+ throw new Error(`el secreto del sitio tiene que tener al menos ${MIN_KEY_LENGTH} caracteres`);
31
+ return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
32
+ }
33
+ export async function hmacBytes(secret, text) {
34
+ return new Uint8Array(await crypto.subtle.sign("HMAC", await hmacKey(secret), encoder.encode(text)));
35
+ }
36
+ export async function hmacSign(secret, text) {
37
+ return toBase64Url(await hmacBytes(secret, text));
38
+ }
39
+ export async function hmacVerify(secret, text, signature) {
40
+ const bytes = fromBase64Url(signature);
41
+ if (bytes === null)
42
+ return false;
43
+ return crypto.subtle.verify("HMAC", await hmacKey(secret), bytes, encoder.encode(text));
44
+ }
45
+ export function base64UrlJson(value) {
46
+ return toBase64Url(encoder.encode(JSON.stringify(value)));
47
+ }
@@ -0,0 +1,7 @@
1
+ export { InquiryForm } from "./inquiry-form.js";
2
+ export type { InquiryFormProps } from "./inquiry-form.js";
3
+ export type { InquiryContactRequirement, InquiryFormState } from "../inquiry/form-state.js";
4
+ export { SiteChat } from "./site-chat.js";
5
+ export { whatsappHref } from "../chat/whatsapp-href.js";
6
+ export type { SiteChatProps } from "./site-chat.js";
7
+ export type { SiteChatReply } from "../chat/chat-reply.js";
@@ -0,0 +1,7 @@
1
+ // @organizapp/site-kit/form
2
+ //
3
+ // Lo que corre en el navegador: el formulario, el chat y lo que comparten con su acción.
4
+ // Nada de acá firma ni conoce un secreto; eso vive en la entrada principal.
5
+ export { InquiryForm } from "./inquiry-form.js";
6
+ export { SiteChat } from "./site-chat.js";
7
+ export { whatsappHref } from "../chat/whatsapp-href.js";
@@ -0,0 +1,23 @@
1
+ import { type InquiryContactRequirement, type InquiryFormState } from "../inquiry/form-state.js";
2
+ /**
3
+ * El formulario de consulta. Marcado semántico y sin estilos propios: el sitio
4
+ * lo viste con `className` y los `data-organizapp-*`. La acción es la Server
5
+ * Action del sitio, que llama a `handleInquiryForm`; acá no hay ningún secreto.
6
+ *
7
+ * El campo trampa va FUERA de la pantalla y no con `display:none`: un bot que
8
+ * lee el CSS saltea lo oculto, pero completa lo que existe. Fuera del tab y del
9
+ * árbol de accesibilidad, una persona no lo ve ni lo alcanza.
10
+ */
11
+ export interface InquiryFormProps {
12
+ readonly action: (state: InquiryFormState, data: FormData) => Promise<InquiryFormState>;
13
+ /** `await createFormSeal(...)` en el Server Component de la página, por pedido. */
14
+ readonly seal: string;
15
+ readonly contact: InquiryContactRequirement;
16
+ readonly policyHref: string;
17
+ /** El nombre del negocio, que nombra el texto de novedades. La casilla arranca sin marcar. */
18
+ readonly businessName: string;
19
+ /** Prende el widget de Turnstile. Con esto, la acción necesita `turnstileSecret`. */
20
+ readonly turnstileSiteKey?: string;
21
+ readonly className?: string;
22
+ }
23
+ export declare function InquiryForm(props: InquiryFormProps): import("react").JSX.Element;
@@ -0,0 +1,43 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useActionState, useId } from "react";
4
+ import { INQUIRY_FIELD } from "../inquiry/form-state.js";
5
+ import { marketingOptInText } from "../inquiry/marketing-opt-in.js";
6
+ const OFFSCREEN = { position: "absolute", left: "-10000px", width: "1px", height: "1px", overflow: "hidden" };
7
+ const FIELD_ERRORS = {
8
+ name: "Escribí tu nombre (hasta 100 caracteres).",
9
+ phone: "Revisá el teléfono: con código de área, solo números.",
10
+ email: "Revisá el email.",
11
+ contact: "Dejanos un teléfono o un email para responderte.",
12
+ message: "Escribí tu consulta (hasta 2000 caracteres).",
13
+ };
14
+ function errorText(state) {
15
+ if (state.status === "invalid")
16
+ return FIELD_ERRORS[state.field] ?? "No pudimos enviar la consulta. Probá de nuevo en un rato.";
17
+ if (state.status === "reload")
18
+ return "El formulario venció. Recargá la página para enviar la consulta.";
19
+ if (state.status === "rate_limited")
20
+ return "Mandaste varias consultas seguidas. Probá de nuevo en unos minutos.";
21
+ if (state.status === "failed")
22
+ return "No pudimos enviar la consulta. Probá de nuevo en un rato.";
23
+ return null;
24
+ }
25
+ const CONTACT_HINT = {
26
+ either: "Dejanos un teléfono o un email.",
27
+ phone: "Necesitamos tu teléfono para responderte.",
28
+ email: "Necesitamos tu email para responderte.",
29
+ both: "Necesitamos tu teléfono y tu email.",
30
+ };
31
+ export function InquiryForm(props) {
32
+ const [state, formAction, pending] = useActionState(props.action, { status: "idle" });
33
+ const id = useId();
34
+ const error = errorText(state);
35
+ const invalid = (field) => (state.status === "invalid" && state.field === field
36
+ ? { "aria-invalid": true, "aria-describedby": `${id}-error` } : {});
37
+ if (state.status === "sent") {
38
+ return _jsx("p", { role: "status", "data-organizapp-sent": "", children: "\u00A1Gracias! Recibimos tu consulta y te vamos a responder pronto." });
39
+ }
40
+ const phoneRequired = props.contact === "phone" || props.contact === "both";
41
+ const emailRequired = props.contact === "email" || props.contact === "both";
42
+ return _jsxs("form", { action: formAction, className: props.className, "data-organizapp-inquiry-form": "", children: [_jsx("input", { type: "hidden", name: INQUIRY_FIELD.seal, value: props.seal }), _jsx("div", { "aria-hidden": "true", style: OFFSCREEN, children: _jsxs("label", { children: ["Tu sitio web", _jsx("input", { type: "text", name: INQUIRY_FIELD.honeypot, tabIndex: -1, autoComplete: "off", defaultValue: "" })] }) }), error === null ? null : _jsx("p", { id: `${id}-error`, role: "alert", "data-organizapp-error": "", children: error }), _jsx("label", { htmlFor: `${id}-name`, children: "Nombre" }), _jsx("input", { id: `${id}-name`, type: "text", name: INQUIRY_FIELD.name, required: true, maxLength: 100, autoComplete: "name", ...invalid("name") }), _jsx("p", { id: `${id}-contact`, "data-organizapp-hint": "", children: CONTACT_HINT[props.contact] }), _jsx("label", { htmlFor: `${id}-phone`, children: "Tel\u00E9fono" }), _jsx("input", { id: `${id}-phone`, type: "tel", name: INQUIRY_FIELD.phone, required: phoneRequired, autoComplete: "tel", "aria-describedby": `${id}-contact`, ...invalid("phone") }), _jsx("label", { htmlFor: `${id}-email`, children: "Email" }), _jsx("input", { id: `${id}-email`, type: "email", name: INQUIRY_FIELD.email, required: emailRequired, maxLength: 254, autoComplete: "email", "aria-describedby": `${id}-contact`, ...invalid("email") }), _jsx("label", { htmlFor: `${id}-message`, children: "Consulta" }), _jsx("textarea", { id: `${id}-message`, name: INQUIRY_FIELD.message, required: true, maxLength: 2000, rows: 5, ...invalid("message") }), _jsxs("label", { "data-organizapp-opt-in": "", children: [_jsx("input", { type: "checkbox", name: INQUIRY_FIELD.marketingOptIn }), marketingOptInText(props.businessName)] }), props.turnstileSiteKey === undefined ? null : _jsxs(_Fragment, { children: [_jsx("script", { src: "https://challenges.cloudflare.com/turnstile/v0/api.js", async: true, defer: true }), _jsx("div", { className: "cf-turnstile", "data-sitekey": props.turnstileSiteKey })] }), _jsxs("p", { "data-organizapp-policy": "", children: ["Al enviar acept\u00E1s la ", _jsx("a", { href: props.policyHref, children: "pol\u00EDtica de privacidad" }), "."] }), _jsx("button", { type: "submit", disabled: pending, children: pending ? "Enviando…" : "Enviar consulta" })] });
43
+ }
@@ -0,0 +1,29 @@
1
+ import { type ReactNode } from "react";
2
+ import type { SiteChatReply } from "../chat/chat-reply.js";
3
+ /**
4
+ * El chat del Asistente en el sitio (W-4). Marcado semántico y sin estilos
5
+ * propios, como el formulario: el sitio lo viste con `className` y los
6
+ * `data-organizapp-*`.
7
+ *
8
+ * Los mensajes viven solo en el estado de React: nada va a `localStorage` ni a
9
+ * otra memoria del navegador. La conversación la guarda Organizapp por un día y
10
+ * la sesión viaja en una cookie `httpOnly` que pone la acción del servidor.
11
+ *
12
+ * Cuando el Asistente deriva, o el chat no está, se cierra la caja de texto y se
13
+ * muestra `handoff` (el `<InquiryForm>` del sitio) y, si hay número, el enlace a
14
+ * WhatsApp. No hay chat en vivo con una persona.
15
+ */
16
+ export interface SiteChatProps {
17
+ /** La Server Action del sitio, que llama a `sendChatMessage` y guarda la cookie. */
18
+ readonly action: (input: {
19
+ readonly text: string;
20
+ }) => Promise<SiteChatReply>;
21
+ /** El nombre del Asistente, que nombra el botón: «Chateá con Sofía». */
22
+ readonly assistantName: string;
23
+ /** Lo que se ofrece al derivar: normalmente el formulario de consulta. */
24
+ readonly handoff: ReactNode;
25
+ /** El WhatsApp del negocio, en cualquier formato; `null` si no tiene. */
26
+ readonly whatsapp: string | null;
27
+ readonly className?: string;
28
+ }
29
+ export declare function SiteChat(props: SiteChatProps): import("react").JSX.Element;
@@ -0,0 +1,42 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useId, useState } from "react";
4
+ import { whatsappHref } from "../chat/whatsapp-href.js";
5
+ const TEXT_MAX = 1000;
6
+ const BUSY = "Escribiste muchos mensajes seguidos. Probá en unos minutos.";
7
+ export function SiteChat(props) {
8
+ const id = useId();
9
+ const [open, setOpen] = useState(false);
10
+ const [lines, setLines] = useState([]);
11
+ const [draft, setDraft] = useState("");
12
+ const [pending, setPending] = useState(false);
13
+ const [notice, setNotice] = useState(null);
14
+ const [closed, setClosed] = useState(false);
15
+ const whatsapp = whatsappHref(props.whatsapp);
16
+ async function send(event) {
17
+ event.preventDefault();
18
+ const text = draft.trim();
19
+ if (text === "" || pending)
20
+ return;
21
+ setPending(true);
22
+ setNotice(null);
23
+ setLines((current) => [...current, { from: "contact", text }]);
24
+ setDraft("");
25
+ try {
26
+ const reply = await props.action({ text });
27
+ if (reply.kind === "answer")
28
+ setLines((current) => [...current, { from: "ai", text: reply.text }]);
29
+ else if (reply.kind === "busy")
30
+ setNotice(BUSY);
31
+ else
32
+ setClosed(true);
33
+ }
34
+ catch {
35
+ setClosed(true);
36
+ }
37
+ finally {
38
+ setPending(false);
39
+ }
40
+ }
41
+ return _jsxs("section", { className: props.className, "data-organizapp-chat": "", children: [_jsx("button", { type: "button", "aria-expanded": open, "aria-controls": `${id}-panel`, onClick: () => setOpen((value) => !value), children: open ? "Cerrar el chat" : `Chateá con ${props.assistantName}` }), _jsxs("div", { id: `${id}-panel`, hidden: !open, "data-organizapp-chat-panel": "", children: [_jsx("ol", { "aria-live": "polite", "data-organizapp-chat-log": "", children: lines.map((line, index) => _jsx("li", { "data-organizapp-from": line.from, children: line.text }, index)) }), notice === null ? null : _jsx("p", { role: "alert", "data-organizapp-error": "", children: notice }), closed ? _jsxs("div", { "data-organizapp-chat-handoff": "", children: [_jsx("p", { children: "Para seguir, dejanos tu consulta y te respondemos." }), props.handoff, whatsapp === null ? null : _jsx("p", { children: _jsx("a", { href: whatsapp, target: "_blank", rel: "noopener noreferrer", children: "Escribinos por WhatsApp" }) })] }) : _jsxs("form", { onSubmit: send, "data-organizapp-chat-form": "", children: [_jsx("label", { htmlFor: `${id}-text`, children: "Tu mensaje" }), _jsx("textarea", { id: `${id}-text`, value: draft, onChange: (event) => setDraft(event.target.value), required: true, maxLength: TEXT_MAX, rows: 2 }), _jsx("button", { type: "submit", disabled: pending, children: pending ? "Enviando…" : "Enviar" })] })] })] });
42
+ }
package/dist/index.d.ts CHANGED
@@ -1 +1,16 @@
1
- export {};
1
+ export { createFormSeal } from "./inquiry/form-seal.js";
2
+ export { handleInquiryForm } from "./inquiry/handle-inquiry-form.js";
3
+ export type { InquiryFormOptions } from "./inquiry/handle-inquiry-form.js";
4
+ export { MARKETING_OPT_IN_VERSION, marketingOptInText } from "./inquiry/marketing-opt-in.js";
5
+ export { submitInquiry } from "./inquiry/submit-inquiry.js";
6
+ export type { SubmitInquiryResult } from "./inquiry/submit-inquiry.js";
7
+ export type { InquiryFields, SiteConnection } from "./inquiry/inquiry-fields.js";
8
+ export { clientIpFromHeaders, revalidationFromEnv, siteConnectionFromEnv } from "./inquiry/site-environment.js";
9
+ export type { InquiryFormState } from "./inquiry/form-state.js";
10
+ export { getSiteProfile } from "./profile/get-site-profile.js";
11
+ export type { SiteProfileResult } from "./profile/get-site-profile.js";
12
+ export type { SiteProfile } from "./profile/site-profile.js";
13
+ export { SITE_PROFILE_TAG, createRevalidateHandler } from "./profile/revalidate-handler.js";
14
+ export type { RevalidationConnection } from "./profile/revalidate-handler.js";
15
+ export { CHAT_COOKIE, CHAT_COOKIE_OPTIONS, sendChatMessage } from "./chat/send-chat-message.js";
16
+ export type { ChatMessageResult } from "./chat/send-chat-message.js";