Back to 0fee
0fee

Páginas de pago alojadas: el flujo de redirección

Cómo 0fee.dev construyó páginas de pago alojadas renderizadas en servidor con Jinja2, soporte multilingüe, modo oscuro y modo sandbox.

Thales & Claude | March 30, 2026 10 min 0fee
EN/ FR/ ES
hosted-checkoutjinja2templatesmulti-languageredirect-flow

No todos los desarrolladores quieren incrustar un widget de pago en su sitio. Algunos prefieren un flujo de redirección: enviar al cliente a una página de pago alojada, dejar que la plataforma de pago maneje toda la interfaz y recibir al cliente de vuelta con una confirmación de pago. El Checkout de Stripe, las páginas de pago de PayPal y el checkout de Shopify siguen este modelo.

Las páginas de pago alojadas de 0fee.dev son plantillas Jinja2 renderizadas en servidor servidas por el backend FastAPI. Soportan 15 idiomas, modos oscuro y claro, múltiples métodos de pago, y entornos tanto sandbox como en vivo. Este artículo cubre la arquitectura de plantillas, el flujo de múltiples pasos, la integración del SDK de PaiementPro y el modo sandbox con su guía de pruebas integrada.

Por qué plantillas renderizadas en servidor

El checkout alojado es servido por el backend FastAPI, no por el frontend SolidJS. Esto es intencional:

EnfoqueProsContras
SPA SolidJSConsistente con el panel de controlRequiere build de SPA, enrutamiento del lado del cliente
Plantillas Jinja2Primera carga rápida, no requiere JS, amigable con SEOSeparado del código SolidJS

Para una página de pago, la prioridad es la velocidad. El cliente debería ver el formulario de pago inmediatamente, sin esperar a que un paquete JavaScript se descargue, analice y renderice. Una página renderizada en servidor logra esto con una sola respuesta HTTP.

La plantilla de checkout

La página de checkout es una única plantilla Jinja2 (backend/templates/checkout.html) que maneja todo el flujo mediante JavaScript del lado del cliente:

html<!DOCTYPE html>
<html lang="{{ lang }}" {% if rtl %}dir="rtl"{% endif %}>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{ t.checkout_title }} - 0fee.dev</title>
    <style>
        /* ~500 líneas de CSS embebido */
        :root {
            --primary: #10b981;
            --primary-hover: #059669;
            --bg: #ffffff;
            --text: #1f2937;
            --border: #e5e7eb;
        }

        .dark {
            --bg: #0f172a;
            --text: #f1f5f9;
            --border: #334155;
        }

        /* Soporte RTL para árabe */
        [dir="rtl"] .checkout-form { direction: rtl; }
        [dir="rtl"] .phone-input { flex-direction: row-reverse; }
    </style>
</head>
<body class="{{ 'dark' if dark_mode else '' }}">
    {% if sandbox_mode %}
    <div class="sandbox-banner">
        <span class="shimmer-text">SANDBOX MODE</span>
        <span>{{ t.sandbox_notice }}</span>
    </div>
    {% endif %}

    <div class="checkout-container">
        <div class="checkout-card">
            <!-- Encabezado con monto e información del comerciante -->
            <div class="checkout-header">
                <div class="amount-display">
                    <span class="amount">{{ formatted_amount }}</span>
                    <span class="currency">{{ currency }}</span>
                </div>
                <span class="merchant-name">{{ merchant_name }}</span>
                <span class="reference">Ref: {{ reference }}</span>
            </div>

            <!-- Formulario de múltiples pasos -->
            <div id="step-country" class="step active">
                <!-- Selección de país y método de pago -->
            </div>

            <div id="step-phone" class="step">
                <!-- Entrada de número de teléfono -->
            </div>

            <div id="step-otp" class="step">
                <!-- Verificación OTP -->
            </div>

            <div id="step-processing" class="step">
                <!-- Indicador de procesamiento -->
            </div>

            <div id="step-result" class="step">
                <!-- Éxito o error -->
            </div>
        </div>

        {% if sandbox_mode %}
        <div id="test-guide" class="test-guide-panel">
            <!-- Tarjetas de prueba, números de teléfono, montos mágicos -->
        </div>
        {% endif %}
    </div>

    <!-- Selector de idioma -->
    <div class="language-selector">
        <select id="lang-select">
            {% for code, name in languages %}
            <option value="{{ code }}" {{ 'selected' if code == lang }}>
                {{ name }}
            </option>
            {% endfor %}
        </select>
    </div>
</body>
</html>

Soporte multilingüe (15 idiomas)

La página de checkout soporta 15 idiomas con traducciones cargadas del lado del servidor:

python# backend/locales/translations.py
TRANSLATIONS = {
    "en": {
        "checkout_title": "Checkout",
        "select_country": "Select your country",
        "select_method": "Choose payment method",
        "enter_phone": "Enter your phone number",
        "enter_otp": "Enter verification code",
        "processing": "Processing your payment...",
        "success": "Payment successful!",
        "failed": "Payment failed",
        "retry": "Try again",
        "sandbox_notice": "Test mode -- no real money will be charged",
    },
    "fr": {
        "checkout_title": "Paiement",
        "select_country": "Choisissez votre pays",
        "select_method": "Choisissez un moyen de paiement",
        "enter_phone": "Entrez votre numero de telephone",
        "enter_otp": "Entrez le code de verification",
        "processing": "Traitement de votre paiement...",
        "success": "Paiement reussi !",
        "failed": "Paiement echoue",
        "retry": "Reessayer",
        "sandbox_notice": "Mode test -- aucun argent reel ne sera debite",
    },
    "ar": {
        "checkout_title": "الدفع",
        "select_country": "اختر بلدك",
        "select_method": "اختر طريقة الدفع",
        # ... Traducciones en árabe con soporte RTL
    },
    # ... 12 idiomas más
}

Idiomas soportados: EN, FR, ES, PT, DE, IT, NL, AR, ZH, JA, KO, TR, RU, SW, HA.

El árabe activa el diseño RTL (derecha a izquierda) mediante el atributo dir="rtl" en el elemento HTML. El CSS incluye sobreescrituras RTL específicas para elementos de formulario, entradas de teléfono y disposiciones de botones.

El selector de idioma en la parte inferior de la página recarga el checkout con el idioma seleccionado:

javascriptdocument.getElementById("lang-select").addEventListener("change", function() {
    const url = new URL(window.location);
    url.pathname = url.pathname.replace(
        /^\/checkout\/[a-z]{2}\//,
        `/checkout/${this.value}/`
    );
    window.location.href = url.toString();
});

El flujo de múltiples pasos

El checkout alojado utiliza un formulario de múltiples pasos similar al widget, pero renderizado en servidor con mejora progresiva:

Paso 1: Selección de país + método
Paso 2: Entrada de número de teléfono
Paso 3: Verificación OTP (si es necesaria)
Paso 4: Procesamiento
Paso 5: Resultado (éxito/error)

Transiciones entre pasos

javascriptfunction showStep(stepId) {
    document.querySelectorAll(".step").forEach(s => {
        s.classList.remove("active");
    });
    document.getElementById(`step-${stepId}`).classList.add("active");
}

// Selección de país -> Selección de método
document.getElementById("country-select").addEventListener("change", function() {
    const country = this.value;
    loadMethodsForCountry(country);
});

// Selección de método -> Entrada de teléfono
document.querySelectorAll(".method-btn").forEach(btn => {
    btn.addEventListener("click", function() {
        selectedMethod = this.dataset.method;
        updatePhonePrefix(selectedCountry);
        showStep("phone");
    });
});

// Envío de teléfono -> Inicio del pago
document.getElementById("pay-btn").addEventListener("click", async function() {
    const phone = dialCode + document.getElementById("phone-input").value;
    showStep("processing");
    await initiatePayment(phone);
});

Integración del SDK de PaiementPro

Para pagos con PaiementPro, el checkout alojado integra el SDK JavaScript de PaiementPro directamente:

html{% if provider == "paiementpro" %}
<script src="https://cdn.paiementpro.net/js/paiementpro.js"></script>
<script>
    async function initiatePaiementProPayment(phone) {
        const pp = new PaiementPro({
            merchantId: "{{ paiementpro_merchant_id }}",
            amount: {{ amount }},
            currency: "{{ currency }}",
            channel: "{{ paiementpro_channel }}",
            reference: "{{ reference }}",
            customerPhone: phone,
            returnURL: "{{ return_url }}",
            notifyURL: "{{ webhook_url }}",
        });

        pp.on("success", function(data) {
            showStep("result");
            showSuccess(data);
        });

        pp.on("error", function(data) {
            showStep("result");
            showError(data.message || "Payment failed");
        });

        pp.submit();
    }
</script>
{% endif %}

El SDK de PaiementPro maneja el procesamiento del pago, pero la página de checkout proporciona la interfaz circundante (visualización del monto, selección de idioma, modo oscuro).

Modo oscuro y claro

La página de checkout soporta ambos temas, determinados por la configuración de la sesión de checkout o la preferencia del usuario:

javascript// Verificar preferencia del sistema
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;

// Verificar configuración de la sesión
const sessionDark = "{{ dark_mode }}" === "True";

if (prefersDark || sessionDark) {
    document.body.classList.add("dark");
}

// Botón de alternancia
document.getElementById("theme-toggle")?.addEventListener("click", function() {
    document.body.classList.toggle("dark");
});

El CSS usa propiedades personalizadas CSS para los temas:

css.checkout-card {
    background: var(--bg);
    color: var(--text);
    border: 1px solid var(--border);
    border-radius: 24px;
    box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.1);
}

.dark .checkout-card {
    box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}

Modo sandbox

Cuando la sesión de checkout se crea con una clave API de sandbox, el checkout alojado se renderiza en modo sandbox con funcionalidades adicionales:

Indicadores visuales

  • Un banner animado con efecto shimmer "SANDBOX MODE" en la parte superior
  • Borde color ámbar en la tarjeta de checkout
  • Aviso de "No se cobrará dinero real"

Panel de guía de pruebas

Un panel desplegable en el lado derecho del checkout (añadido en la sesión 059):

html{% if sandbox_mode %}
<div id="test-guide" class="test-guide-panel">
    <div class="panel-header">
        <h3>Test Guide</h3>
        <button id="toggle-guide" class="toggle-btn green">
            Close
        </button>
    </div>

    <div class="panel-section">
        <h4>Test Cards</h4>
        <table>
            <tr><td><code>4242424242424242</code></td><td>Success</td></tr>
            <tr><td><code>4000000000000002</code></td><td>Decline</td></tr>
        </table>
    </div>

    <div class="panel-section">
        <h4>Test Phone Numbers</h4>
        <table>
            <tr><td><code>+11111111111</code></td><td>Success</td></tr>
            <tr><td><code>+22222222222</code></td><td>Failure</td></tr>
            <tr><td><code>+44444444444</code></td><td>OTP (123456)</td></tr>
        </table>
    </div>

    <div class="panel-section">
        <h4>Magic Amounts</h4>
        <table>
            <tr><td><code>10000</code></td><td>Success</td></tr>
            <tr><td><code>99999</code></td><td>Failure</td></tr>
            <tr><td><code>88888</code></td><td>OTP required</td></tr>
        </table>
    </div>
</div>
{% endif %}

La guía de pruebas usa estilo glassmorphism con un fondo oscuro, proporcionando toda la información que un desarrollador necesita para probar pagos sin salir de la página de checkout.

Valores predeterminados del teléfono en sandbox

En modo sandbox, el país predeterminado se establece en EE. UU. (+1) con una pista útil:

pythonif sandbox_mode:
    context["default_country"] = "US"
    context["default_phone_prefix"] = "+1"
    context["phone_placeholder"] = "1111111111"
    context["phone_hint"] = "Use +11111111111 for successful payment"

La ruta del backend

La página de checkout es servida por una ruta FastAPI que carga los datos de la sesión y renderiza la plantilla:

python@router.get("/checkout/{lang}/{session_id}")
async def checkout_page(
    request: Request,
    lang: str,
    session_id: str,
):
    """Render hosted checkout page."""
    session = await get_checkout_session(session_id)
    if not session:
        raise HTTPException(status_code=404, detail="Session not found")

    # Determinar modo sandbox desde la clave API
    sandbox_mode = session["api_key"].startswith("sk_sand_")

    # Cargar traducciones
    translations = TRANSLATIONS.get(lang, TRANSLATIONS["en"])

    # Cargar métodos de pago para la sesión
    methods = await get_methods_for_country(session["country"])

    context = {
        "request": request,
        "session": session,
        "t": translations,
        "lang": lang,
        "rtl": lang == "ar",
        "dark_mode": session.get("dark_mode", False),
        "sandbox_mode": sandbox_mode,
        "amount": session["amount"],
        "currency": session["currency"],
        "formatted_amount": format_amount(session["amount"], session["currency"]),
        "merchant_name": session["app_name"],
        "reference": session["reference"],
        "methods": methods,
        "languages": SUPPORTED_LANGUAGES,
        "return_url": f"{BASE_URL}/v1/payments/{session['transaction_id']}/return",
    }

    if sandbox_mode:
        context["default_country"] = "US"
        context["default_phone_prefix"] = "+1"
        context["phone_placeholder"] = "1111111111"
        context["phone_hint"] = "Use +11111111111 for successful payment"

    return templates.TemplateResponse("checkout.html", context)

Plantilla vs. widget: cuándo usar cuál

CaracterísticaWidget de checkoutCheckout alojado
IntegraciónEtiqueta script en el sitio del comercianteRedirección a URL de 0fee
Control de UIEl comerciante controla la página circundante0fee controla todo
Cumplimiento PCILa página del comerciante ve el formulario de tarjetaDatos de tarjeta en la página de 0fee
Tamaño del bundle21KB JavaScriptCero JavaScript para el comerciante
PersonalizaciónLimitada (monto, moneda, callbacks)Idioma, tema, preajustes de país
Experiencia móvilSuperposición modalPágina completa

Lo que aprendimos

Construir el checkout alojado nos enseñó tres cosas:

  1. Las páginas renderizadas en servidor superan a las SPA para formularios de pago. La primera carga es más rápida, no hay paquete JavaScript que descargar, y la página funciona incluso con JavaScript deshabilitado (para el renderizado inicial). La mejora progresiva añade interactividad sin bloquear la visualización inicial.
  1. 15 idiomas necesitan soporte RTL. Añadir árabe no fue solo traducir cadenas de texto -- requirió invertir la dirección del diseño, ajustar el posicionamiento de elementos de formulario y probar cada componente visual en modo RTL.
  1. Sandbox y producción deben usar la misma plantilla. La bandera sandbox_mode habilita funcionalidades adicionales (guía de pruebas, banner, valores predeterminados) sin duplicar la plantilla. Una plantilla, dos modos -- esto mantiene la experiencia visual consistente entre pruebas y producción.

El checkout alojado evolucionó de una página Jinja2 básica en la sesión 004 a una experiencia de checkout completa multilingüe, con modo oscuro y consciente del sandbox para la sesión 059. Sirve como la ruta principal de checkout para desarrolladores que prefieren el flujo de redirección sobre el widget embebido.


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 ingenieros humanos. 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