Back to 0fee
0fee

El widget de checkout: un IIFE de 21 KB que lo gestiona todo

Cómo construimos el widget de checkout de 21 KB de 0fee.dev como una librería IIFE de Vite con selección de país, soporte OTP y entrada de teléfono.

Thales & Claude | March 30, 2026 10 min 0fee
EN/ FR/ ES
checkout-widgetiifejavascriptembedded-paymentsvite-library

Toda plataforma de pagos necesita una experiencia de checkout embebible. Stripe tiene Stripe.js con 28 KB. PayPal tiene su SDK. 0fee.dev tiene un IIFE (Immediately Invoked Function Expression) de 21 KB que un desarrollador coloca en su página con una sola etiqueta script. Gestiona la selección de país, el filtrado de métodos de pago, la entrada del número de teléfono con códigos de marcación, la validación OTP, los estados de procesamiento y los callbacks de éxito/error, todo sin requerir que el desarrollador construya ninguna interfaz de pago.

Este artículo cubre la configuración de compilación como librería Vite, el flujo multipaso del widget, la recopilación de información del cliente, la salida dual IIFE vs. módulo ES, y las correcciones de errores que lo dejaron listo para producción.

La experiencia de integración

Desde la perspectiva del desarrollador, el widget de checkout son tres líneas de código:

html<script src="https://cdn.0fee.dev/checkout.js"></script>
<script>
  const checkout = new ZeroFee.Checkout({
    apiKey: "pk_live_...",
    amount: 5000,
    currency: "XOF",
    onSuccess: (result) => console.log("Paid!", result),
    onError: (error) => console.error("Failed:", error),
  });

  checkout.open();
</script>

La llamada checkout.open() renderiza una superposición modal con el flujo de pago completo. El cliente selecciona su país, elige un método de pago, introduce su número de teléfono, confirma mediante OTP si es necesario, y ve un estado de éxito o error. El callback onSuccess del desarrollador se ejecuta con los detalles de la transacción.

Compilación como librería Vite

El widget se compila con Vite en modo librería, produciendo tanto un paquete IIFE (para etiquetas <script>) como un módulo ES (para bundlers modernos):

typescript// checkout/vite.config.ts
import { defineConfig } from "vite";

export default defineConfig({
  build: {
    lib: {
      entry: "src/checkout.ts",
      name: "ZeroFee",
      formats: ["iife", "es"],
      fileName: (format) =>
        format === "iife" ? "checkout.js" : "checkout.esm.js",
    },
    rollupOptions: {
      output: {
        // IIFE: single file, no external dependencies
        inlineDynamicImports: true,
      },
    },
    minify: "terser",
    terserOptions: {
      compress: {
        drop_console: true,
      },
    },
  },
});

El formato IIFE es crítico. A diferencia de los módulos ES, un IIFE se puede cargar con una etiqueta <script> simple y expone inmediatamente el objeto global ZeroFee. Sin sentencias import, sin sistema de módulos, sin bundler requerido. Esto hace que la integración sea tan simple como añadir una etiqueta script a cualquier página HTML.

SalidaFormatoTamañoCaso de uso
checkout.jsIIFE21 KBEtiqueta script en cualquier sitio web
checkout.esm.jsMódulo ES19 KBImport en apps React/Vue/Svelte

El flujo del widget

El widget de checkout sigue un flujo multipaso:

Información del cliente -> País -> Método -> Teléfono -> OTP -> Procesamiento -> Resultado

Paso 1: Información del cliente

typescriptrenderCustomerStep(): string {
  return `
    <div class="zf-step">
      <h3 class="zf-title">Customer Information</h3>

      <label class="zf-label">Full Name *</label>
      <input
        type="text"
        class="zf-field"
        id="zf-customer-name"
        placeholder="John Doe"
        required
      />

      <label class="zf-label">Email (optional)</label>
      <input
        type="email"
        class="zf-field"
        id="zf-customer-email"
        placeholder="[email protected]"
      />

      <label class="zf-label">Country *</label>
      <select class="zf-select" id="zf-country">
        ${this.countries.map(c =>
          `<option value="${c.code}">${c.flag} ${c.name}</option>`
        ).join("")}
      </select>

      <button class="zf-btn-primary" id="zf-next-step">
        Continue
      </button>
    </div>
  `;
}

La Sesión 077 rediseñó este paso para combinar la información del cliente y la selección de país en un solo formulario, reduciendo el número de pasos. El nombre completo es obligatorio; el correo electrónico es opcional. El menú desplegable de países se llena dinámicamente desde la API /v1/countries con un respaldo a una lista codificada.

Paso 2: Selección del método de pago

typescriptrenderMethodStep(): string {
  const methods = this.getMethodsForCountry(this.selectedCountry);

  return `
    <div class="zf-step">
      <h3 class="zf-title">Payment Method</h3>
      <div class="zf-methods">
        ${methods.map(m => `
          <button
            class="zf-method-btn"
            data-method="${m.code}"
          >
            <span class="zf-method-icon">${m.icon}</span>
            <span class="zf-method-name">${m.name}</span>
          </button>
        `).join("")}
      </div>
    </div>
  `;
}

Los métodos se filtran según el país seleccionado. Un cliente en Costa de Marfil ve Orange Money, MTN, Wave y Moov. Un cliente en Estados Unidos ve Tarjeta y PayPal.

Paso 3: Entrada del número de teléfono

typescriptrenderPhoneStep(): string {
  const country = this.getCountry(this.selectedCountry);

  return `
    <div class="zf-step">
      <h3 class="zf-title">Phone Number</h3>
      <div class="zf-phone-input">
        <span class="zf-dial-code">${country.dialCode}</span>
        <input
          type="tel"
          class="zf-field"
          id="zf-phone"
          placeholder="${country.phonePlaceholder}"
          maxlength="12"
        />
      </div>
      <button class="zf-btn-primary" id="zf-pay">
        Pay ${this.formatAmount()}
      </button>
    </div>
  `;
}

El código de marcación se rellena automáticamente a partir de la selección del país. El campo de teléfono valida el formato según la longitud esperada del número de teléfono del país.

Paso 4: Entrada OTP (condicional)

typescriptrenderOTPStep(): string {
  return `
    <div class="zf-step">
      <h3 class="zf-title">Enter OTP Code</h3>
      <p class="zf-subtitle">
        A verification code has been sent to your phone
      </p>
      <div class="zf-otp-inputs">
        ${Array(6).fill(0).map((_, i) => `
          <input
            type="text"
            class="zf-otp-digit"
            maxlength="1"
            data-index="${i}"
          />
        `).join("")}
      </div>
      <button class="zf-btn-primary" id="zf-verify-otp">
        Verify
      </button>
    </div>
  `;
}

El paso OTP solo aparece cuando el proveedor devuelve payment_flow.type === "otp". Renderiza seis campos de entrada individuales para dígitos que avanzan automáticamente al presionar una tecla.

Paso 5: Procesamiento

typescriptrenderProcessingStep(): string {
  return `
    <div class="zf-step zf-processing">
      <div class="zf-spinner"></div>
      <h3 class="zf-title">Processing Payment</h3>
      <p class="zf-subtitle">
        ${this.getProcessingMessage()}
      </p>
    </div>
  `;
}

getProcessingMessage(): string {
  if (this.paymentFlow === "ussd_push") {
    return "Check your phone for the payment prompt";
  }
  return "Please wait while we process your payment...";
}

Para pagos USSD push, el paso de procesamiento muestra un mensaje indicando al cliente que revise su teléfono. El widget consulta el endpoint de estado de la transacción hasta que el pago se completa, falla o expira.

Paso 6: Estados de éxito/error

typescriptrenderSuccessStep(): string {
  return `
    <div class="zf-step zf-success">
      <div class="zf-check-icon">
        <svg><!-- checkmark SVG --></svg>
      </div>
      <h3 class="zf-title">Payment Successful</h3>
      <p class="zf-amount">${this.formatAmount()}</p>
      <p class="zf-reference">Ref: ${this.transactionId}</p>
    </div>
  `;
}

renderErrorStep(): string {
  return `
    <div class="zf-step zf-error">
      <div class="zf-error-icon">
        <svg><!-- X icon SVG --></svg>
      </div>
      <h3 class="zf-title">Payment Failed</h3>
      <p class="zf-subtitle">${this.errorMessage}</p>
      <button class="zf-btn-primary" id="zf-retry">
        Try Again
      </button>
    </div>
  `;
}

Procesamiento de pagos

La lógica central de pago gestiona la llamada a la API y el sondeo de estado:

typescriptasync processPayment(): Promise<void> {
  this.showStep("processing");

  try {
    const phone = `${this.dialCode}${this.phoneNumber}`;

    const response = await fetch(`${this.apiUrl}/v1/payments`, {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${this.options.apiKey}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        amount: this.options.amount,
        currency: this.options.currency,
        payment_method: this.selectedMethod,
        customer: {
          phone,
          fullName: this.customerName,
          email: this.customerEmail || undefined,
        },
        return_url: window.location.href,
      }),
    });

    const data = await response.json();

    if (data.data?.redirect_url) {
      // Redirect flow (Wave, PayPal, Stripe)
      window.location.href = data.data.redirect_url;
      return;
    }

    if (data.data?.payment_flow?.type === "otp") {
      this.transactionId = data.data.id;
      this.showStep("otp");
      return;
    }

    // USSD push -- poll for status
    this.transactionId = data.data.id;
    await this.pollPaymentStatus();

  } catch (error) {
    this.errorMessage = error.message || "Payment failed";
    this.showStep("error");
    this.options.onError?.(error);
  }
}

async pollPaymentStatus(): Promise<void> {
  const maxAttempts = 60;  // 5 minutes at 5-second intervals
  let attempts = 0;

  while (attempts < maxAttempts) {
    await new Promise(resolve => setTimeout(resolve, 5000));

    const response = await fetch(
      `${this.apiUrl}/v1/payments/${this.transactionId}`,
      { headers: { "Authorization": `Bearer ${this.options.apiKey}` } }
    );

    const data = await response.json();

    if (data.data?.status === "completed") {
      this.showStep("success");
      this.options.onSuccess?.(data.data);
      return;
    }

    if (data.data?.status === "failed") {
      this.errorMessage = "Payment was declined";
      this.showStep("error");
      this.options.onError?.(data.data);
      return;
    }

    attempts++;
  }

  // Timeout
  this.errorMessage = "Payment timed out";
  this.showStep("error");
}

CSS embebido

El widget incluye su propio CSS, embebido directamente en el paquete JavaScript para evitar dependencias de hojas de estilo externas:

typescript// styles/checkout-styles.ts
export const CHECKOUT_STYLES = `
  .zf-overlay {
    position: fixed;
    inset: 0;
    background: rgba(0, 0, 0, 0.5);
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 99999;
  }

  .zf-modal {
    background: white;
    border-radius: 16px;
    width: 400px;
    max-height: 90vh;
    overflow-y: auto;
    box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
  }

  .zf-field {
    width: 100%;
    padding: 12px 16px;
    border: 1px solid #e2e8f0;
    border-radius: 8px;
    font-size: 14px;
    outline: none;
    transition: border-color 0.2s;
  }

  .zf-field:focus {
    border-color: #10b981;
    box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1);
  }

  .zf-btn-primary {
    width: 100%;
    padding: 12px;
    background: #10b981;
    color: white;
    border: none;
    border-radius: 8px;
    font-weight: 600;
    cursor: pointer;
    transition: background 0.2s;
  }

  /* ... ~200 more CSS rules */
`;

Todos los estilos llevan el prefijo zf- (ZeroFee) para evitar conflictos con el CSS de la página anfitriona. La superposición usa z-index: 99999 para asegurar que aparezca por encima de cualquier otro elemento.

Detección de URL de la API

Un error sutil pero crítico: el widget necesitaba detectar la URL correcta de la API según dónde se cargaba:

typescriptprivate getApiUrl(): string {
  // Check if running on localhost (development)
  if (window.location.hostname === "localhost"
      || window.location.hostname === "127.0.0.1") {
    return "http://localhost:8000";
  }

  // Production: use the configured API URL
  return this.options.apiUrl || "https://api.0fee.dev";
}

Sin esta detección, el widget cargado en localhost:3000 intentaría llamar a https://api.0fee.dev, lo que resultaría en errores CORS durante el desarrollo. La corrección verifica el nombre de host y recurre a localhost:8000 para el desarrollo local.

Carga dinámica de países

La Sesión 077 añadió la carga dinámica de países desde la API:

typescriptasync loadCountries(): Promise<void> {
  try {
    const response = await fetch(`${this.apiUrl}/v1/countries`);
    const data = await response.json();
    this.countries = data.data || data;
  } catch {
    // Fallback to hardcoded list
    this.countries = DEFAULT_COUNTRIES;
  }
}

El respaldo asegura que el widget funcione incluso si la API de países no está disponible. La lista codificada cubre los 10 países más comunes (CI, SN, CM, BJ, BF, ML, TG, GH, NG, KE).

Correcciones de errores

Recursión infinita (Sesión 077)

Un getter que se referenciaba a sí mismo causaba un bucle infinito:

typescript// Bug: this.currency calls the getter, which calls this.currency...
get currency(): string {
  return this.currency;  // Infinite recursion
}

// Fix: read from the options object
get currency(): string {
  return this.options.currency;
}

Archivo de compilación incorrecto (Sesión 077)

La compilación del módulo ES fue copiada accidentalmente en lugar del IIFE:

javascript// ES module (checkout.esm.js) -- won't work in a <script> tag
export class Checkout { ... }

// IIFE (checkout.js) -- works in a <script> tag
var ZeroFee = (function() { ... })();

El script de compilación fue actualizado para copiar explícitamente la salida IIFE al directorio público.

Lo que aprendimos

La construcción del widget de checkout nos enseñó tres cosas:

  1. El formato IIFE es esencial para widgets embebibles. Los módulos ES requieren type="module" en la etiqueta script y no exponen objetos globales. Un IIFE funciona en todas partes, en cada página, con cero configuración por parte del desarrollador.
  1. CSS embebido con selectores con espacio de nombres previene conflictos. Al prefijar cada clase con zf- e incrustar el CSS en JavaScript, evitamos requerir que el desarrollador añada un enlace a una hoja de estilos. El widget es verdaderamente autónomo.
  1. Los flujos multipaso necesitan una gestión de estado cuidadosa. El widget rastrea el paso actual, los datos del cliente, el país seleccionado, el método seleccionado, el número de teléfono, el código OTP y el ID de la transacción. Cada transición de paso debe validar el estado actual y preparar los datos del siguiente paso.

El widget de checkout fue construido en la Sesión 002 y refinado a lo largo de las Sesiones 006, 077 y posteriores. Con 21 KB minificado, gestiona el flujo de pago completo para cualquier país y método de pago en el ecosistema de 0fee.


Este artículo es parte de la serie «Cómo construimos 0fee.dev». 0fee.dev es un orquestador de pagos que cubre más de 53 proveedores en más de 200 países, construido por Juste A. GNIMAVO y Claude desde Abiyán sin ningún ingeniero humano. Sigue la serie para conocer la historia completa de la construcción.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles