npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

  1. Click → el modal se abre de inmediato con un skeleton. No espera a la red.
  2. En paralelo, POST a sessionEndpoint.
  3. Cuando llega el embedUrl, se asigna al src del iframe.
  4. El onLoad del 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.com

Y 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; Partitioned

Partitioned (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, name e imageUrl, 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 — un window.open fuera 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 devolver data.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.