📦 Overview

The MersoModal component provides a clean, type-safe way to embed the payment modal in React applications. It handles all the complexity of iframe communication, form submission, and event handling.

🔒

Secure POST

JWT sent via POST body, not URL

📡

Event Callbacks

TypeScript callbacks for all events

🎨

Flexible Sizing

Customizable width and height

Auto-Submit

Form submits on component mount

📄 MersoModal.tsx

Copy this component into your React/Next.js project:

TypeScript (React)
"use client";

import React, { useEffect, useRef, useId } from "react";

interface MersoModalProps {
  modalServiceUrl?: string;
  jwtToken: string;
  productData?: Record<string, string>;
  onPaymentSuccess?: (data: {
    paymentIntentId: string;
    firstPaymentAmount?: string;
    weeklyPaymentAmount?: string;
    totalAmount?: string;
  }) => void;
  onPaymentCancelled?: () => void;
  onModalClose?: () => void;
  productType?: "Item" | "NFT";
  paymentMode?: "BNPL" | "UPFRONT";
  // Style options
  width?: number | string;
  height?: number | string;
}

const MersoModal: React.FC<MersoModalProps> = ({
  modalServiceUrl = "https://modal.dev.merso.io",
  jwtToken,
  productData,
  onPaymentSuccess,
  onPaymentCancelled,
  onModalClose,
  productType,
  paymentMode,
  width,
  height,
}) => {
  const iframeRef = useRef<HTMLIFrameElement>(null);
  const formRef = useRef<HTMLFormElement>(null);
  const hasSubmittedRef = useRef(false);
  const iframeName = useId().replace(/:/g, "_");

  // Build product data for POST request
  const getProductDataFields = (): Record<string, string> => {
    const fields: Record<string, string> = {
      jwt_token: jwtToken, // JWT sent securely via POST body
    };
    // Product type determines which data to send
    if (productType) {
      fields.product_type = productType.toUpperCase();
    }
    // Optional: Pre-select payment mode
    if (paymentMode) fields.payment_mode = paymentMode;

    if (productData) {
      Object.entries(productData).forEach(([key, value]) => {
        fields[key] = value;
      });
    }

    return fields;
  };

  // Submit form to iframe on mount
  useEffect(() => {
    if (formRef.current && !hasSubmittedRef.current) {
      hasSubmittedRef.current = true;
      formRef.current.submit();
    }
  }, []);

  // Listen for postMessage from iframe
  useEffect(() => {
    const handleMessage = (event: MessageEvent) => {
      if (event.origin === "https://js.stripe.com") return;

      const expectedTypes = [
        "payment-success",
        "payment-cancelled",
        "crypto-payment-success",
        "crypto-payment-error",
        "modal-close",
      ];

      if (!event.data?.type || !expectedTypes.includes(event.data.type)) return;

      if (event.origin !== modalServiceUrl) {
        console.warn("Message from unexpected origin:", event.origin);
        return;
      }

      if (event.data?.type === "payment-success") {
        onPaymentSuccess?.({
          paymentIntentId: event.data.paymentIntentId,
          firstPaymentAmount: event.data.firstPaymentAmount,
          weeklyPaymentAmount: event.data.weeklyPaymentAmount,
          totalAmount: event.data.totalAmount,
        });
      } else if (event.data?.type === "payment-cancelled") {
        onPaymentCancelled?.();
      } else if (event.data?.type === "crypto-payment-success") {
        onPaymentSuccess?.({
          paymentIntentId: event.data.transactionHash || "crypto-payment",
        });
      } else if (event.data?.type === "modal-close") {
        onModalClose?.();
      }
    };

    window.addEventListener("message", handleMessage);
    return () => window.removeEventListener("message", handleMessage);
  }, [modalServiceUrl, onPaymentSuccess, onPaymentCancelled, onModalClose]);

  const productDataFields = getProductDataFields();

  return (
    <>
      {/* Hidden form that POSTs to the iframe */}
      <form
        ref={formRef}
        action={`${modalServiceUrl}/modal`}
        method="POST"
        target={iframeName}
        style={{ display: "none" }}
      >
        {Object.entries(productDataFields).map(([key, value]) => (
          <input key={key} type="hidden" name={key} value={value} />
        ))}
      </form>

      {/* Iframe receives POST data */}
      <iframe
        ref={iframeRef}
        name={iframeName}
        title="Merso Payment Modal"
        allow="payment *; publickey-credentials-get *; publickey-credentials-create *; fullscreen *"
        sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation allow-modals"
        // @ts-ignore - React requires lowercase for this HTML attribute
        allowtransparency="true"
        scrolling="no"
        style={{
          width: width ? `calc(${typeof width === 'number' ? `${width}px` : width} * 1.1)` : "100%",
          height: height ? `calc(${typeof height === 'number' ? `${height}px` : height} * 1.1)` : "100%",
          border: "none",
          overflow: "hidden",
          background: "transparent",
        }}
      />
    </>
  );
};

export default MersoModal;

⚙️ Props Reference

Prop Type Required Default Description
jwtToken string ✅ Yes Your JWT token from the /auth endpoint
productType "Item" | "NFT" No Type of product being purchased
paymentMode "BNPL" | "UPFRONT" No Pre-select a payment mode
productData Record<string, string> No Additional product data (item_id, item_price, etc.)
modalServiceUrl string No https://modal.dev.merso.io Base URL of the modal service
onPaymentSuccess function No Callback when payment succeeds
onPaymentCancelled function No Callback when user cancels
onModalClose function No Callback when user clicks Close after successful payment
width number | string No "100%" Iframe width (e.g., 480 or "480px")
height number | string No "100%" Iframe height (e.g., 600 or "600px")

💡 Usage Examples

Basic Item Purchase

TypeScript (React)
import MersoModal from "./components/MersoModal";

function CheckoutPage() {
  const handleSuccess = (data) => {
    console.log("Payment successful!", data.paymentIntentId);
    // Redirect to success page or update UI
  };

  const handleCancel = () => {
    console.log("Payment cancelled");
    // Handle cancellation
  };

  return (
    <div style={{ width: 480, height: 600 }}>
      <MersoModal
        jwtToken="your-jwt-token"
        productType="Item"
        productData={{
          item_id: "123",
          item_name: "Premium Sword",
          item_price: "29.99",
          player_email: "player@example.com",
        }}
        onPaymentSuccess={handleSuccess}
        onPaymentCancelled={handleCancel}
        width={480}
        height={600}
      />
    </div>
  );
}

NFT Purchase

TypeScript (React)
<MersoModal
  jwtToken={jwtToken}
  productType="NFT"
  paymentMode="BNPL"/"UPFRONT"
  productData={{
    wallet_address: "0x1234...abcd",
    collection_address: "0xNFT...collection",
    token_id: "1",
    token_price: "0.05",
    token_name: "Epic NFT #1",
    user_email: "user@example.com",
    chain_id: "1",
  }}
  onPaymentSuccess={(data) => {
    console.log("NFT purchased with BNPL!");
    console.log("First payment:", data.firstPaymentAmount);
    console.log("Weekly:", data.weeklyPaymentAmount);
  }}
/>

Modal Overlay Pattern

TypeScript (React)
import { useState } from "react";
import MersoModal from "./components/MersoModal";

function GameShop() {
  const [showModal, setShowModal] = useState(false);
  const [selectedItem, setSelectedItem] = useState(null);
  const [paymentData, setPaymentData] = useState(null);

  const openCheckout = (item) => {
    setSelectedItem(item);
    setShowModal(true);
  };

  return (
    <>
      <button onClick={() => openCheckout({ id: "123", name: "Sword", price: "29.99" })}>
        Buy Sword - $29.99
      </button>

      {showModal && (
        <div style={{
          position: "fixed",
          inset: 0,
          background: "rgba(0,0,0,0.6)",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          zIndex: 1000,
        }}>
          <div style={{ width: 480, height: 600, borderRadius: "1rem", overflow: "hidden" }}>
            <MersoModal
              jwtToken={jwtToken}
              productType="Item"
              productData={{
                item_id: selectedItem.id,
                item_name: selectedItem.name,
                item_price: selectedItem.price,
              }}
              onPaymentSuccess={(data) => {
                // Store payment data for use after modal closes
                setPaymentData(data);
                console.log("Payment successful!", data);
              }}
              onPaymentCancelled={() => setShowModal(false)}
              onModalClose={() => {
                // User clicked Close after seeing success screen
                setShowModal(false);
                if (paymentData) {
                  alert(`Purchase complete! ID: ${paymentData.paymentIntentId}`);
                }
              }}
              width="100%"
              height="100%"
            />
          </div>
        </div>
      )}
    </>
  );
}

🔧 How It Works

🔄 Form POST to Iframe

This component uses a hidden form that POSTs data directly to the iframe. This is more secure as the JWT token and product data aren't exposed in the URL.

The Flow:

  1. Mount: Component creates a hidden form with all product data as hidden inputs
  2. Submit: On mount, the form auto-submits targeting the iframe by name
  3. Load: Iframe receives POST data and renders the payment modal
  4. Interact: User completes payment in the iframe
  5. Success: Modal shows success screen with payment details
  6. Message: Modal sends payment-success postMessage with payment data
  7. Close: User clicks Close button, modal sends modal-close postMessage
  8. Callback: Component calls onPaymentSuccess, then onModalClose to close the modal
Key Implementation Details
// Unique iframe name using React's useId hook
const iframeName = useId().replace(/:/g, "_");

// Prevent double-submission with ref
const hasSubmittedRef = useRef(false);

// Form targets iframe by name
<form target={iframeName} method="POST" action="/modal">
  <input type="hidden" name="jwt_token" value={jwtToken} />
  ...
</form>

<iframe name={iframeName} ... />

🔒 Security Considerations

⚠️ Never Hardcode JWT Tokens

Always fetch your JWT token from a secure backend endpoint. Never commit tokens to your codebase or expose your API key in client-side code.

Recommended: Fetch Token Server-Side
// pages/api/merso-token.ts (Next.js API Route)
export async function POST(req: Request) {
  const response = await fetch("https://modal.dev.merso.io/auth", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      game_id: process.env.MERSO_GAME_ID,
      api_key: process.env.MERSO_API_KEY,
    }),
  });
  
  const data = await response.json();
  return Response.json({ token: data.authResult.token });
}

// Client component
const [jwtToken, setJwtToken] = useState("");

useEffect(() => {
  fetch("/api/merso-token", { method: "POST" })
    .then(res => res.json())
    .then(data => setJwtToken(data.token));
}, []);

Iframe Sandbox Attributes

The component uses restrictive sandbox attributes for security:

  • allow-scripts - Required for modal functionality
  • allow-same-origin - Required for Stripe integration
  • allow-forms - Required for payment forms
  • allow-popups - Required for 3D Secure authentication
  • allow-popups-to-escape-sandbox - Required for external popup windows
  • allow-top-navigation-by-user-activation - Required for certain redirect flows
  • allow-modals - Required for confirmation dialogs

📨 Callback Data

onPaymentSuccess Data

TypeScript
interface PaymentSuccessData {
  paymentIntentId: string;      // Stripe Payment Intent ID
  firstPaymentAmount?: string;  // BNPL: First payment amount
  weeklyPaymentAmount?: string; // BNPL: Weekly installment
  totalAmount?: string;         // Total purchase amount
}

Event Types Handled

  • payment-success - Fiat payment completed (Stripe)
  • payment-cancelled - User cancelled the payment
  • crypto-payment-success - Crypto/NFT payment completed
  • crypto-payment-error - Crypto payment failed
  • modal-close - User clicked Close button after successful payment