payfrit-works/portal/menu-builder.html
John Mizerek 51a80b537d Add local dev support and fix menu builder API
Portal local development:
- Add BASE_PATH detection to all portal files (login, portal.js, menu-builder, station-assignment)
- Allows portal to work at /biz.payfrit.com/ path locally

Menu Builder fixes:
- Fix duplicate template options in getForBuilder.cfm query
- Filter template children by business ID with DISTINCT

New APIs:
- api/portal/myBusinesses.cfm - List businesses for logged-in user
- api/stations/list.cfm - List KDS stations
- api/menu/updateStations.cfm - Update item station assignments
- api/setup/reimportBigDeans.cfm - Full Big Dean's menu import script

Admin utilities:
- Various debug and migration scripts for menu/template management
- Beacon switching, category cleanup, modifier template setup

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 22:47:12 -08:00

2627 lines
84 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Menu Builder - Payfrit</title>
<link rel="stylesheet" href="portal.css">
<style>
/* Menu Builder Specific Styles */
.builder-container {
display: flex;
height: calc(100vh - 60px);
gap: 16px;
padding: 16px;
}
/* Sidebar Panel */
.builder-sidebar {
width: 280px;
background: var(--bg-card);
border-radius: 12px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
}
.builder-sidebar h3 {
margin: 0;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
/* Palette Items */
.palette-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.palette-item {
background: var(--bg-secondary);
border: 2px dashed var(--border-color);
border-radius: 8px;
padding: 12px;
cursor: grab;
display: flex;
align-items: center;
gap: 10px;
transition: all 0.2s;
}
.palette-item:hover {
border-color: var(--primary);
background: rgba(0, 255, 136, 0.05);
}
.palette-item:active {
cursor: grabbing;
}
.palette-item .icon {
width: 32px;
height: 32px;
background: var(--bg-card);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.palette-item .label {
font-weight: 500;
}
.palette-item .hint {
font-size: 12px;
color: var(--text-muted);
}
/* Main Canvas */
.builder-canvas {
flex: 1;
background: var(--bg-card);
border-radius: 12px;
padding: 16px;
overflow-y: auto;
}
.canvas-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-color);
}
.canvas-header h2 {
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.canvas-actions {
display: flex;
gap: 8px;
}
/* Menu Structure */
.menu-structure {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 400px;
}
.drop-zone {
border: 2px dashed var(--border-color);
border-radius: 8px;
padding: 24px;
text-align: center;
color: var(--text-muted);
transition: all 0.2s;
}
.drop-zone.drag-over {
border-color: var(--primary);
background: rgba(0, 255, 136, 0.05);
}
.drop-zone.drag-over-error {
border-color: var(--danger);
background: rgba(255, 68, 68, 0.05);
}
/* Category Card */
.category-card {
background: var(--bg-secondary);
border-radius: 12px;
border: 2px solid var(--border-color);
overflow: hidden;
transition: all 0.2s;
}
.category-card.dragging {
opacity: 0.5;
transform: scale(0.98);
}
.category-card.drag-over {
border-color: var(--primary);
}
.category-header {
display: flex;
align-items: center;
padding: 12px 16px;
background: var(--bg-card);
cursor: grab;
gap: 12px;
}
.category-header:active {
cursor: grabbing;
}
.drag-handle {
color: var(--text-muted);
display: flex;
align-items: center;
}
.category-info {
flex: 1;
}
.category-name {
font-weight: 600;
font-size: 16px;
}
.category-count {
font-size: 12px;
color: var(--text-muted);
}
.category-actions {
display: flex;
gap: 4px;
}
.category-actions button {
background: transparent;
border: none;
color: var(--text-muted);
padding: 6px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.category-actions button:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}
.category-actions button.danger:hover {
color: var(--danger);
}
/* Items List */
.items-list {
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 60px;
}
.items-list.drag-over {
background: rgba(0, 255, 136, 0.02);
}
.item-drop-zone {
border: 2px dashed var(--border-color);
border-radius: 6px;
padding: 16px;
text-align: center;
font-size: 13px;
color: var(--text-muted);
}
/* Item Card */
.item-card {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: var(--bg-card);
border-radius: 8px;
border: 1px solid var(--border-color);
cursor: grab;
transition: all 0.2s;
}
.item-card:active {
cursor: grabbing;
}
.item-card.dragging {
opacity: 0.5;
}
.item-card.drag-over {
border-color: var(--primary);
transform: translateY(2px);
}
.item-card.modifier {
margin-left: 24px;
background: var(--bg-secondary);
border-style: dashed;
}
.item-card.modifier .item-type-badge {
background: var(--warning);
color: #000;
}
.item-image {
width: 48px;
height: 48px;
background: var(--bg-secondary);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
overflow: hidden;
position: relative;
}
.item-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.item-image .photo-badge {
position: absolute;
bottom: 2px;
right: 2px;
width: 16px;
height: 16px;
background: var(--primary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.item-image .photo-badge.missing {
background: var(--warning);
}
.item-info {
flex: 1;
min-width: 0;
}
.item-name {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-meta {
display: flex;
gap: 8px;
font-size: 12px;
color: var(--text-muted);
}
.item-price {
color: var(--primary);
font-weight: 600;
}
.item-type-badge {
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
background: var(--bg-secondary);
text-transform: uppercase;
}
.item-actions {
display: flex;
gap: 2px;
}
.item-actions button {
background: transparent;
border: none;
color: var(--text-muted);
padding: 4px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.item-actions button:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}
.item-actions button.danger:hover {
color: var(--danger);
}
/* Properties Panel */
.builder-properties {
width: 320px;
background: var(--bg-card);
border-radius: 12px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
}
.properties-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.properties-header h3 {
margin: 0;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
.property-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.property-group label {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.property-group input,
.property-group textarea,
.property-group select {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 10px 12px;
color: var(--text-primary);
font-size: 14px;
}
.property-group input:focus,
.property-group textarea:focus,
.property-group select:focus {
outline: none;
border-color: var(--primary);
}
.property-row {
display: flex;
gap: 12px;
}
.property-row .property-group {
flex: 1;
}
/* Photo Task Section */
.photo-task-section {
background: var(--bg-secondary);
border-radius: 8px;
padding: 12px;
}
.photo-task-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.photo-task-header h4 {
margin: 0;
font-size: 13px;
font-weight: 600;
}
.photo-preview {
width: 100%;
aspect-ratio: 4/3;
background: var(--bg-card);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
margin-bottom: 12px;
overflow: hidden;
}
.photo-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.photo-actions {
display: flex;
gap: 8px;
}
.photo-actions button {
flex: 1;
}
/* Task Badge */
.task-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
padding: 3px 8px;
border-radius: 12px;
background: var(--warning);
color: #000;
}
.task-badge.completed {
background: var(--success);
}
/* Empty State */
.empty-canvas {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: var(--text-muted);
text-align: center;
}
.empty-canvas svg {
width: 64px;
height: 64px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-canvas h3 {
margin: 0 0 8px 0;
color: var(--text-primary);
}
.empty-canvas p {
margin: 0;
max-width: 300px;
}
/* Toolbar */
.builder-toolbar {
display: flex;
gap: 8px;
padding: 12px 16px;
background: var(--bg-card);
border-bottom: 1px solid var(--border-color);
}
.toolbar-group {
display: flex;
gap: 4px;
padding-right: 12px;
border-right: 1px solid var(--border-color);
}
.toolbar-group:last-child {
border-right: none;
}
.toolbar-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.toolbar-btn:hover {
background: var(--bg-card);
border-color: var(--primary);
}
.toolbar-btn.primary {
background: var(--primary);
border-color: var(--primary);
color: #000;
}
.toolbar-btn.primary:hover {
background: var(--primary-hover);
}
.toolbar-btn svg {
width: 16px;
height: 16px;
}
/* Import/Export Modal */
.import-export-area {
width: 100%;
min-height: 300px;
font-family: monospace;
font-size: 12px;
resize: vertical;
}
/* Selection highlight */
.category-card.selected,
.item-card.selected {
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(0, 255, 136, 0.2);
}
/* Context Menu */
.context-menu {
position: fixed;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 8px 0;
min-width: 180px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
z-index: 1000;
display: none;
}
.context-menu.visible {
display: block;
}
.context-menu-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 16px;
cursor: pointer;
transition: background 0.15s;
}
.context-menu-item:hover {
background: var(--bg-secondary);
}
.context-menu-item.danger {
color: var(--danger);
}
.context-menu-item svg {
width: 16px;
height: 16px;
}
.context-menu-divider {
height: 1px;
background: var(--border-color);
margin: 8px 0;
}
/* Keyboard shortcuts hint */
.shortcut-hint {
font-size: 11px;
color: var(--text-muted);
margin-left: auto;
font-family: monospace;
}
</style>
</head>
<body>
<div class="app" style="flex-direction: column;">
<!-- Toolbar -->
<div class="builder-toolbar">
<div class="toolbar-group">
<a href="/portal/#menu" class="toolbar-btn" title="Back to Portal" style="text-decoration: none;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Back
</a>
</div>
<div class="toolbar-group">
<button class="toolbar-btn" onclick="MenuBuilder.undo()" title="Undo (Ctrl+Z)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 7v6h6M3 13a9 9 0 103-7.5"/>
</svg>
Undo
</button>
<button class="toolbar-btn" onclick="MenuBuilder.redo()" title="Redo (Ctrl+Y)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 7v6h-6M21 13a9 9 0 10-3-7.5"/>
</svg>
Redo
</button>
</div>
<div class="toolbar-group">
<button class="toolbar-btn" onclick="MenuBuilder.addCategory()" title="Add Category">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M5 12h14"/>
</svg>
Category
</button>
<button class="toolbar-btn" onclick="MenuBuilder.addItem()" title="Add Item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M5 12h14"/>
</svg>
Item
</button>
</div>
<div class="toolbar-group">
<button class="toolbar-btn" onclick="MenuBuilder.cloneSelected()" title="Clone (Ctrl+D)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2"/>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
</svg>
Clone
</button>
<button class="toolbar-btn" onclick="MenuBuilder.deleteSelected()" title="Delete (Del)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
</svg>
Delete
</button>
</div>
<div class="toolbar-group">
<button class="toolbar-btn" onclick="MenuBuilder.showImportModal()" title="Import JSON">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/>
</svg>
Import
</button>
<button class="toolbar-btn" onclick="MenuBuilder.showExportModal()" title="Export JSON">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
</svg>
Export
</button>
</div>
<div class="toolbar-group" style="margin-left: auto;">
<button class="toolbar-btn primary" onclick="MenuBuilder.saveMenu()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/>
<path d="M17 21v-8H7v8M7 3v5h8"/>
</svg>
Save Menu
</button>
</div>
</div>
<!-- Main Builder -->
<div class="builder-container">
<!-- Left Sidebar - Palette -->
<div class="builder-sidebar">
<h3>Components</h3>
<div class="palette-section">
<div class="palette-item" draggable="true" data-type="category">
<div class="icon">📁</div>
<div>
<div class="label">Category</div>
<div class="hint">Group of menu items</div>
</div>
</div>
<div class="palette-item" draggable="true" data-type="item">
<div class="icon">🍽️</div>
<div>
<div class="label">Menu Item</div>
<div class="hint">Orderable product</div>
</div>
</div>
<div class="palette-item" draggable="true" data-type="modifier">
<div class="icon">⚙️</div>
<div>
<div class="label">Modifier</div>
<div class="hint">Add-on or option</div>
</div>
</div>
<div class="palette-item" draggable="true" data-type="modifier-group">
<div class="icon">📋</div>
<div>
<div class="label">Modifier Group</div>
<div class="hint">Group of options</div>
</div>
</div>
</div>
<h3>Modifier Templates</h3>
<div class="palette-section" id="templateLibrary">
<div style="color: var(--text-muted); font-size: 13px; padding: 8px;">Loading templates...</div>
</div>
<h3>Quick Stats</h3>
<div style="font-size: 13px; color: var(--text-muted);">
<div style="display: flex; justify-content: space-between; padding: 6px 0;">
<span>Categories:</span>
<strong id="statCategories">0</strong>
</div>
<div style="display: flex; justify-content: space-between; padding: 6px 0;">
<span>Items:</span>
<strong id="statItems">0</strong>
</div>
<div style="display: flex; justify-content: space-between; padding: 6px 0;">
<span>Templates:</span>
<strong id="statTemplates">0</strong>
</div>
<div style="display: flex; justify-content: space-between; padding: 6px 0;">
<span>Photos Missing:</span>
<strong id="statPhotosMissing" style="color: var(--warning);">0</strong>
</div>
</div>
</div>
<!-- Main Canvas -->
<div class="builder-canvas">
<div class="canvas-header">
<h2>
<span id="menuName">Menu Builder</span>
<span style="font-size: 13px; color: var(--text-muted); font-weight: normal;" id="businessLabel"></span>
</h2>
</div>
<div class="menu-structure" id="menuStructure">
<div class="empty-canvas" id="emptyCanvas">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M3 6h18M3 12h18M3 18h18"/>
</svg>
<h3>Start Building Your Menu</h3>
<p>Drag categories and items from the left panel, or click the buttons above to add them.</p>
</div>
</div>
</div>
<!-- Right Sidebar - Properties -->
<div class="builder-properties" id="propertiesPanel">
<div class="properties-header">
<h3>Properties</h3>
</div>
<div id="propertiesContent">
<p style="color: var(--text-muted); text-align: center; padding: 20px;">
Select an item to edit its properties
</p>
</div>
</div>
</div>
</div>
<!-- Context Menu -->
<div class="context-menu" id="contextMenu">
<div class="context-menu-item" onclick="MenuBuilder.editSelected()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
Edit
<span class="shortcut-hint">Enter</span>
</div>
<div class="context-menu-item" onclick="MenuBuilder.cloneSelected()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2"/>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
</svg>
Clone
<span class="shortcut-hint">Ctrl+D</span>
</div>
<div class="context-menu-item" onclick="MenuBuilder.createPhotoTask()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<path d="M21 15l-5-5L5 21"/>
</svg>
Create Photo Task
</div>
<div class="context-menu-divider"></div>
<div class="context-menu-item" onclick="MenuBuilder.moveUp()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 19V5M5 12l7-7 7 7"/>
</svg>
Move Up
<span class="shortcut-hint">Alt+Up</span>
</div>
<div class="context-menu-item" onclick="MenuBuilder.moveDown()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M19 12l-7 7-7-7"/>
</svg>
Move Down
<span class="shortcut-hint">Alt+Down</span>
</div>
<div class="context-menu-divider"></div>
<div class="context-menu-item danger" onclick="MenuBuilder.deleteSelected()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
</svg>
Delete
<span class="shortcut-hint">Del</span>
</div>
</div>
<!-- Modal -->
<div class="modal-overlay" id="modalOverlay">
<div class="modal" id="modal" style="max-width: 600px;">
<div class="modal-header">
<h3 id="modalTitle">Modal</h3>
<button class="modal-close" onclick="MenuBuilder.closeModal()">&times;</button>
</div>
<div class="modal-body" id="modalBody"></div>
</div>
</div>
<!-- Toast Container -->
<div class="toast-container" id="toastContainer"></div>
<script>
// Detect base path for API calls (handles local dev at /biz.payfrit.com/)
const BASE_PATH = (() => {
const path = window.location.pathname;
const portalIndex = path.indexOf('/portal/');
if (portalIndex > 0) {
return path.substring(0, portalIndex);
}
return '';
})();
/**
* Payfrit Menu Builder
* Drag-and-drop menu creation with photo tasks
*/
const MenuBuilder = {
// Config
config: {
apiBaseUrl: BASE_PATH + '/api',
businessId: null,
},
// State
menu: {
categories: []
},
templates: [],
stations: [],
selectedElement: null,
selectedData: null,
undoStack: [],
redoStack: [],
idCounter: 1,
// Initialize
async init() {
console.log('[MenuBuilder] Initializing...');
// Check authentication
const token = localStorage.getItem('payfrit_portal_token');
const savedBusiness = localStorage.getItem('payfrit_portal_business');
if (!token || !savedBusiness) {
window.location.href = BASE_PATH + '/portal/login.html';
return;
}
// Get business ID from URL or saved
const urlParams = new URLSearchParams(window.location.search);
this.config.businessId = parseInt(urlParams.get('bid')) || parseInt(savedBusiness);
this.config.token = token;
// Load business info
await this.loadBusinessInfo();
// Setup drag and drop
this.setupDragAndDrop();
// Setup keyboard shortcuts
this.setupKeyboardShortcuts();
// Setup context menu
this.setupContextMenu();
// Load existing menu if any
await this.loadMenu();
console.log('[MenuBuilder] Ready');
},
// Load business info
async loadBusinessInfo() {
try {
const response = await fetch(`${this.config.apiBaseUrl}/businesses/get.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ BusinessID: this.config.businessId })
});
const data = await response.json();
if (data.OK && data.BUSINESS) {
document.getElementById('businessLabel').textContent = `- ${data.BUSINESS.BusinessName}`;
}
} catch (err) {
console.error('[MenuBuilder] Error loading business:', err);
}
},
// Setup drag and drop
setupDragAndDrop() {
// Palette items
document.querySelectorAll('.palette-item').forEach(item => {
item.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('type', item.dataset.type);
e.dataTransfer.setData('source', 'palette');
});
});
// Canvas drop zone
const structure = document.getElementById('menuStructure');
structure.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
});
structure.addEventListener('drop', (e) => {
e.preventDefault();
const type = e.dataTransfer.getData('type');
const source = e.dataTransfer.getData('source');
if (source === 'palette') {
this.handlePaletteDrop(type, e.target);
}
});
},
// Handle palette drop
handlePaletteDrop(type, target) {
switch (type) {
case 'category':
this.addCategory();
break;
case 'item':
this.addItem(this.findCategoryFromTarget(target));
break;
case 'modifier':
this.addModifier(this.findItemFromTarget(target));
break;
case 'modifier-group':
this.addModifierGroup(this.findItemFromTarget(target));
break;
case 'template-size':
this.addSizeTemplate(this.findItemFromTarget(target));
break;
case 'template-protein':
this.addProteinTemplate(this.findItemFromTarget(target));
break;
case 'template-temp':
this.addTempTemplate(this.findItemFromTarget(target));
break;
}
},
// Find category from drop target
findCategoryFromTarget(target) {
const card = target.closest('.category-card');
if (card) {
return parseInt(card.dataset.categoryId);
}
return this.menu.categories.length > 0 ? this.menu.categories[0].id : null;
},
// Find item from drop target
findItemFromTarget(target) {
const card = target.closest('.item-card');
if (card) {
return parseInt(card.dataset.itemId);
}
return null;
},
// Setup keyboard shortcuts
setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
// Delete
if (e.key === 'Delete' && this.selectedElement) {
e.preventDefault();
this.deleteSelected();
}
// Clone (Ctrl+D)
if (e.key === 'd' && e.ctrlKey && this.selectedElement) {
e.preventDefault();
this.cloneSelected();
}
// Undo (Ctrl+Z)
if (e.key === 'z' && e.ctrlKey && !e.shiftKey) {
e.preventDefault();
this.undo();
}
// Redo (Ctrl+Y or Ctrl+Shift+Z)
if ((e.key === 'y' && e.ctrlKey) || (e.key === 'z' && e.ctrlKey && e.shiftKey)) {
e.preventDefault();
this.redo();
}
// Move up (Alt+Up)
if (e.key === 'ArrowUp' && e.altKey && this.selectedElement) {
e.preventDefault();
this.moveUp();
}
// Move down (Alt+Down)
if (e.key === 'ArrowDown' && e.altKey && this.selectedElement) {
e.preventDefault();
this.moveDown();
}
// Edit (Enter)
if (e.key === 'Enter' && this.selectedElement) {
e.preventDefault();
this.editSelected();
}
// Escape - deselect
if (e.key === 'Escape') {
this.clearSelection();
this.hideContextMenu();
}
});
},
// Setup context menu
setupContextMenu() {
document.addEventListener('contextmenu', (e) => {
const element = e.target.closest('.category-card, .item-card');
if (element) {
e.preventDefault();
this.selectElement(element);
this.showContextMenu(e.clientX, e.clientY);
}
});
document.addEventListener('click', () => {
this.hideContextMenu();
});
},
// Show context menu
showContextMenu(x, y) {
const menu = document.getElementById('contextMenu');
menu.style.left = x + 'px';
menu.style.top = y + 'px';
menu.classList.add('visible');
},
// Hide context menu
hideContextMenu() {
document.getElementById('contextMenu').classList.remove('visible');
},
// Generate unique ID
generateId() {
return `temp_${Date.now()}_${this.idCounter++}`;
},
// Save state for undo
saveState() {
this.undoStack.push(JSON.stringify(this.menu));
this.redoStack = [];
if (this.undoStack.length > 50) {
this.undoStack.shift();
}
},
// Undo
undo() {
if (this.undoStack.length === 0) return;
this.redoStack.push(JSON.stringify(this.menu));
this.menu = JSON.parse(this.undoStack.pop());
this.render();
this.toast('Undo', 'info');
},
// Redo
redo() {
if (this.redoStack.length === 0) return;
this.undoStack.push(JSON.stringify(this.menu));
this.menu = JSON.parse(this.redoStack.pop());
this.render();
this.toast('Redo', 'info');
},
// Add category
addCategory(name = null) {
this.saveState();
const category = {
id: this.generateId(),
name: name || `New Category ${this.menu.categories.length + 1}`,
description: '',
sortOrder: this.menu.categories.length,
items: []
};
this.menu.categories.push(category);
this.render();
this.selectElementById('category', category.id);
this.showPropertiesForCategory(category);
},
// Add item
addItem(categoryId = null) {
this.saveState();
// Find category
let category = null;
if (categoryId) {
category = this.menu.categories.find(c => c.id === categoryId);
} else if (this.menu.categories.length > 0) {
category = this.menu.categories[0];
} else {
this.toast('Create a category first', 'error');
return;
}
const item = {
id: this.generateId(),
name: 'New Item',
description: '',
price: 0,
imageUrl: null,
photoTaskId: null,
modifiers: [],
sortOrder: category.items.length
};
category.items.push(item);
this.render();
this.selectElementById('item', item.id);
this.showPropertiesForItem(item, category);
},
// Add modifier
addModifier(itemId) {
if (!itemId) {
this.toast('Select an item first', 'error');
return;
}
this.saveState();
// Find item
let item = null;
let parentCategory = null;
for (const cat of this.menu.categories) {
const found = cat.items.find(i => i.id === itemId);
if (found) {
item = found;
parentCategory = cat;
break;
}
}
if (!item) return;
const modifier = {
id: this.generateId(),
name: 'New Modifier',
price: 0,
isDefault: false,
sortOrder: item.modifiers.length
};
item.modifiers.push(modifier);
this.render();
},
// Add modifier group
addModifierGroup(itemId) {
if (!itemId) {
this.toast('Select an item first', 'error');
return;
}
// For now, just add a modifier with a group indicator
this.addModifier(itemId);
},
// Add size template
addSizeTemplate(itemId) {
if (!itemId) {
this.toast('Select an item first', 'error');
return;
}
this.saveState();
let item = null;
for (const cat of this.menu.categories) {
const found = cat.items.find(i => i.id === itemId);
if (found) {
item = found;
break;
}
}
if (!item) return;
const sizes = [
{ name: 'Small', price: 0, isDefault: true },
{ name: 'Medium', price: 1.00, isDefault: false },
{ name: 'Large', price: 2.00, isDefault: false }
];
sizes.forEach((s, i) => {
item.modifiers.push({
id: this.generateId(),
name: s.name,
price: s.price,
isDefault: s.isDefault,
group: 'Size',
sortOrder: item.modifiers.length + i
});
});
this.render();
this.toast('Size options added', 'success');
},
// Add protein template
addProteinTemplate(itemId) {
if (!itemId) {
this.toast('Select an item first', 'error');
return;
}
this.saveState();
let item = null;
for (const cat of this.menu.categories) {
const found = cat.items.find(i => i.id === itemId);
if (found) {
item = found;
break;
}
}
if (!item) return;
const proteins = [
{ name: 'Chicken', price: 0, isDefault: true },
{ name: 'Beef', price: 2.00, isDefault: false },
{ name: 'Lamb', price: 3.00, isDefault: false },
{ name: 'Falafel (V)', price: 0, isDefault: false }
];
proteins.forEach((p, i) => {
item.modifiers.push({
id: this.generateId(),
name: p.name,
price: p.price,
isDefault: p.isDefault,
group: 'Protein',
sortOrder: item.modifiers.length + i
});
});
this.render();
this.toast('Protein options added', 'success');
},
// Add temperature template
addTempTemplate(itemId) {
if (!itemId) {
this.toast('Select an item first', 'error');
return;
}
this.saveState();
let item = null;
for (const cat of this.menu.categories) {
const found = cat.items.find(i => i.id === itemId);
if (found) {
item = found;
break;
}
}
if (!item) return;
const temps = [
{ name: 'Hot', price: 0, isDefault: true },
{ name: 'Iced', price: 0.50, isDefault: false },
{ name: 'Blended', price: 1.00, isDefault: false }
];
temps.forEach((t, i) => {
item.modifiers.push({
id: this.generateId(),
name: t.name,
price: t.price,
isDefault: t.isDefault,
group: 'Temperature',
sortOrder: item.modifiers.length + i
});
});
this.render();
this.toast('Temperature options added', 'success');
},
// Select element
selectElement(element) {
this.clearSelection();
this.selectedElement = element;
element.classList.add('selected');
// Get data
if (element.classList.contains('category-card')) {
const catId = element.dataset.categoryId;
const category = this.menu.categories.find(c => c.id === catId);
this.selectedData = { type: 'category', data: category };
this.showPropertiesForCategory(category);
} else if (element.classList.contains('item-card')) {
const itemId = element.dataset.itemId;
for (const cat of this.menu.categories) {
const item = cat.items.find(i => i.id === itemId);
if (item) {
this.selectedData = { type: 'item', data: item, category: cat };
this.showPropertiesForItem(item, cat);
break;
}
}
}
},
// Select element by ID
selectElementById(type, id) {
const selector = type === 'category'
? `.category-card[data-category-id="${id}"]`
: `.item-card[data-item-id="${id}"]`;
const element = document.querySelector(selector);
if (element) {
this.selectElement(element);
}
},
// Clear selection
clearSelection() {
if (this.selectedElement) {
this.selectedElement.classList.remove('selected');
}
this.selectedElement = null;
this.selectedData = null;
document.getElementById('propertiesContent').innerHTML = `
<p style="color: var(--text-muted); text-align: center; padding: 20px;">
Select an item to edit its properties
</p>
`;
},
// Show properties for category
showPropertiesForCategory(category) {
document.getElementById('propertiesContent').innerHTML = `
<div class="property-group">
<label>Category Name</label>
<input type="text" id="propCatName" value="${this.escapeHtml(category.name)}"
onchange="MenuBuilder.updateCategory('${category.id}', 'name', this.value)">
</div>
<div class="property-group">
<label>Description</label>
<textarea id="propCatDesc" rows="3"
onchange="MenuBuilder.updateCategory('${category.id}', 'description', this.value)">${this.escapeHtml(category.description || '')}</textarea>
</div>
<div class="property-group">
<label>Sort Order</label>
<input type="number" id="propCatSort" value="${category.sortOrder}"
onchange="MenuBuilder.updateCategory('${category.id}', 'sortOrder', parseInt(this.value))">
</div>
<div class="property-group" style="margin-top: 16px;">
<button class="btn btn-danger" onclick="MenuBuilder.deleteCategory('${category.id}')">
Delete Category
</button>
</div>
`;
},
// Show properties for item
showPropertiesForItem(item, category) {
document.getElementById('propertiesContent').innerHTML = `
<div class="property-group">
<label>Item Name</label>
<input type="text" id="propItemName" value="${this.escapeHtml(item.name)}"
onchange="MenuBuilder.updateItem('${category.id}', '${item.id}', 'name', this.value)">
</div>
<div class="property-group">
<label>Description</label>
<textarea id="propItemDesc" rows="3"
onchange="MenuBuilder.updateItem('${category.id}', '${item.id}', 'description', this.value)">${this.escapeHtml(item.description || '')}</textarea>
</div>
<div class="property-row">
<div class="property-group">
<label>Price ($)</label>
<input type="number" step="0.01" id="propItemPrice" value="${item.price || 0}"
onchange="MenuBuilder.updateItem('${category.id}', '${item.id}', 'price', parseFloat(this.value))">
</div>
<div class="property-group">
<label>Category</label>
<select onchange="MenuBuilder.moveItemToCategory('${item.id}', this.value)">
${this.menu.categories.map(c =>
`<option value="${c.id}" ${c.id === category.id ? 'selected' : ''}>${this.escapeHtml(c.name)}</option>`
).join('')}
</select>
</div>
</div>
<div class="photo-task-section">
<div class="photo-task-header">
<h4>Item Photo</h4>
${item.photoTaskId ?
`<span class="task-badge ${item.imageUrl ? 'completed' : ''}">
${item.imageUrl ? 'Photo Added' : 'Task Pending'}
</span>` : ''}
</div>
<div class="photo-preview">
${item.imageUrl ?
`<img src="${item.imageUrl}" alt="${this.escapeHtml(item.name)}">` :
`<span>No photo yet</span>`}
</div>
<div class="photo-actions">
<button class="btn btn-secondary" onclick="MenuBuilder.uploadPhoto('${item.id}')">
Upload
</button>
<button class="btn btn-secondary" onclick="MenuBuilder.createPhotoTask('${item.id}')">
Create Task
</button>
</div>
</div>
<div class="property-group" style="margin-top: 16px;">
<label>Modifiers (${item.modifiers.length})</label>
<button class="btn btn-secondary" onclick="MenuBuilder.addModifier('${item.id}')" style="margin-top: 8px;">
+ Add Modifier
</button>
</div>
<div class="property-group" style="margin-top: 16px;">
<button class="btn btn-danger" onclick="MenuBuilder.deleteItem('${category.id}', '${item.id}')">
Delete Item
</button>
</div>
`;
},
// Select modifier and show its properties
selectModifier(itemId, modifierId) {
this.clearSelection();
// Find the modifier element and highlight it
const element = document.querySelector(`.item-card.modifier[data-modifier-id="${modifierId}"]`);
if (element) {
this.selectedElement = element;
element.classList.add('selected');
}
// Find the modifier data
for (const cat of this.menu.categories) {
const item = cat.items.find(i => i.id === itemId);
if (item) {
const modifier = item.modifiers.find(m => m.id === modifierId);
if (modifier) {
this.selectedData = { type: 'modifier', data: modifier, item: item, category: cat };
this.showPropertiesForModifier(modifier, item, cat);
break;
}
}
}
},
// Show properties for modifier
showPropertiesForModifier(modifier, item, category) {
document.getElementById('propertiesContent').innerHTML = `
<div class="property-group">
<label>Modifier Name</label>
<input type="text" id="propModName" value="${this.escapeHtml(modifier.name)}"
onchange="MenuBuilder.updateModifier('${item.id}', '${modifier.id}', 'name', this.value)">
</div>
<div class="property-row">
<div class="property-group">
<label>Extra Price ($)</label>
<input type="number" step="0.01" id="propModPrice" value="${modifier.price || 0}"
onchange="MenuBuilder.updateModifier('${item.id}', '${modifier.id}', 'price', parseFloat(this.value))">
</div>
<div class="property-group">
<label>Default</label>
<select id="propModDefault"
onchange="MenuBuilder.updateModifier('${item.id}', '${modifier.id}', 'isDefault', this.value === 'true')">
<option value="false" ${!modifier.isDefault ? 'selected' : ''}>No</option>
<option value="true" ${modifier.isDefault ? 'selected' : ''}>Yes</option>
</select>
</div>
</div>
<div class="property-group">
<label>Group Name (optional)</label>
<input type="text" id="propModGroup" value="${this.escapeHtml(modifier.group || '')}"
placeholder="e.g., Size, Protein, Temperature"
onchange="MenuBuilder.updateModifier('${item.id}', '${modifier.id}', 'group', this.value)">
<small style="color: var(--text-muted); font-size: 11px;">
Modifiers with the same group name are shown together
</small>
</div>
<div class="property-group">
<label>Sort Order</label>
<input type="number" id="propModSort" value="${modifier.sortOrder || 0}"
onchange="MenuBuilder.updateModifier('${item.id}', '${modifier.id}', 'sortOrder', parseInt(this.value))">
</div>
<div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border-color);">
<p style="color: var(--text-muted); font-size: 12px; margin: 0 0 8px;">
Parent Item: <strong>${this.escapeHtml(item.name)}</strong>
</p>
</div>
<div class="property-group" style="margin-top: 16px;">
<button class="btn btn-danger" onclick="MenuBuilder.deleteModifier('${item.id}', '${modifier.id}')">
Delete Modifier
</button>
</div>
`;
},
// Update modifier
updateModifier(itemId, modifierId, field, value) {
this.saveState();
for (const cat of this.menu.categories) {
const item = cat.items.find(i => i.id === itemId);
if (item) {
const modifier = item.modifiers.find(m => m.id === modifierId);
if (modifier) {
modifier[field] = value;
this.render();
// Re-select the modifier to keep properties panel open
this.selectModifier(itemId, modifierId);
break;
}
}
}
},
// Delete modifier
deleteModifier(itemId, modifierId) {
if (!confirm('Delete this modifier?')) return;
this.saveState();
for (const cat of this.menu.categories) {
const item = cat.items.find(i => i.id === itemId);
if (item) {
item.modifiers = item.modifiers.filter(m => m.id !== modifierId);
break;
}
}
this.clearSelection();
this.render();
this.toast('Modifier deleted', 'success');
},
// Helper: Find an option recursively in the menu tree
findOptionRecursive(options, optionId) {
for (const opt of options) {
if (opt.id === optionId) return opt;
if (opt.options && opt.options.length > 0) {
const found = this.findOptionRecursive(opt.options, optionId);
if (found) return found;
}
}
return null;
},
// Helper: Find parent info for an option
findOptionWithParent(parentId, optionId) {
// First check if parent is a top-level item
for (const cat of this.menu.categories) {
for (const item of cat.items) {
if (item.id === parentId) {
const opt = item.modifiers.find(m => m.id === optionId);
if (opt) return { option: opt, parent: item, parentType: 'item', category: cat };
}
// Check nested modifiers
const result = this.findInModifiers(item.modifiers, parentId, optionId);
if (result) return { ...result, rootItem: item, category: cat };
}
}
return null;
},
findInModifiers(modifiers, parentId, optionId) {
for (const mod of modifiers) {
if (mod.id === parentId) {
const opt = (mod.options || []).find(o => o.id === optionId);
if (opt) return { option: opt, parent: mod, parentType: 'modifier' };
}
if (mod.options && mod.options.length > 0) {
const result = this.findInModifiers(mod.options, parentId, optionId);
if (result) return result;
}
}
return null;
},
// Select option at any depth
selectOption(parentId, optionId, depth) {
this.clearSelection();
const element = document.querySelector(`.item-card.modifier[data-modifier-id="${optionId}"]`);
if (element) {
this.selectedElement = element;
element.classList.add('selected');
}
const result = this.findOptionWithParent(parentId, optionId);
if (result) {
this.selectedData = { type: 'option', data: result.option, parent: result.parent, depth: depth };
this.showPropertiesForOption(result.option, result.parent, depth);
}
},
// Show properties for option at any depth
showPropertiesForOption(option, parent, depth) {
const hasOptions = option.options && option.options.length > 0;
const levelName = depth === 1 ? 'Modifier' : (depth === 2 ? 'Option' : `Level ${depth} Option`);
document.getElementById('propertiesContent').innerHTML = `
<div class="property-group">
<label>${levelName} Name</label>
<input type="text" value="${this.escapeHtml(option.name)}"
onchange="MenuBuilder.updateOption('${parent.id}', '${option.id}', 'name', this.value)">
</div>
<div class="property-row">
<div class="property-group">
<label>Extra Price ($)</label>
<input type="number" step="0.01" value="${option.price || 0}"
onchange="MenuBuilder.updateOption('${parent.id}', '${option.id}', 'price', parseFloat(this.value))">
</div>
<div class="property-group">
<label>Default</label>
<select onchange="MenuBuilder.updateOption('${parent.id}', '${option.id}', 'isDefault', this.value === 'true')">
<option value="false" ${!option.isDefault ? 'selected' : ''}>No</option>
<option value="true" ${option.isDefault ? 'selected' : ''}>Yes</option>
</select>
</div>
</div>
<div class="property-group">
<label>Sort Order</label>
<input type="number" value="${option.sortOrder || 0}"
onchange="MenuBuilder.updateOption('${parent.id}', '${option.id}', 'sortOrder', parseInt(this.value))">
</div>
<div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border-color);">
<p style="color: var(--text-muted); font-size: 12px; margin: 0 0 8px;">
Parent: <strong>${this.escapeHtml(parent.name)}</strong>
</p>
<p style="color: var(--text-muted); font-size: 12px; margin: 0;">
Sub-options: <strong>${hasOptions ? option.options.length : 0}</strong>
</p>
</div>
<div class="property-group" style="margin-top: 16px;">
<button class="btn btn-secondary" onclick="MenuBuilder.addSubOption('${option.id}')">
+ Add Sub-Option
</button>
</div>
<div class="property-group" style="margin-top: 8px;">
<button class="btn btn-danger" onclick="MenuBuilder.deleteOption('${parent.id}', '${option.id}', ${depth})">
Delete ${levelName}
</button>
</div>
`;
},
// Update option at any depth
updateOption(parentId, optionId, field, value) {
this.saveState();
const result = this.findOptionWithParent(parentId, optionId);
if (result && result.option) {
result.option[field] = value;
this.render();
this.selectOption(parentId, optionId, this.selectedData?.depth || 1);
}
},
// Delete option at any depth
deleteOption(parentId, optionId, depth) {
if (!confirm('Delete this option and all its sub-options?')) return;
this.saveState();
// Find and remove from parent's options array
for (const cat of this.menu.categories) {
for (const item of cat.items) {
// Check if parent is the item itself
if (item.id === parentId) {
item.modifiers = item.modifiers.filter(m => m.id !== optionId);
this.clearSelection();
this.render();
this.toast('Option deleted', 'success');
return;
}
// Check nested
if (this.deleteFromModifiers(item.modifiers, parentId, optionId)) {
this.clearSelection();
this.render();
this.toast('Option deleted', 'success');
return;
}
}
}
},
deleteFromModifiers(modifiers, parentId, optionId) {
for (const mod of modifiers) {
if (mod.id === parentId && mod.options) {
const idx = mod.options.findIndex(o => o.id === optionId);
if (idx !== -1) {
mod.options.splice(idx, 1);
return true;
}
}
if (mod.options && mod.options.length > 0) {
if (this.deleteFromModifiers(mod.options, parentId, optionId)) return true;
}
}
return false;
},
// Add sub-option to any option
addSubOption(parentOptionId) {
this.saveState();
const newOption = {
id: 'opt_new_' + Date.now(),
dbId: null,
name: 'New Option',
price: 0,
isDefault: false,
sortOrder: 0,
options: []
};
// Find the parent option and add to its options array
for (const cat of this.menu.categories) {
for (const item of cat.items) {
const found = this.findOptionRecursive(item.modifiers, parentOptionId);
if (found) {
if (!found.options) found.options = [];
found.options.push(newOption);
this.render();
this.toast('Sub-option added', 'success');
return;
}
}
}
},
// Update category
updateCategory(categoryId, field, value) {
this.saveState();
const category = this.menu.categories.find(c => c.id === categoryId);
if (category) {
category[field] = value;
this.render();
}
},
// Update item
updateItem(categoryId, itemId, field, value) {
this.saveState();
const category = this.menu.categories.find(c => c.id === categoryId);
if (category) {
const item = category.items.find(i => i.id === itemId);
if (item) {
item[field] = value;
this.render();
}
}
},
// Move item to category
moveItemToCategory(itemId, newCategoryId) {
this.saveState();
// Find and remove item from current category
let item = null;
for (const cat of this.menu.categories) {
const idx = cat.items.findIndex(i => i.id === itemId);
if (idx !== -1) {
item = cat.items.splice(idx, 1)[0];
break;
}
}
// Add to new category
if (item) {
const newCat = this.menu.categories.find(c => c.id === newCategoryId);
if (newCat) {
item.sortOrder = newCat.items.length;
newCat.items.push(item);
}
}
this.render();
},
// Delete category
deleteCategory(categoryId) {
if (!confirm('Delete this category and all its items?')) return;
this.saveState();
this.menu.categories = this.menu.categories.filter(c => c.id !== categoryId);
this.clearSelection();
this.render();
this.toast('Category deleted', 'success');
},
// Delete item
deleteItem(categoryId, itemId) {
if (!confirm('Delete this item?')) return;
this.saveState();
const category = this.menu.categories.find(c => c.id === categoryId);
if (category) {
category.items = category.items.filter(i => i.id !== itemId);
}
this.clearSelection();
this.render();
this.toast('Item deleted', 'success');
},
// Clone selected
cloneSelected() {
if (!this.selectedData) return;
this.saveState();
if (this.selectedData.type === 'category') {
const original = this.selectedData.data;
const clone = JSON.parse(JSON.stringify(original));
clone.id = this.generateId();
clone.name = original.name + ' (Copy)';
clone.items.forEach(item => {
item.id = this.generateId();
item.modifiers.forEach(mod => mod.id = this.generateId());
});
this.menu.categories.push(clone);
} else if (this.selectedData.type === 'item') {
const original = this.selectedData.data;
const category = this.selectedData.category;
const clone = JSON.parse(JSON.stringify(original));
clone.id = this.generateId();
clone.name = original.name + ' (Copy)';
clone.modifiers.forEach(mod => mod.id = this.generateId());
category.items.push(clone);
}
this.render();
this.toast('Cloned', 'success');
},
// Delete selected
deleteSelected() {
if (!this.selectedData) return;
if (this.selectedData.type === 'category') {
this.deleteCategory(this.selectedData.data.id);
} else if (this.selectedData.type === 'item') {
this.deleteItem(this.selectedData.category.id, this.selectedData.data.id);
}
},
// Edit selected
editSelected() {
if (!this.selectedElement) return;
// Focus on the name input in properties panel
const nameInput = document.getElementById('propCatName') || document.getElementById('propItemName');
if (nameInput) {
nameInput.focus();
nameInput.select();
}
},
// Move up
moveUp() {
if (!this.selectedData) return;
this.saveState();
if (this.selectedData.type === 'category') {
const idx = this.menu.categories.findIndex(c => c.id === this.selectedData.data.id);
if (idx > 0) {
[this.menu.categories[idx - 1], this.menu.categories[idx]] =
[this.menu.categories[idx], this.menu.categories[idx - 1]];
}
} else if (this.selectedData.type === 'item') {
const items = this.selectedData.category.items;
const idx = items.findIndex(i => i.id === this.selectedData.data.id);
if (idx > 0) {
[items[idx - 1], items[idx]] = [items[idx], items[idx - 1]];
}
}
this.render();
this.selectElementById(this.selectedData.type, this.selectedData.data.id);
},
// Move down
moveDown() {
if (!this.selectedData) return;
this.saveState();
if (this.selectedData.type === 'category') {
const idx = this.menu.categories.findIndex(c => c.id === this.selectedData.data.id);
if (idx < this.menu.categories.length - 1) {
[this.menu.categories[idx], this.menu.categories[idx + 1]] =
[this.menu.categories[idx + 1], this.menu.categories[idx]];
}
} else if (this.selectedData.type === 'item') {
const items = this.selectedData.category.items;
const idx = items.findIndex(i => i.id === this.selectedData.data.id);
if (idx < items.length - 1) {
[items[idx], items[idx + 1]] = [items[idx + 1], items[idx]];
}
}
this.render();
this.selectElementById(this.selectedData.type, this.selectedData.data.id);
},
// Create photo task
createPhotoTask(itemId = null) {
const id = itemId || (this.selectedData?.type === 'item' ? this.selectedData.data.id : null);
if (!id) {
this.toast('Select an item first', 'error');
return;
}
// Find item
let item = null;
for (const cat of this.menu.categories) {
const found = cat.items.find(i => i.id === id);
if (found) {
item = found;
break;
}
}
if (!item) return;
document.getElementById('modalTitle').textContent = 'Create Photo Task';
document.getElementById('modalBody').innerHTML = `
<form id="photoTaskForm" class="form">
<div class="form-group">
<label>Item</label>
<input type="text" class="form-input" value="${this.escapeHtml(item.name)}" disabled>
</div>
<div class="form-group">
<label>Task Type</label>
<select id="taskType" class="form-select">
<option value="employee">Employee Task</option>
<option value="user">User Participation (earn PYT)</option>
</select>
</div>
<div class="form-group" id="pytRewardGroup" style="display: none;">
<label>PYT Reward</label>
<input type="number" id="pytReward" class="form-input" value="10" min="1" max="100">
<small style="color: var(--text-muted);">Tokens rewarded for approved photo</small>
</div>
<div class="form-group">
<label>Instructions (optional)</label>
<textarea id="taskInstructions" class="form-textarea" rows="3"
placeholder="e.g., Photo should show the full plate with garnish visible"></textarea>
</div>
<button type="submit" class="btn btn-primary">Create Task</button>
</form>
`;
this.showModal();
document.getElementById('taskType').addEventListener('change', (e) => {
document.getElementById('pytRewardGroup').style.display =
e.target.value === 'user' ? 'block' : 'none';
});
document.getElementById('photoTaskForm').addEventListener('submit', async (e) => {
e.preventDefault();
await this.submitPhotoTask(item.id);
});
},
// Submit photo task
async submitPhotoTask(itemId) {
const taskType = document.getElementById('taskType').value;
const instructions = document.getElementById('taskInstructions').value;
const pytReward = taskType === 'user' ? parseInt(document.getElementById('pytReward').value) : 0;
try {
const response = await fetch(`${this.config.apiBaseUrl}/tasks/create.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
BusinessID: this.config.businessId,
ItemID: itemId,
TaskType: taskType === 'user' ? 'user_photo' : 'employee_photo',
Instructions: instructions,
PYTReward: pytReward
})
});
const data = await response.json();
if (data.OK) {
// Update item with task ID
for (const cat of this.menu.categories) {
const item = cat.items.find(i => i.id === itemId);
if (item) {
item.photoTaskId = data.TASK_ID;
break;
}
}
this.closeModal();
this.render();
this.toast('Photo task created!', 'success');
} else {
this.toast(data.ERROR || 'Failed to create task', 'error');
}
} catch (err) {
console.error('[MenuBuilder] Error creating task:', err);
// For demo, just mark as having a task
for (const cat of this.menu.categories) {
const item = cat.items.find(i => i.id === itemId);
if (item) {
item.photoTaskId = 'pending_' + Date.now();
break;
}
}
this.closeModal();
this.render();
this.toast('Photo task created (demo)', 'success');
}
},
// Upload photo
uploadPhoto(itemId) {
// Create file input
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = async (e) => {
const file = e.target.files[0];
if (file) {
// For demo, just use a data URL
const reader = new FileReader();
reader.onload = () => {
for (const cat of this.menu.categories) {
const item = cat.items.find(i => i.id === itemId);
if (item) {
item.imageUrl = reader.result;
break;
}
}
this.render();
this.toast('Photo uploaded', 'success');
};
reader.readAsDataURL(file);
}
};
input.click();
},
// Show import modal
showImportModal() {
document.getElementById('modalTitle').textContent = 'Import Menu JSON';
document.getElementById('modalBody').innerHTML = `
<form id="importForm" class="form">
<div class="form-group">
<label>Paste JSON data</label>
<textarea id="importData" class="form-textarea import-export-area"
placeholder='{"categories": [{"name": "...", "items": [...]}]}'></textarea>
</div>
<div style="display: flex; gap: 8px;">
<button type="button" class="btn btn-secondary" onclick="MenuBuilder.loadSampleMenu()">
Load Sample
</button>
<button type="submit" class="btn btn-primary">Import</button>
</div>
</form>
`;
this.showModal();
document.getElementById('importForm').addEventListener('submit', (e) => {
e.preventDefault();
this.importMenu();
});
},
// Import menu
importMenu() {
try {
const data = JSON.parse(document.getElementById('importData').value);
this.saveState();
// Handle different JSON formats
if (data.categories) {
this.menu = data;
} else if (Array.isArray(data)) {
// Array of categories
this.menu.categories = data.map((cat, i) => ({
id: this.generateId(),
name: cat.name || cat.category || `Category ${i + 1}`,
description: cat.description || '',
sortOrder: i,
items: (cat.items || cat.children || []).map((item, j) => ({
id: this.generateId(),
name: item.name || item.title || 'Item',
description: item.description || '',
price: parseFloat(item.price) || 0,
imageUrl: item.imageUrl || item.image || null,
photoTaskId: null,
modifiers: (item.modifiers || item.children || []).map((mod, k) => ({
id: this.generateId(),
name: mod.name || 'Modifier',
price: parseFloat(mod.price) || 0,
isDefault: mod.isDefault || mod.checked || false,
sortOrder: k
})),
sortOrder: j
}))
}));
}
// Ensure all IDs are unique
this.menu.categories.forEach(cat => {
if (!cat.id || typeof cat.id === 'number') cat.id = this.generateId();
cat.items.forEach(item => {
if (!item.id || typeof item.id === 'number') item.id = this.generateId();
if (item.modifiers) {
item.modifiers.forEach(mod => {
if (!mod.id || typeof mod.id === 'number') mod.id = this.generateId();
});
}
});
});
this.closeModal();
this.render();
this.toast(`Imported ${this.menu.categories.length} categories`, 'success');
} catch (err) {
console.error('[MenuBuilder] Import error:', err);
this.toast('Invalid JSON format', 'error');
}
},
// Load sample menu
loadSampleMenu() {
const sample = {
categories: [
{
name: "Appetizers",
description: "Start your meal right",
items: [
{ name: "Hummus", description: "Classic chickpea dip with olive oil", price: 8.99 },
{ name: "Falafel", description: "Crispy fried chickpea balls", price: 7.99 },
{ name: "Baba Ganoush", description: "Smoky eggplant dip", price: 9.99 }
]
},
{
name: "Entrees",
description: "Main dishes",
items: [
{ name: "Chicken Shawarma", description: "Slow-roasted chicken with garlic sauce", price: 14.99 },
{ name: "Lamb Kebab", description: "Grilled lamb skewers", price: 16.99 },
{ name: "Mixed Grill", description: "Assortment of grilled meats", price: 22.99 }
]
},
{
name: "Beverages",
description: "Drinks",
items: [
{ name: "Mint Lemonade", description: "Fresh squeezed with mint", price: 4.99 },
{ name: "Turkish Coffee", description: "Traditional preparation", price: 3.99 }
]
}
]
};
document.getElementById('importData').value = JSON.stringify(sample, null, 2);
},
// Show export modal
showExportModal() {
document.getElementById('modalTitle').textContent = 'Export Menu JSON';
document.getElementById('modalBody').innerHTML = `
<div class="form-group">
<label>Menu Data</label>
<textarea id="exportData" class="form-textarea import-export-area" readonly>${JSON.stringify(this.menu, null, 2)}</textarea>
</div>
<div style="display: flex; gap: 8px;">
<button class="btn btn-secondary" onclick="MenuBuilder.copyExport()">
Copy to Clipboard
</button>
<button class="btn btn-primary" onclick="MenuBuilder.downloadExport()">
Download JSON
</button>
</div>
`;
this.showModal();
},
// Copy export
copyExport() {
const textarea = document.getElementById('exportData');
textarea.select();
document.execCommand('copy');
this.toast('Copied to clipboard', 'success');
},
// Download export
downloadExport() {
const data = document.getElementById('exportData').value;
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'menu-export.json';
a.click();
URL.revokeObjectURL(url);
},
// Load menu from API
async loadMenu() {
try {
const response = await fetch(`${this.config.apiBaseUrl}/menu/getForBuilder.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ BusinessID: this.config.businessId })
});
const data = await response.json();
if (data.OK && data.MENU) {
this.menu = data.MENU;
// Store templates from API
if (data.TEMPLATES) {
this.templates = data.TEMPLATES;
this.renderTemplateLibrary();
}
this.render();
}
} catch (err) {
console.log('[MenuBuilder] No existing menu or API not available');
}
},
// Render template library in sidebar
renderTemplateLibrary() {
const container = document.getElementById('templateLibrary');
if (!container || !this.templates) return;
if (this.templates.length === 0) {
container.innerHTML = '<div style="color: var(--text-muted); font-size: 13px; padding: 8px;">No templates yet</div>';
return;
}
container.innerHTML = this.templates.map(tmpl => `
<div class="palette-item template-item" draggable="true" data-type="template" data-template-id="${tmpl.dbId}">
<div class="icon">📋</div>
<div>
<div class="label">${this.escapeHtml(tmpl.name)}</div>
<div class="hint">${tmpl.options ? tmpl.options.length : 0} options</div>
</div>
</div>
`).join('');
// Update template count stat
const statEl = document.getElementById('statTemplates');
if (statEl) statEl.textContent = this.templates.length;
},
// Save menu to API
async saveMenu() {
try {
const response = await fetch(`${this.config.apiBaseUrl}/menu/saveFromBuilder.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
BusinessID: this.config.businessId,
Menu: this.menu
})
});
const data = await response.json();
if (data.OK) {
this.toast('Menu saved successfully!', 'success');
} else {
this.toast(data.ERROR || 'Failed to save', 'error');
}
} catch (err) {
console.error('[MenuBuilder] Save error:', err);
this.toast('Error saving menu', 'error');
}
},
// Recursive function to render modifiers/options at any depth
renderModifiers(modifiers, parentItemId, depth) {
if (!modifiers || modifiers.length === 0) return '';
const indent = 16 + (depth * 16); // Progressive indentation
const iconSize = Math.max(24, 32 - (depth * 4)); // Smaller icons for deeper levels
const icons = ['⚙️', '🔘', '◉', '•']; // Different icons for different levels
const icon = icons[Math.min(depth - 1, icons.length - 1)];
return modifiers.map(mod => {
const hasOptions = mod.options && mod.options.length > 0;
return `
<div class="item-card modifier depth-${depth}" data-modifier-id="${mod.id}" data-parent-item-id="${parentItemId}" data-depth="${depth}"
style="margin-left: ${indent}px;"
onclick="event.stopPropagation(); MenuBuilder.selectOption('${parentItemId}', '${mod.id}', ${depth})">
<div class="drag-handle" style="visibility: hidden;">
<svg width="12" height="12"></svg>
</div>
<div class="item-image" style="width: ${iconSize}px; height: ${iconSize}px; font-size: ${iconSize/2}px;">${icon}</div>
<div class="item-info">
<div class="item-name">${this.escapeHtml(mod.name)}</div>
<div class="item-meta">
${mod.price > 0 ? `<span>+$${mod.price.toFixed(2)}</span>` : ''}
${mod.isDefault ? '<span class="item-type-badge">Default</span>' : ''}
${hasOptions ? `<span class="item-type-badge">${mod.options.length} options</span>` : ''}
</div>
</div>
<div class="item-actions">
<button onclick="event.stopPropagation(); MenuBuilder.deleteOption('${parentItemId}', '${mod.id}', ${depth})" title="Delete">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
</div>
${hasOptions ? this.renderModifiers(mod.options, mod.id, depth + 1) : ''}
`;
}).join('');
},
// Render menu structure
render() {
const container = document.getElementById('menuStructure');
if (this.menu.categories.length === 0) {
container.innerHTML = `
<div class="empty-canvas" id="emptyCanvas">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M3 6h18M3 12h18M3 18h18"/>
</svg>
<h3>Start Building Your Menu</h3>
<p>Drag categories and items from the left panel, or click the buttons above to add them.</p>
</div>
`;
return;
}
container.innerHTML = this.menu.categories.map(category => `
<div class="category-card" data-category-id="${category.id}" draggable="true"
onclick="MenuBuilder.selectElement(this)">
<div class="category-header">
<div class="drag-handle">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/>
<circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/>
<circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/>
</svg>
</div>
<div class="category-info">
<div class="category-name">${this.escapeHtml(category.name)}</div>
<div class="category-count">${category.items.length} items</div>
</div>
<div class="category-actions">
<button onclick="event.stopPropagation(); MenuBuilder.addItem('${category.id}')" title="Add Item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M5 12h14"/>
</svg>
</button>
<button class="danger" onclick="event.stopPropagation(); MenuBuilder.deleteCategory('${category.id}')" title="Delete">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
</svg>
</button>
</div>
</div>
<div class="items-list">
${category.items.length === 0 ? `
<div class="item-drop-zone">Drag items here or click + to add</div>
` : category.items.map(item => `
<div class="item-card" data-item-id="${item.id}" draggable="true"
onclick="event.stopPropagation(); MenuBuilder.selectElement(this)">
<div class="drag-handle">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/>
<circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/>
<circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/>
</svg>
</div>
<div class="item-image">
${item.imageUrl ? `<img src="${item.imageUrl}" alt="">` : '🍽️'}
${item.photoTaskId ? `
<div class="photo-badge ${item.imageUrl ? '' : 'missing'}" title="${item.imageUrl ? 'Has photo' : 'Photo task pending'}">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<path d="M21 15l-5-5L5 21"/>
</svg>
</div>
` : ''}
</div>
<div class="item-info">
<div class="item-name">${this.escapeHtml(item.name)}</div>
<div class="item-meta">
<span class="item-price">$${(item.price || 0).toFixed(2)}</span>
${item.modifiers.length > 0 ? `<span>${item.modifiers.length} modifiers</span>` : ''}
</div>
</div>
<div class="item-actions">
<button onclick="event.stopPropagation(); MenuBuilder.cloneItem('${category.id}', '${item.id}')" title="Clone">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2"/>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
</svg>
</button>
<button class="danger" onclick="event.stopPropagation(); MenuBuilder.deleteItem('${category.id}', '${item.id}')" title="Delete">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
</div>
${this.renderModifiers(item.modifiers, item.id, 1)}
`).join('')}
</div>
</div>
`).join('');
// Update stats
let totalItems = 0;
let missingPhotos = 0;
this.menu.categories.forEach(cat => {
totalItems += cat.items.length;
cat.items.forEach(item => {
if (!item.imageUrl) missingPhotos++;
});
});
document.getElementById('statCategories').textContent = this.menu.categories.length;
document.getElementById('statItems').textContent = totalItems;
document.getElementById('statPhotosMissing').textContent = missingPhotos;
// Re-setup drag handlers for new elements
this.setupItemDragHandlers();
},
// Setup drag handlers for items
setupItemDragHandlers() {
document.querySelectorAll('.category-card').forEach(card => {
card.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('categoryId', card.dataset.categoryId);
e.dataTransfer.setData('source', 'canvas');
card.classList.add('dragging');
});
card.addEventListener('dragend', () => {
card.classList.remove('dragging');
});
card.addEventListener('dragover', (e) => {
e.preventDefault();
const source = e.dataTransfer.types.includes('categoryid') ? 'category' : 'item';
if (source === 'item') {
card.classList.add('drag-over');
}
});
card.addEventListener('dragleave', () => {
card.classList.remove('drag-over');
});
card.addEventListener('drop', (e) => {
e.preventDefault();
card.classList.remove('drag-over');
const itemId = e.dataTransfer.getData('itemId');
if (itemId) {
this.moveItemToCategory(itemId, card.dataset.categoryId);
}
});
});
document.querySelectorAll('.item-card:not(.modifier)').forEach(card => {
card.addEventListener('dragstart', (e) => {
e.stopPropagation();
e.dataTransfer.setData('itemId', card.dataset.itemId);
e.dataTransfer.setData('source', 'canvas');
card.classList.add('dragging');
});
card.addEventListener('dragend', () => {
card.classList.remove('dragging');
});
});
},
// Clone item
cloneItem(categoryId, itemId) {
this.saveState();
const category = this.menu.categories.find(c => c.id === categoryId);
if (category) {
const item = category.items.find(i => i.id === itemId);
if (item) {
const clone = JSON.parse(JSON.stringify(item));
clone.id = this.generateId();
clone.name = item.name + ' (Copy)';
clone.modifiers.forEach(mod => mod.id = this.generateId());
category.items.push(clone);
this.render();
this.toast('Item cloned', 'success');
}
}
},
// Show modal
showModal() {
document.getElementById('modalOverlay').classList.add('visible');
},
// Close modal
closeModal() {
document.getElementById('modalOverlay').classList.remove('visible');
},
// Toast
toast(message, type = 'info') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
},
// Escape HTML
escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>"']/g, (m) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
})[m]);
}
};
// Initialize
document.addEventListener('DOMContentLoaded', () => MenuBuilder.init());
</script>
</body>
</html>