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
graph LR
A[Component] -->|Request| B[BoardAPI]
B -->|Webhook| C[Your Backend]
C -->|Response| B
B -->|Data| AFlow:
- Component requests data from BoardAPI
- BoardAPI calls your webhook endpoint
- Your backend returns restaurant + menu data
- Component renders the data
Step 1: Create Your Backend Endpoint
First, create an endpoint that returns restaurant data:
Express.js Example
// 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:
curl http://localhost:3000/api/restaurant/pizza-heavenStep 2: Create Component Files
mkdir ~/restaurant-menu-component
cd ~/restaurant-menu-componentmanifest.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
<!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
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:
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)
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
- Sandbox mode: Component loads mock data immediately
- Production mode: Component requests data via webhook
- Real-time updates: Change
restaurantSlugprop to see different restaurant
Features Explained
1. External Data Integration
Request data from parent board:
window.parent.postMessage({
type: 'request-external-data',
component: 'restaurant-menu',
params: { restaurantSlug: 'pizza-heaven' }
}, '*');Receive response:
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:
if (window.externalData) {
restaurantData = window.externalData;
renderMenu();
return;
}3. Dynamic Rendering
Group items by category:
const categories = {};
menu.forEach(item => {
if (!categories[item.category]) {
categories[item.category] = [];
}
categories[item.category].push(item);
});4. Error Handling
Timeout for slow backends:
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:
{
"data_provider": {
"webhook_url": "https://api.yourcompany.com/restaurant/:restaurantSlug"
}
}2. Add authentication:
{
"headers": {
"Authorization": "Bearer prod_api_key_here"
}
}3. Enable caching (optional):
{
"data_provider": {
"cache_ttl": 300
}
}Next Steps
- Tutorial 4: Production Integration - Deploy with webhooks and monitoring
- Component Structure - Deep dive into manifest and schemas
- Publishing Guide - Versioning and updates
Troubleshooting
Mock data not loading:
- Verify
is_sandbox: truein board config - Check
mock_external_datastructure matches expected format
Webhook not called:
- Verify
data_provider.webhook_urlis 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.