Skip to main content

Build Your First Checkout Funnel

Create a complete 3-step funnel: Checkout → Upsell → Thank You. Learn how data flows automatically between steps!
What you’ll learn:
  • ✅ Setup a React plugin project
  • ✅ Create multiple pages
  • ✅ Pass data between pages
  • ✅ Use static resources (for upsells/offers)
  • ✅ Deploy to TagadaPay
Related Guides:
Time: ~20 minutes | Difficulty: Beginner

🎯 What You’ll Build

A 3-page checkout funnel:
  1. Checkout Page - Collect payment → Create order
  2. Upsell Page - Show special offer using static resources
  3. Thank You Page - Display order confirmation
Data Flow:
Checkout → (order data) → Upsell → (order + upsell) → Thank You

Step 1: Create Your Project

# Create project
npm create vite@latest my-checkout -- --template react-ts
cd my-checkout

# Install SDK
npm install @tagadapay/plugin-sdk wouter

# Install dependencies
npm install

Step 2: Create Plugin Manifest

The manifest tells TagadaPay about your plugin’s pages and what data they need. Create plugin.manifest.json in your project root:
{
  "name": "My First Checkout",
  "pluginId": "my-first-checkout",
  "version": "1.0.0",
  "description": "A simple checkout with upsell",
  "mode": "direct-mode",
  "category": "checkout",
  "tags": ["checkout", "upsell"],
  "configuration": {
    "schema": "./config/schema.json",
    "uiSchema": "./config/ui-schema.json",
    "presets": [
      {
        "id": "default",
        "name": "Default",
        "description": "Default configuration",
        "config": "./config/default.config.json"
      }
    ]
  },
  "pages": [
    {
      "path": "/checkout",
      "features": [
        {
          "type": "checkout",
          "requirements": [
            {
              "resource": "checkoutSession",
              "from": [
                {
                  "name": "checkoutToken",
                  "type": "query"
                }
              ]
            }
          ]
        }
      ],
      "remappable": true
    },
    {
      "path": "/upsell",
      "features": [
        {
          "type": "offer",
          "requirements": [
            {
              "resource": "order",
              "from": [
                {
                  "name": "id",
                  "type": "context"
                }
              ]
            },
            {
              "resource": "offer",
              "from": [
                {
                  "name": "id",
                  "type": "static"
                }
              ]
            }
          ]
        }
      ],
      "remappable": true
    },
    {
      "path": "/thankyou/:orderId",
      "features": [
        {
          "type": "thankyou",
          "requirements": [
            {
              "resource": "order",
              "from": [
                {
                  "name": "id",
                  "type": "path"
                }
              ]
            }
          ]
        }
      ],
      "remappable": true
    }
  ],
  "router": {
    "basePath": "/",
    "matcher": ".*",
    "excluder": null
  },
  "requirements": {
    "sdk": "^2.2.0",
    "sdkVersion": "v2"
  }
}
Basic Info:
  • name, pluginId, version - Identify your plugin
  • mode: "direct-mode" - Plugin runs directly (no iframe)
  • category - Helps organize in marketplace
Pages Array: Each page defines:
  • path - The URL path (e.g., /checkout, /upsell/:id)
  • features - What type of page it is (checkout, offer, thankyou)
  • requirements - What data this page needs
  • remappable - Allows custom URLs (highly recommended!)
Requirements Explained:
{
  "resource": "order",           // What resource you need
  "from": [                       // Where to get it from
    {
      "name": "id",               // The field name
      "type": "context"           // Source type
    }
  ]
}
Resource Types:
  • query - From URL query params (?checkoutToken=abc)
  • path - From URL path (/thankyou/:orderId)
  • context - From previous step’s funnel context
  • static - From CRM configuration (offers, products, etc.)
What are Static Resources?Static resources are IDs configured by merchants in the CRM. They let you build flexible plugins where merchants choose which products/offers to show without changing your code.Example Use Case: You build an upsell page. Different merchants want to show different products. Instead of hardcoding product IDs, you use a static resource!In Manifest:
{
  "resource": "offer",        // Name it "offer"
  "from": [{
    "name": "id",             // It's an ID
    "type": "static"          // From CRM config
  }]
}
In Your Code:
// Access the offer ID configured by merchant
const offerId = funnel.context?.static?.offer?.id;
For Local Testing: Create config/resources.static.json:
{
  "offer": {
    "id": "offer_test123"
  }
}
The SDK automatically loads this file in local development!
Configuration lets merchants customize your plugin without code changes.Structure:
  • schema.json - Defines what settings are available
  • uiSchema.json - How settings appear in CRM UI
  • presets - Pre-made configurations merchants can choose
Example Preset:
{
  "id": "premium",
  "name": "Premium Theme",
  "description": "High-end checkout design",
  "config": "./config/premium.config.json"
}
Each config file should have metadata:
{
  "meta": {
    "id": "premium",
    "name": "Premium Theme",
    "description": "High-end checkout design"
  },
  "branding": {
    "primaryColor": "#000000",
    "companyName": "Premium Store"
  }
}
Access in code with usePluginConfig() hook!

Step 3: Setup Provider

The TagadaProvider wraps your app and provides all functionality. Create src/main.tsx:
import React from 'react';
import ReactDOM from 'react-dom/client';
import { TagadaProvider } from '@tagadapay/plugin-sdk/v2';
import App from './App';
import './index.css';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <TagadaProvider>
      <App />
    </TagadaProvider>
  </React.StrictMode>
);
That’s it! TagadaProvider automatically:
  • ✅ Creates a session
  • ✅ Initializes the funnel
  • ✅ Enables debug mode (local dev)
  • ✅ Handles redirects
No manual initialization needed!

Step 4: Build Checkout Page

Create src/pages/CheckoutPage.tsx:
import { useState } from 'react';
import { useFunnel, useCheckout, useCurrency } from '@tagadapay/plugin-sdk/v2';

export default function CheckoutPage() {
  const funnel = useFunnel();
  const { data: checkout, isLoading } = useCheckout();
  const { formatMoney } = useCurrency();
  
  const [email, setEmail] = useState('');
  const [name, setName] = useState('');
  
  const handlePayment = async (e: React.FormEvent) => {
    e.preventDefault();
    
    // Create mock order
    const order = {
      id: `ord_${Date.now()}`,
      amount: checkout?.total || 4900,
      currency: checkout?.currency || 'USD',
      items: checkout?.lineItems || []
    };

    const customer = {
      id: `cus_${Date.now()}`,
      email,
      name
    };

    // Navigate to upsell page with order data
    await funnel.next({
      type: 'payment_success',
      data: {
        resources: {
          order,           // ← Order data flows to next page
          customer         // ← Customer data flows too
        }
      }
    });
    
    // SDK auto-redirects to upsell page!
  };
  
  if (isLoading || !funnel.context) {
    return <div className="loading">Loading checkout...</div>;
  }
  
  return (
    <div className="checkout-page">
      <h1>Checkout</h1>
      
      {checkout && (
        <div className="order-summary">
          <h2>Order Summary</h2>
          <div className="line-items">
            {checkout.lineItems?.map((item: any) => (
              <div key={item.id} className="line-item">
                <span>{item.quantity}x {item.title}</span>
                <span>{formatMoney(item.price * item.quantity, checkout.currency)}</span>
              </div>
            ))}
          </div>
          <div className="total">
            <strong>Total:</strong> {formatMoney(checkout.total, checkout.currency)}
          </div>
        </div>
      )}
      
      <form onSubmit={handlePayment} className="checkout-form">
        <div className="form-group">
          <label htmlFor="email">Email</label>
          <input
            id="email"
            type="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            placeholder="[email protected]"
            required
          />
        </div>
        
        <div className="form-group">
          <label htmlFor="name">Full Name</label>
          <input
            id="name"
            type="text"
            value={name}
            onChange={(e) => setName(e.target.value)}
            placeholder="John Doe"
            required
          />
        </div>
        
        <div className="form-group">
          <label htmlFor="card">Card Number (Test: 4242...)</label>
          <input
            id="card"
            type="text"
            defaultValue="4242424242424242"
            placeholder="4242 4242 4242 4242"
            required
          />
        </div>
        
        <button type="submit" className="btn-primary">
          Pay {formatMoney(checkout?.total || 4900, checkout?.currency || 'USD')}
        </button>
      </form>
    </div>
  );
}

Step 5: Build Upsell Page (Using Static Resources!)

This page shows an offer. The merchant configures which offer in the CRM using static resources. Create src/pages/UpsellPage.tsx:
import { useFunnel, useOffer, useCurrency } from '@tagadapay/plugin-sdk/v2';

export default function UpsellPage() {
  const funnel = useFunnel();
  const { formatMoney } = useCurrency();
  
  // 🎯 Get offer ID from STATIC resource (configured by merchant)
  const offerId = funnel.context?.static?.offer?.id;
  
  // 📦 Get order from PREVIOUS page (from context)
  const order = funnel.context?.resources?.order;
  const customer = funnel.context?.resources?.customer;
  
  // Fetch offer details
  const { data: offers } = useOffer({
    offerIds: offerId ? [offerId] : [],
    orderId: order?.id
  });
  
  const offer = offers?.[0];
  
  const handleAccept = async () => {
    // Add upsell to order and go to thank you
    await funnel.next({
      type: 'offer_accepted',
      data: {
        resources: {
          order: {
            ...order,
            upsell: { 
              id: offer.id, 
              amount: offer.price 
            }
          },
          customer
        }
      }
    });
  };
  
  const handleDecline = async () => {
    // Skip upsell, go to thank you
    await funnel.next({
      type: 'offer_declined',
      data: {
        resources: {
          order,
          customer
        }
      }
    });
  };
  
  if (!funnel.context || !offerId) {
    return <div className="loading">Loading...</div>;
  }
  
  if (!offer) {
    // No offer configured - skip to thank you
    handleDecline();
    return <div className="loading">Loading...</div>;
  }
  
  return (
    <div className="upsell-page">
      <h1>🎉 Special Offer Just For You!</h1>
      
      <div className="offer-card">
        <img src={offer.imageUrl} alt={offer.title} />
        <h2>{offer.title}</h2>
        <p>{offer.description}</p>
        
        <div className="pricing">
          <div className="price">
            <span className="original">{formatMoney(offer.compareAtPrice, order.currency)}</span>
            <span className="sale">{formatMoney(offer.price, order.currency)}</span>
          </div>
          <span className="savings">
            Save {formatMoney(offer.compareAtPrice - offer.price, order.currency)}!
          </span>
        </div>
        
        <button onClick={handleAccept} className="btn-primary">
          Yes! Add To My Order
        </button>
        <button onClick={handleDecline} className="btn-secondary">
          No Thanks, Continue
        </button>
      </div>
      
      <div className="order-info">
        <p>Your order: {formatMoney(order.amount, order.currency)}</p>
        <p>Customer: {customer?.email}</p>
      </div>
    </div>
  );
}
Step by Step:
  1. In Manifest - Declare you need an “offer” resource:
{
  "resource": "offer",
  "from": [{ "name": "id", "type": "static" }]
}
  1. In Code - Access it from funnel.context.static:
const offerId = funnel.context?.static?.offer?.id;
  1. Merchant Configures - In CRM, they select which offer to show:
Funnel Step → Upsell Page → Static Resources → Offer → (select from dropdown)
  1. For Local Testing - Create config/resources.static.json:
{
  "offer": {
    "id": "offer_test123"
  }
}
Why This is Powerful:
  • Same plugin code works for all merchants
  • Merchants customize without touching code
  • Easy A/B testing (change offer ID in CRM)
  • No redeployment needed to change offers
Context Resources (from previous pages):
// Data passed from checkout page
const order = funnel.context?.resources?.order;
const customer = funnel.context?.resources?.customer;
  • ✅ Dynamic data from previous steps
  • ✅ Changes per user/session
  • ✅ Example: order details, customer info
Static Resources (configured in CRM):
// Configured by merchant in CRM
const offerId = funnel.context?.static?.offer?.id;
  • ✅ Fixed per funnel configuration
  • ✅ Same for all users
  • ✅ Example: offer IDs, product IDs, variant IDs

Step 6: Build Thank You Page

Create src/pages/ThankYouPage.tsx:
import { useFunnel, useCurrency } from '@tagadapay/plugin-sdk/v2';
import { useParams } from 'wouter';

export default function ThankYouPage() {
  const { orderId } = useParams<{ orderId: string }>();
  const funnel = useFunnel();
  const { formatMoney } = useCurrency();
  
  // Get data from previous pages
  const order = funnel.context?.resources?.order;
  const customer = funnel.context?.resources?.customer;
  const upsell = order?.upsell;
  
  if (!funnel.context || !order) {
    return <div className="loading">Loading...</div>;
  }
  
  const totalAmount = order.amount + (upsell?.amount || 0);
  
  return (
    <div className="thankyou-page">
      <div className="success-icon"></div>
      <h1>Thank You for Your Purchase!</h1>
      <p>Order #{order.id}</p>
      
      <div className="order-details">
        <h2>Order Summary</h2>
        
        <div className="order-line">
          <span>Original Order</span>
          <span>{formatMoney(order.amount, order.currency)}</span>
        </div>
        
        {upsell && (
          <div className="order-line upsell">
            <span>✨ Special Offer</span>
            <span>{formatMoney(upsell.amount, order.currency)}</span>
          </div>
        )}
        
        <div className="order-line total">
          <strong>Total Paid</strong>
          <strong>{formatMoney(totalAmount, order.currency)}</strong>
        </div>
      </div>
      
      <div className="customer-info">
        <p><strong>Email:</strong> {customer?.email}</p>
        <p><strong>Name:</strong> {customer?.name}</p>
      </div>
      
      <p className="confirmation">
        A confirmation email has been sent to {customer?.email}
      </p>
      
      <button onClick={() => window.location.href = '/'} className="btn-primary">
        Continue Shopping
      </button>
    </div>
  );
}

Step 7: Setup Routing

Create src/App.tsx:
import { Route, Switch } from 'wouter';
import CheckoutPage from './pages/CheckoutPage';
import UpsellPage from './pages/UpsellPage';
import ThankYouPage from './pages/ThankYouPage';

export default function App() {
  return (
    <Switch>
      <Route path="/checkout" component={CheckoutPage} />
      <Route path="/upsell" component={UpsellPage} />
      <Route path="/thankyou/:orderId" component={ThankYouPage} />
      <Route path="/" component={CheckoutPage} />
    </Switch>
  );
}

Step 8: Create Config for Local Testing

Create config/resources.static.json:
{
  "offer": {
    "id": "offer_test123"
  }
}
What this does: When developing locally, the SDK automatically loads this file. This lets you test your upsell page without configuring anything in the CRM!The SDK detects local environment and loads static resources from this file. In production, resources come from the CRM configuration.

Step 9: Add Basic Styles

Create src/index.css:
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: system-ui, -apple-system, sans-serif;
  line-height: 1.6;
  color: #333;
}

.checkout-page,
.upsell-page,
.thankyou-page {
  max-width: 600px;
  margin: 0 auto;
  padding: 2rem;
}

h1 {
  font-size: 2rem;
  margin-bottom: 1rem;
}

h2 {
  font-size: 1.5rem;
  margin-bottom: 0.75rem;
}

/* Order Summary */
.order-summary {
  background: #f5f5f5;
  padding: 1.5rem;
  border-radius: 8px;
  margin: 2rem 0;
}

.line-items {
  margin: 1rem 0;
}

.line-item {
  display: flex;
  justify-content: space-between;
  padding: 0.5rem 0;
  border-bottom: 1px solid #ddd;
}

.total {
  display: flex;
  justify-content: space-between;
  padding-top: 1rem;
  font-size: 1.25rem;
}

/* Forms */
.checkout-form {
  margin-top: 2rem;
}

.form-group {
  margin-bottom: 1.5rem;
}

.form-group label {
  display: block;
  margin-bottom: 0.5rem;
  font-weight: 500;
}

.form-group input {
  width: 100%;
  padding: 0.75rem;
  border: 1px solid #ddd;
  border-radius: 6px;
  font-size: 1rem;
}

.form-group input:focus {
  outline: none;
  border-color: #0070f3;
  box-shadow: 0 0 0 3px rgba(0, 112, 243, 0.1);
}

/* Buttons */
.btn-primary,
.btn-secondary {
  width: 100%;
  padding: 1rem;
  border: none;
  border-radius: 6px;
  font-size: 1rem;
  font-weight: 600;
  cursor: pointer;
  margin-top: 0.5rem;
  transition: all 0.2s;
}

.btn-primary {
  background: #0070f3;
  color: white;
}

.btn-primary:hover {
  background: #0051cc;
  transform: translateY(-1px);
  box-shadow: 0 4px 12px rgba(0, 112, 243, 0.3);
}

.btn-secondary {
  background: transparent;
  color: #666;
  border: 1px solid #ddd;
}

.btn-secondary:hover {
  background: #f5f5f5;
}

/* Upsell Page */
.offer-card {
  background: white;
  border: 2px solid #0070f3;
  border-radius: 12px;
  padding: 2rem;
  margin: 2rem 0;
  text-align: center;
  box-shadow: 0 4px 20px rgba(0, 112, 243, 0.1);
}

.offer-card img {
  max-width: 100%;
  border-radius: 8px;
  margin-bottom: 1rem;
}

.pricing {
  margin: 1.5rem 0;
}

.price {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 1rem;
  margin-bottom: 0.5rem;
}

.price .original {
  text-decoration: line-through;
  color: #999;
  font-size: 1.25rem;
}

.price .sale {
  color: #0070f3;
  font-size: 2rem;
  font-weight: bold;
}

.savings {
  display: inline-block;
  background: #10b981;
  color: white;
  padding: 0.5rem 1rem;
  border-radius: 20px;
  font-weight: 600;
}

.order-info {
  text-align: center;
  color: #666;
  font-size: 0.9rem;
  margin-top: 2rem;
  padding-top: 2rem;
  border-top: 1px solid #ddd;
}

/* Thank You Page */
.thankyou-page {
  text-align: center;
}

.success-icon {
  font-size: 4rem;
  margin-bottom: 1rem;
}

.order-details {
  background: #f5f5f5;
  padding: 2rem;
  border-radius: 8px;
  margin: 2rem 0;
  text-align: left;
}

.order-line {
  display: flex;
  justify-content: space-between;
  padding: 0.75rem 0;
  border-bottom: 1px solid #ddd;
}

.order-line.upsell {
  color: #10b981;
  font-weight: 500;
}

.order-line.total {
  border-bottom: none;
  padding-top: 1rem;
  font-size: 1.25rem;
}

.customer-info {
  text-align: left;
  background: #f9fafb;
  padding: 1rem;
  border-radius: 6px;
  margin: 1rem 0;
}

.confirmation {
  color: #666;
  margin: 2rem 0;
}

/* Loading */
.loading {
  text-align: center;
  padding: 4rem;
  color: #666;
}

Step 10: Test Locally

# Start development server
npm run dev

# Visit http://localhost:5173/checkout
1

Test Checkout

  1. Fill in email and name
  2. Use card: 4242424242424242
  3. Click “Pay”
  4. Should redirect to upsell page ✅
2

Test Upsell

  1. Should show your test offer (from resources.static.json)
  2. Order info from checkout should be visible
  3. Click “Accept” or “Decline”
  4. Should redirect to thank you page ✅
3

Test Thank You

  1. Should show order confirmation
  2. Should show upsell if accepted
  3. Should display customer email
Check the Console!The SDK logs helpful debug information:
  • ✅ Funnel Initialized - Funnel is ready
  • 🚀 Auto-redirecting to: - Navigation happening
  • Check funnel.context to see all available data

Step 11: Deploy to TagadaPay

# Build your plugin
npm run build

# Install CLI
npm install -g @tagadapay/cli

# Login
tgdcli login

# Deploy
tgdcli deploy
Follow the prompts to deploy your plugin!

Step 12: Configure in CRM

  1. Go to Funnels → Create new funnel
  2. Add Checkout Step → Select your plugin → /checkout page
  3. Add Upsell Step → Select your plugin → /upsell page
    • ⚠️ Important: Configure Static Resources:
      • Resource: offer
      • Select an offer from the dropdown
  4. Add Thank You Step → Select your plugin → /thankyou/:orderId page
  5. Save & Test!
Don’t forget to configure the static resource for the upsell page!Without it, the upsell won’t show. In the CRM:
  • Funnel → Upsell Step → Static Resources section
  • Select “offer” → Choose an offer from your catalog

🎉 Congratulations!

You’ve built a complete checkout funnel with:
  • ✅ Multi-step navigation
  • ✅ Automatic data flow between pages
  • ✅ Static resources for flexible configuration
  • ✅ Professional UI
  • ✅ Deployed to production

📚 What You Learned

Funnel Navigation

Pass data between steps using funnel.next() with resources

Static Resources

Let merchants configure offers/products without code changes

Context Resources

Access data from previous steps via funnel.context.resources

Auto-redirect

SDK handles navigation automatically

Next Steps


Common Questions

In Manifest - Add to requirements:
{
  "resource": "product",
  "from": [{ "name": "id", "type": "static" }]
}
In Code - Access it:
const productId = funnel.context?.static?.product?.id;
For Local Testing:
{
  "offer": { "id": "offer_123" },
  "product": { "id": "prod_456" }
}
Yes! Add anything to resources:
await funnel.next({
  data: {
    resources: {
      order: { ... },
      customer: { ... },
      customData: {
        selectedPlan: 'premium',
        addons: ['support', 'backup'],
        anything: 'you want!'
      }
    }
  }
});
Access on next page:
const customData = funnel.context?.resources?.customData;
Always check if it exists:
const offerId = funnel.context?.static?.offer?.id;

if (!offerId) {
  // Skip this page or show error
  await funnel.next({ type: 'skip_offer' });
  return;
}
Or provide a default:
const offerId = funnel.context?.static?.offer?.id || 'default_offer_id';
In your component:
useEffect(() => {
  console.log('Funnel Context:', funnel.context);
  console.log('Static Resources:', funnel.context?.static);
  console.log('Context Resources:', funnel.context?.resources);
}, [funnel.context]);
Or use the SDK debug drawer (in development mode)!