Skip to content

Tutorial 3: Restaurant Menu with External Data

Build a collaborative restaurant ordering system with real-time updates and external data integration.

What You'll Build

A multi-component restaurant ordering system with:

  • Menu Item Cards - Browse menu with beautiful cards
  • Order Summary - Real-time order tracking
  • Participant Grid - See who ordered what
  • External data integration via webhooks
  • Real-time collaboration across multiple users

Difficulty: Advanced Time: 45 minutes Prerequisites: Tutorial 1: Quick Start, Tutorial 2: Balloon Game

Try it Live

🚀 Open Interactive Demo (Production) - See the full system in action!

For local testing, create your own demo board with the script below.

This demo includes 4 menu items, order summary, and participant tracking. Try opening it in multiple browsers to see real-time collaboration.

Components Version: v1.0.1 (with props listening and event broadcasting fixes) Board UUID: d3b062e2-5222-459d-b255-5102df30dc24

Quick start: API_KEY="your-api-key" ./scripts/demo/create-restaurant-demo.sh

Preview

Interactive restaurant ordering system with real-time collaboration

Learning Goals

  • External data integration via webhooks
  • Data provider configuration
  • Sandbox mode testing with mock data
  • Dynamic content rendering
  • Error handling and loading states

Architecture Overview

mermaid
graph LR
    A[Component] -->|Request| B[BoardAPI]
    B -->|Webhook| C[Your Backend]
    C -->|Response| B
    B -->|Data| A

Flow:

  1. Component requests data from BoardAPI
  2. BoardAPI calls your webhook endpoint
  3. Your backend returns restaurant + menu data
  4. Component renders the data

Step 1: Create Your Backend Endpoint

First, create an endpoint that returns restaurant data:

Express.js Example

javascript
// server.js
const express = require('express');
const app = express();

app.get('/api/restaurant/:slug', (req, res) => {
  const { slug } = req.params;

  // In production, fetch from database
  const restaurants = {
    'pizza-heaven': {
      name: 'Pizza Heaven',
      cuisine: 'Italian',
      logo: 'https://example.com/logo.png',
      menu: [
        {
          id: 1,
          name: 'Margherita Pizza',
          description: 'Fresh mozzarella, basil, tomato sauce',
          price: 12.99,
          category: 'Pizza'
        },
        {
          id: 2,
          name: 'Pepperoni Pizza',
          description: 'Classic pepperoni with mozzarella',
          price: 14.99,
          category: 'Pizza'
        },
        {
          id: 3,
          name: 'Caesar Salad',
          description: 'Romaine, parmesan, croutons, Caesar dressing',
          price: 8.99,
          category: 'Salads'
        }
      ]
    },
    'sushi-master': {
      name: 'Sushi Master',
      cuisine: 'Japanese',
      logo: 'https://example.com/sushi-logo.png',
      menu: [
        {
          id: 4,
          name: 'California Roll',
          description: 'Crab, avocado, cucumber',
          price: 10.99,
          category: 'Rolls'
        },
        {
          id: 5,
          name: 'Salmon Nigiri',
          description: 'Fresh salmon on sushi rice',
          price: 6.99,
          category: 'Nigiri'
        }
      ]
    }
  };

  const restaurant = restaurants[slug];

  if (!restaurant) {
    return res.status(404).json({ error: 'Restaurant not found' });
  }

  res.json(restaurant);
});

app.listen(3000, () => {
  console.log('Restaurant API running on http://localhost:3000');
});

Test your endpoint:

bash
curl http://localhost:3000/api/restaurant/pizza-heaven

Step 2: Create Component Files

bash
mkdir ~/restaurant-menu-component
cd ~/restaurant-menu-component

manifest.json

json
{
  "name": "restaurant-menu",
  "version": "1.0.0",
  "description": "Dynamic restaurant menu with external data integration",
  "author": "Your Name",
  "license": "MIT",
  "entry": "index.html",
  "component": {
    "width": 500,
    "height": 600,
    "resizable": true,
    "schema": {
      "type": "object",
      "properties": {
        "restaurantSlug": {
          "type": "string",
          "description": "Restaurant identifier (e.g., 'pizza-heaven')",
          "default": "pizza-heaven"
        },
        "showPrices": {
          "type": "boolean",
          "description": "Display menu item prices",
          "default": true
        },
        "currency": {
          "type": "string",
          "enum": ["USD", "EUR", "GBP"],
          "default": "USD"
        }
      },
      "required": ["restaurantSlug"]
    }
  }
}

index.html

html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }

    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      width: 100%;
      height: 100%;
      overflow: auto;
      background: #f8f9fa;
    }

    .menu-container {
      width: 100%;
      min-height: 100%;
      padding: 20px;
    }

    .header {
      background: white;
      border-radius: 12px;
      padding: 20px;
      margin-bottom: 20px;
      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
      text-align: center;
    }

    .logo {
      width: 80px;
      height: 80px;
      border-radius: 50%;
      margin: 0 auto 15px;
      object-fit: cover;
      background: #e9ecef;
    }

    .restaurant-name {
      font-size: 28px;
      font-weight: bold;
      color: #212529;
      margin-bottom: 5px;
    }

    .cuisine {
      color: #6c757d;
      font-size: 16px;
    }

    .loading, .error {
      text-align: center;
      padding: 40px 20px;
      color: #6c757d;
    }

    .error {
      color: #dc3545;
    }

    .menu-section {
      margin-bottom: 20px;
    }

    .category-title {
      font-size: 20px;
      font-weight: bold;
      color: #495057;
      margin-bottom: 15px;
      padding-bottom: 8px;
      border-bottom: 2px solid #dee2e6;
    }

    .menu-item {
      background: white;
      border-radius: 8px;
      padding: 15px;
      margin-bottom: 10px;
      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
      transition: transform 0.2s, box-shadow 0.2s;
      cursor: pointer;
    }

    .menu-item:hover {
      transform: translateY(-2px);
      box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
    }

    .item-header {
      display: flex;
      justify-content: space-between;
      align-items: baseline;
      margin-bottom: 8px;
    }

    .item-name {
      font-size: 18px;
      font-weight: 600;
      color: #212529;
    }

    .item-price {
      font-size: 16px;
      font-weight: bold;
      color: #28a745;
    }

    .item-description {
      color: #6c757d;
      font-size: 14px;
      line-height: 1.5;
    }

    .currency-symbol {
      font-size: 14px;
    }

    .loading-spinner {
      display: inline-block;
      width: 40px;
      height: 40px;
      border: 4px solid #f3f3f3;
      border-top: 4px solid #667eea;
      border-radius: 50%;
      animation: spin 1s linear infinite;
    }

    @keyframes spin {
      0% { transform: rotate(0deg); }
      100% { transform: rotate(360deg); }
    }
  </style>
</head>
<body>
  <div class="menu-container">
    <div id="content">
      <div class="loading">
        <div class="loading-spinner"></div>
        <p>Loading menu...</p>
      </div>
    </div>
  </div>

  <script>
    // Component props
    const props = window.componentProps || {};
    const restaurantSlug = props.restaurantSlug || 'pizza-heaven';
    const showPrices = props.showPrices !== false;
    const currency = props.currency || 'USD';

    // Currency symbols
    const currencySymbols = {
      'USD': '$',
      'EUR': '€',
      'GBP': '£'
    };

    // State
    let restaurantData = null;

    // Fetch restaurant data
    async function fetchRestaurantData() {
      try {
        // Check if external data is provided (sandbox mode)
        if (window.externalData) {
          console.log('Using sandbox mock data');
          restaurantData = window.externalData;
          renderMenu();
          return;
        }

        // Request data from parent board
        window.parent.postMessage({
          type: 'request-external-data',
          component: 'restaurant-menu',
          params: {
            restaurantSlug: restaurantSlug
          }
        }, '*');

        // Timeout after 10 seconds
        setTimeout(() => {
          if (!restaurantData) {
            renderError('Request timeout. Please check your data provider configuration.');
          }
        }, 10000);

      } catch (error) {
        console.error('Error fetching data:', error);
        renderError('Failed to load menu data');
      }
    }

    // Listen for external data response
    window.addEventListener('message', (event) => {
      if (event.data.type === 'external-data-response') {
        restaurantData = event.data.data;
        renderMenu();
      }

      if (event.data.type === 'props-update') {
        // Reload on prop changes
        location.reload();
      }
    });

    // Render menu
    function renderMenu() {
      if (!restaurantData) {
        renderError('No data received');
        return;
      }

      // Group menu items by category
      const categories = {};
      restaurantData.menu.forEach(item => {
        if (!categories[item.category]) {
          categories[item.category] = [];
        }
        categories[item.category].push(item);
      });

      const html = `
        <div class="header">
          ${restaurantData.logo ? `<img src="${restaurantData.logo}" class="logo" alt="Logo">` : ''}
          <div class="restaurant-name">${restaurantData.name}</div>
          <div class="cuisine">${restaurantData.cuisine}</div>
        </div>

        ${Object.keys(categories).map(category => `
          <div class="menu-section">
            <div class="category-title">${category}</div>
            ${categories[category].map(item => renderMenuItem(item)).join('')}
          </div>
        `).join('')}
      `;

      document.getElementById('content').innerHTML = html;

      // Emit loaded event
      window.parent.postMessage({
        type: 'component-event',
        component: 'restaurant-menu',
        event: 'menu-loaded',
        data: {
          restaurant: restaurantData.name,
          itemCount: restaurantData.menu.length
        }
      }, '*');
    }

    // Render single menu item
    function renderMenuItem(item) {
      return `
        <div class="menu-item" onclick="selectItem(${item.id})">
          <div class="item-header">
            <div class="item-name">${item.name}</div>
            ${showPrices ? `
              <div class="item-price">
                <span class="currency-symbol">${currencySymbols[currency]}</span>${item.price.toFixed(2)}
              </div>
            ` : ''}
          </div>
          <div class="item-description">${item.description}</div>
        </div>
      `;
    }

    // Handle item selection
    function selectItem(itemId) {
      const item = restaurantData.menu.find(i => i.id === itemId);
      if (item) {
        window.parent.postMessage({
          type: 'component-event',
          component: 'restaurant-menu',
          event: 'item-selected',
          data: item
        }, '*');
      }
    }

    // Render error
    function renderError(message) {
      document.getElementById('content').innerHTML = `
        <div class="error">
          <p>${message}</p>
        </div>
      `;
    }

    // Initialize
    fetchRestaurantData();
  </script>
</body>
</html>

Step 3: Package and Upload

bash
zip -r restaurant-menu-1.0.0.zip manifest.json index.html

curl -X POST http://localhost:4000/api/v1/components/publish \
  -H "Content-Type: multipart/form-data" \
  -F "bundle=@restaurant-menu-1.0.0.zip" \
  -F "apiKey=YOUR_API_KEY"

Step 4: Create Board with Data Provider

Option A: Sandbox Mode (Testing with Mock Data)

Perfect for testing without a real backend:

bash
TOKEN=$(curl -X POST http://localhost:4000/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"password"}' \
  | jq -r '.accessToken')

curl -X POST http://localhost:4000/api/v1/boards \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Restaurant Menu - Sandbox Test",
    "is_sandbox": true,
    "mock_external_data": {
      "name": "Pizza Heaven",
      "cuisine": "Italian",
      "menu": [
        {
          "id": 1,
          "name": "Margherita Pizza",
          "description": "Fresh mozzarella, basil, tomato sauce",
          "price": 12.99,
          "category": "Pizza"
        },
        {
          "id": 2,
          "name": "Caesar Salad",
          "description": "Romaine, parmesan, croutons",
          "price": 8.99,
          "category": "Salads"
        }
      ]
    },
    "config": {
      "components": [
        {
          "type": "restaurant-menu",
          "url": "http://localhost:4000/components/restaurant-menu@1.0.0/index.html",
          "version": "1.0.0",
          "props": {
            "restaurantSlug": "pizza-heaven",
            "showPrices": true,
            "currency": "USD"
          }
        }
      ]
    }
  }'

Option B: Production Mode (With Real Backend)

bash
curl -X POST http://localhost:4000/api/v1/boards \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Restaurant Menu - Production",
    "data_provider": {
      "type": "webhook",
      "webhook_url": "http://localhost:3000/api/restaurant/:restaurantSlug",
      "method": "GET",
      "headers": {
        "Authorization": "Bearer YOUR_API_KEY"
      }
    },
    "config": {
      "components": [
        {
          "type": "restaurant-menu",
          "url": "http://localhost:4000/components/restaurant-menu@1.0.0/index.html",
          "version": "1.0.0",
          "props": {
            "restaurantSlug": "pizza-heaven"
          }
        }
      ]
    }
  }'

Step 5: Test Your Component

  1. Sandbox mode: Component loads mock data immediately
  2. Production mode: Component requests data via webhook
  3. Real-time updates: Change restaurantSlug prop to see different restaurant

Features Explained

1. External Data Integration

Request data from parent board:

javascript
window.parent.postMessage({
  type: 'request-external-data',
  component: 'restaurant-menu',
  params: { restaurantSlug: 'pizza-heaven' }
}, '*');

Receive response:

javascript
window.addEventListener('message', (event) => {
  if (event.data.type === 'external-data-response') {
    restaurantData = event.data.data;
    renderMenu();
  }
});

2. Sandbox Mode Support

Check for mock data first:

javascript
if (window.externalData) {
  restaurantData = window.externalData;
  renderMenu();
  return;
}

3. Dynamic Rendering

Group items by category:

javascript
const categories = {};
menu.forEach(item => {
  if (!categories[item.category]) {
    categories[item.category] = [];
  }
  categories[item.category].push(item);
});

4. Error Handling

Timeout for slow backends:

javascript
setTimeout(() => {
  if (!restaurantData) {
    renderError('Request timeout');
  }
}, 10000);

Testing Checklist

  • [ ] Sandbox mode loads mock data
  • [ ] Production mode calls webhook
  • [ ] Menu items render correctly
  • [ ] Prices display in correct currency
  • [ ] Category grouping works
  • [ ] Item selection emits event
  • [ ] Error states display properly
  • [ ] Loading spinner shows during fetch

Production Deployment

1. Update webhook URL to production:

json
{
  "data_provider": {
    "webhook_url": "https://api.yourcompany.com/restaurant/:restaurantSlug"
  }
}

2. Add authentication:

json
{
  "headers": {
    "Authorization": "Bearer prod_api_key_here"
  }
}

3. Enable caching (optional):

json
{
  "data_provider": {
    "cache_ttl": 300
  }
}

Next Steps

Troubleshooting

Mock data not loading:

  • Verify is_sandbox: true in board config
  • Check mock_external_data structure matches expected format

Webhook not called:

  • Verify data_provider.webhook_url is correct
  • Check webhook endpoint is accessible from BoardAPI server
  • Review webhook logs in backend

Component shows timeout:

  • Check webhook response time (should be < 5s)
  • Verify webhook returns valid JSON
  • Check CORS headers if cross-origin

Full Source Code

Download complete source with backend example: restaurant-menu-component.zip


🎉 Congratulations! You've mastered external data integration with sandbox testing and production webhooks.