forkads-components
v1.0.6
Published
Componentes de integración de ForkAds para partners.
Readme
forkads-components
Componentes de integración de ForkAds para partners.
ForkadsLauncher
Un botón que abre el flujo completo de ForkAds dentro de un <dialog> modal con un
iframe. Sustituye a ForkadsEasyRedirectButton, que abría una pestaña nueva hacia
una página de registro.
import { ForkadsLauncher } from 'forkads-components';
<ForkadsLauncher
affiliateKey="dropkiller"
redirectionFlow={{
name: 'canvas',
properties: {
productId: 'sku-4821',
name: 'Zapatilla Runner Pro',
imageUrl: 'https://cdn.example.com/img.png?sig=abc&exp=123',
},
}}
/>Props
| Prop | Tipo | Default | |
|---|---|---|---|
| affiliateKey | "dropkiller" \| "scalboost" | — | Reparto de comisiones en Stripe. |
| redirectionFlow | { name: "canvas"; properties: { productId: string; name: string; imageUrl: string } } | — | Flujo a ejecutar y el producto sobre el que se ejecuta. |
| sessionEndpoint | string | "/api/forkads/session" | Ruta del backend del partner que acuña la sesión. |
| baseUrl | string | "https://app.forkads.com" | Origen de ForkAds. Valida el embedUrl que devuelve el backend. |
| label | string | "Crear Landing Page" | Texto del botón. |
Hereda todo React.ButtonHTMLAttributes<HTMLButtonElement>.
productId, name e imageUrl viajan a ForkAds como parámetros del iframe y son los que
siembran el producto en el tablero. Si falta cualquiera de ellos, el iframe muestra su
tarjeta de "sin producto" en lugar del selector de tableros. imageUrl tiene requisitos
duros del lado de ForkAds: https, accesible sin autenticación, Content-Type de
imagen real (un HTML con extensión .jpg se rechaza), ≤ 25 MB y ≤ 5 redirecciones.
No existe una prop para el correo del usuario, y no debe añadirse. El correo lo resuelve el backend del partner desde su propia sesión. Aceptarlo por props permitiría a cualquiera acuñar una sesión a nombre de otra persona desde la consola del navegador.
El componente tampoco lee process.env: en Vite process no existe en el navegador.
Toda la configuración entra por props.
Comportamiento
- Click → el modal se abre de inmediato con un skeleton. No espera a la red.
- En paralelo,
POSTasessionEndpoint. - Cuando llega el
embedUrl, se asigna alsrcdel iframe. - El
onLoaddel iframe retira el skeleton. Funciona cross-origin.
A partir de ahí el padre no habla con el iframe: no hay protocolo postMessage. El
iframe se gobierna solo — sus pasos, sus errores y su fallback de almacenamiento
bloqueado (la tarjeta con el enlace target="_blank") los renderiza él dentro de su
propio documento.
El único error que gestiona el padre es el fallo al mintear la sesión: si el POST falla
o tarda más de 15 s, el modal muestra un estado de error con botón de reintento.
Cualquier error posterior es cosa del iframe.
Se cierra con el botón del chrome, con Esc o con click en el backdrop. Al cerrar se
aborta la petición en vuelo y se desmonta el iframe, para que el documento embebido no
siga vivo en segundo plano.
Apertura (morph)
El botón no abre una ventana aparte: se convierte en ella. El <dialog> arranca
trasladado y recortado al rectángulo exacto del botón (negro, radio 8 px) y desde ahí se
expande hasta su tamaño final; el contenido entra con un fundido en la segunda mitad.
Al cerrar, el mismo movimiento a la inversa.
Es un FLIP con la Web Animations API (Element.animate, parte del DOM — sin dependencias)
que anima transform + clip-path, nunca scale: así el contenido del panel conserva su
layout final durante toda la animación y no se estira. Mientras la ventana está abierta el
botón queda en visibility: hidden — la ventana es el botón.
Duración y curva: 320 ms con cubic-bezier(0.22, 1, 0.36, 1) (--fka-dur / --fka-ease
en el CSS, constantes equivalentes en el componente). Con prefers-reduced-motion: reduce
el morph se omite y la ventana aparece directamente.
Tamaño
Uno solo, fijo desde que abre: width: min(1240px, 94vw), height: min(820px, 92vh).
Bajo 640 px de viewport, pantalla completa. El modal no cambia de tamaño después de
abrirse; si el contenido necesita más o menos alto, es el iframe quien se adapta.
El iframe
Se monta sin atributo sandbox — lo rompería todo: cookies y Storage Access API — con
allow="clipboard-write" y referrerpolicy="strict-origin".
Requisitos para que funcione
El JSX es la parte fácil. Sin estas cuatro cosas el modal abre pero se queda en blanco o en error.
1. La ruta de sesión existe y es misma-origen. sessionEndpoint se llama con
credentials: "same-origin". Si lo apuntas a otro dominio, la cookie de sesión no viaja y
el endpoint no puede saber quién es el usuario. Déjalo relativo.
2. ForkAds permite ser embebido desde tu origen. En las respuestas de /embed/**:
Content-Security-Policy: frame-ancestors https://app.dropkiller.comY sin X-Frame-Options: DENY ni SAMEORIGIN — esa cabecera gana sobre frame-ancestors
en algunos navegadores y bloquea el iframe sin más. Si ves el modal en blanco, mira la
consola: el error de framing sale ahí, no en el componente.
ForkAds no adivina ese origen: hay que pasárselo al equipo de ForkAds para que lo
registre. Scheme + host + puerto, sin barra final y sin ruta (https://app.dropkiller.com),
tanto el de producción como el de staging.
3. Las cookies de ForkAds sobreviven al contexto de terceros. Dentro del iframe son cookies third-party:
Set-Cookie: sb-<ref>-auth-token=...; SameSite=None; Secure; Partitioned
Set-Cookie: FORKADS_EMBED=...; SameSite=None; Secure; PartitionedPartitioned (CHIPS) es lo que las mantiene vivas en Chrome con las cookies de terceros
restringidas. En Safari, con ITP, ni así: ese es el caso en el que el propio iframe debe
mostrar su tarjeta con el enlace target="_blank".
4. Ambos lados en HTTPS. Secure y Partitioned exigen contexto seguro. En local,
http://localhost cuenta como seguro, pero una IP de red local no.
Probarlo sin el backend de ForkAds
Devuelve un embedUrl que apunte a una página tuya y pásale ese origen por baseUrl —
si no coinciden, el componente rechaza la URL a propósito:
<ForkadsLauncher
affiliateKey="dropkiller"
redirectionFlow={{ name: 'canvas', properties: { productId, name, imageUrl } }}
sessionEndpoint="/api/forkads/session-mock"
baseUrl="http://localhost:3000"
/>// app/api/forkads/session-mock/route.ts — solo para desarrollo
export async function POST() {
return Response.json({ embedUrl: 'http://localhost:3000/fake-embed' });
}Contrato de sessionEndpoint
Ruta en el dominio del partner (misma-origen, se llama con credentials: "same-origin"
para que viaje la cookie de sesión).
Request
POST {sessionEndpoint}
// Content-Type: application/json
{
"affiliateKey": "dropkiller",
"flow": {
"name": "canvas",
"properties": {
"productId": "sku-4821",
"name": "Zapatilla Runner Pro",
"imageUrl": "https://cdn.example.com/img.png?sig=abc&exp=123"
}
}
}La ruta del partner no necesita leer este cuerpo: el producto lo pone el componente en la URL del iframe. Llega por si el partner quiere validarlo o registrarlo.
Response 200
{ "embedUrl": "https://app.forkads.com/embed?k=..." }Requisitos sobre embedUrl:
- Debe pertenecer al mismo origen que
baseUrl. El componente lo verifica y falla si no coincide. - Debe ser de un solo uso y de vida corta. La key que devuelve ForkAds vive 15 minutos y se quema en el primer canje, así que hay que acuñarla en el clic y no cachear la URL.
- El componente le añade, con
URLSearchParams,via,productId,nameeimageUrl, pero solo si no vienen ya en la URL. Lo que ponga el servidor manda.
Cualquier status distinto de 2xx se muestra como error con reintento. No devuelvas
detalles internos en el cuerpo: el componente puede mostrar el mensaje al usuario.
Qué espera el componente de /embed/**
Nada, y esa es la idea. No hay postMessage, ni handshake, ni contrato de mensajes: el
padre asigna el src, espera el load del iframe para retirar el skeleton y no vuelve a
intervenir.
Eso deja tres cosas del lado de ForkAds:
- Sus propios errores. El padre solo cubre el fallo al mintear la sesión. Cualquier error posterior lo renderiza el iframe dentro de su documento.
- El fallback de almacenamiento bloqueado (Safari, ITP): si el navegador bloquea el
almacenamiento en contexto embebido, la tarjeta de consentimiento muestra un enlace
target="_blank". El padre no abre ventanas — unwindow.openfuera de un gesto del usuario se bloquea de todas formas. - Su propio alto. El modal mide
min(820px, 92vh)y no se mueve de ahí.
Ruta de servidor que tiene que crear el partner
Lee el correo de su propia sesión, llama a ForkAds con el secreto de servidor y devuelve
{ embedUrl }. Es un proxy de credencial: no mira el producto y no toca la URL.
FORKADS_PARTNER_SECRET jamás sale del servidor.
Tres cosas que si se saltan no funciona:
- El correo sale de la sesión del servidor, nunca del cuerpo de la petición. Aceptarlo del cliente permite acuñar una sesión sobre cualquier cuenta de ForkAds escribiendo un correo ajeno.
- El endpoint de ForkAds es
POST /api/embed/session. - La respuesta de ForkAds viene envuelta:
{ isSuccess, data: { key, embedUrl, expiresAt } }. Hay que devolverdata.embedUrl.
Next.js — app/api/forkads/session/route.ts
import { NextResponse } from 'next/server';
import { getSession } from '@/lib/auth'; // tu sesión, la de Dropkiller
export async function POST() {
const session = await getSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
const forkads = await fetch('https://app.forkads.com/api/embed/session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.FORKADS_PARTNER_SECRET}`,
},
body: JSON.stringify({
// El correo lo pone el servidor desde su sesión, nunca el cliente.
email: session.user.email,
name: session.user.name ?? null,
}),
cache: 'no-store',
});
if (!forkads.ok) {
return NextResponse.json({ error: 'forkads_unavailable' }, { status: 502 });
}
const { data } = await forkads.json();
return NextResponse.json({ embedUrl: data.embedUrl }, {
headers: { 'Cache-Control': 'no-store' },
});
}Express — equivalente
app.post('/api/forkads/session', requireAuth, async (req, res) => {
const forkads = await fetch('https://app.forkads.com/api/embed/session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.FORKADS_PARTNER_SECRET}`,
},
body: JSON.stringify({
email: req.user.email, // de la sesión del servidor
name: req.user.name ?? null,
}),
});
if (!forkads.ok) return res.status(502).json({ error: 'forkads_unavailable' });
const { data } = await forkads.json();
res.set('Cache-Control', 'no-store').json({ embedUrl: data.embedUrl });
});Respuestas de error de ForkAds
| Status | message | Qué pasó |
|---|---|---|
| 401 | unauthorized | falta o no coincide FORKADS_PARTNER_SECRET |
| 400 | invalid_email | el correo no tiene formato válido |
| 403 | account_not_linkable | esa cuenta es de backoffice y no se vincula |
| 429 | rate_limited | más de 120 acuñaciones por minuto |
Estilos
El CSS se inyecta con el bundle (injectStyle en tsup.config.ts). Todas las clases
llevan el prefijo fka- y no hay selectores de elemento sin prefijo.
El prefijo resuelve las colisiones de nombre, no las de cascada. Una regla del
anfitrión como .card button { padding: 0 } (0-1-1) gana a .fka-launcher (0-1-0) sin
que exista ningún conflicto de clases, y los resets de framework (img { max-width: 100% },
la preflight de Tailwind, .btn de Bootstrap) pisan cosas a diario. Por eso el reset va
con !important, propiedad a propiedad.
Dónde no hay !important
En el orden de cascada, las declaraciones !important de autor están por encima de las
animaciones. Marcar una propiedad animada la congela y mata el morph. Quedan
deliberadamente sin !important:
| Selector | Propiedades |
|---|---|
| .fka-dialog | transform, clip-path, background-color, box-shadow |
| .fka-dialog::backdrop | opacity |
| .fka-panel | opacity |
| .fka-launcher--busy, .fka-skeleton__* | background-position |
| .fka-launcher__icon.is-loading::after | transform |
Que el anfitrión pise el background-color del <dialog> no se nota: el panel de dentro
lo cubre entero con su propio fondo, y ese sí va blindado. Solo se vería durante la
animación.
Tampoco se toca el display del <dialog>: lo gobierna el user-agent (block al abrir,
none al cerrar). Fijarlo dejaría el modal visible siempre.
El bloque @media (prefers-reduced-motion: reduce) va al final del archivo a
propósito: apaga animaciones declaradas más arriba con !important, y entre dos
declaraciones !important de la misma especificidad gana la última. Si ese bloque
subiera, dejaría de tener efecto.
Cómo personalizar el botón
Con !important puesto, una clase del consumidor ya no puede reestilar el botón —
className sigue llegando al DOM, pero pierde la cascada. El canal de override son las
custom properties, que se leen con var(--fka-btn-x, valor) y nunca se declaran dentro
del componente: cualquier definición tuya gana siempre, la pongas en un ancestro o en el
propio style.
| Token | Default |
|---|---|
| --fka-btn-bg | #000000 |
| --fka-btn-fg | #ffffff |
| --fka-btn-font | la pila de sistema |
| --fka-btn-font-size | 10px |
| --fka-btn-font-weight | 600 |
| --fka-btn-padding | 10px 20px |
| --fka-btn-radius | 8px |
| --fka-btn-gap | 10px |
| --fka-btn-width | auto |
| --fka-btn-icon-size | 1rem |
<ForkadsLauncher
style={{ '--fka-btn-bg': '#4f46e5', '--fka-btn-width': '100%' } as React.CSSProperties}
/* ... */
/>El morph lee el color de fondo y el radio reales del botón con getComputedStyle, así
que la ventana arranca desde lo que se ve y no desde el negro de fábrica.
