@organizapp/site-kit 0.0.1 → 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.
- package/README.md +171 -6
- package/dist/assertion/site-assertion.d.ts +35 -0
- package/dist/assertion/site-assertion.js +49 -0
- package/dist/chat/chat-reply.d.ts +22 -0
- package/dist/chat/chat-reply.js +1 -0
- package/dist/chat/send-chat-message.d.ts +32 -0
- package/dist/chat/send-chat-message.js +61 -0
- package/dist/chat/whatsapp-href.d.ts +7 -0
- package/dist/chat/whatsapp-href.js +10 -0
- package/dist/crypto/hmac.d.ts +5 -0
- package/dist/crypto/hmac.js +47 -0
- package/dist/form/index.d.ts +7 -0
- package/dist/form/index.js +7 -0
- package/dist/form/inquiry-form.d.ts +23 -0
- package/dist/form/inquiry-form.js +43 -0
- package/dist/form/site-chat.d.ts +29 -0
- package/dist/form/site-chat.js +42 -0
- package/dist/index.d.ts +16 -1
- package/dist/index.js +11 -4
- package/dist/inquiry/form-seal.d.ts +18 -0
- package/dist/inquiry/form-seal.js +34 -0
- package/dist/inquiry/form-state.d.ts +36 -0
- package/dist/inquiry/form-state.js +16 -0
- package/dist/inquiry/handle-inquiry-form.d.ts +21 -0
- package/dist/inquiry/handle-inquiry-form.js +55 -0
- package/dist/inquiry/idempotency-key.d.ts +2 -0
- package/dist/inquiry/idempotency-key.js +24 -0
- package/dist/inquiry/inquiry-fields.d.ts +25 -0
- package/dist/inquiry/inquiry-fields.js +1 -0
- package/dist/inquiry/marketing-opt-in.d.ts +11 -0
- package/dist/inquiry/marketing-opt-in.js +16 -0
- package/dist/inquiry/site-environment.d.ts +19 -0
- package/dist/inquiry/site-environment.js +44 -0
- package/dist/inquiry/submit-inquiry.d.ts +30 -0
- package/dist/inquiry/submit-inquiry.js +50 -0
- package/dist/inquiry/turnstile.d.ts +6 -0
- package/dist/inquiry/turnstile.js +25 -0
- package/dist/profile/get-site-profile.d.ts +24 -0
- package/dist/profile/get-site-profile.js +41 -0
- package/dist/profile/revalidate-handler.d.ts +19 -0
- package/dist/profile/revalidate-handler.js +27 -0
- package/dist/profile/site-profile.d.ts +26 -0
- package/dist/profile/site-profile.js +21 -0
- package/package.json +13 -3
package/README.md
CHANGED
|
@@ -1,15 +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
|
|
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
|
-
**
|
|
9
|
-
|
|
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.
|
|
13
|
-
desde el workflow `site-kit-publish` del
|
|
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).
|
|
14
179
|
|
|
15
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";
|