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.
| Salida | Formato | Tamaño | Caso de uso |
|---|---|---|---|
checkout.js | IIFE | 21 KB | Etiqueta script en cualquier sitio web |
checkout.esm.js | Módulo ES | 19 KB | Import 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 -> ResultadoPaso 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:
- 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.
- 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.
- 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.