payfrit-works/portal/menu-builder.html

3712 lines
129 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="icon" type="image/svg+xml" href="favicon.svg">
<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;
overflow: hidden;
transition: max-height 0.3s ease, padding 0.3s ease, opacity 0.3s ease;
}
.items-list.collapsed {
max-height: 0;
padding: 0 12px;
opacity: 0;
min-height: 0;
}
/* Chevron toggle */
.category-toggle {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 0.3s ease;
color: var(--text-muted);
}
.category-toggle:hover {
color: var(--text-primary);
}
.category-toggle.expanded {
transform: rotate(90deg);
}
/* Item Toggle (for expanding modifiers) */
.item-toggle {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 0.3s ease;
color: var(--text-muted);
flex-shrink: 0;
}
.item-toggle:hover {
color: var(--text-primary);
}
.item-toggle.expanded {
transform: rotate(90deg);
}
.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);
}
/* Drop indicators for reordering */
.item-card.drop-before,
.category-card.drop-before {
position: relative;
}
.item-card.drop-before::before,
.category-card.drop-before::before {
content: '';
position: absolute;
top: -4px;
left: 0;
right: 0;
height: 4px;
background: var(--primary);
border-radius: 2px;
}
.item-card.drop-after,
.category-card.drop-after {
position: relative;
}
.item-card.drop-after::after,
.category-card.drop-after::after {
content: '';
position: absolute;
bottom: -4px;
left: 0;
right: 0;
height: 4px;
background: var(--primary);
border-radius: 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: #fff;
}
.toolbar-btn.primary:hover {
background: var(--primary-hover);
}
.toolbar-btn.primary:active {
background: var(--primary-dark);
transform: scale(0.98);
}
.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 id="backLink" href="#" 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.showOutlineModal()" title="View Full Menu Outline">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 6h16M4 12h16M4 18h12"/>
</svg>
Outline
</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>
<h3>Branding</h3>
<div style="display: flex; flex-direction: column; gap: 8px;">
<button class="btn btn-secondary" onclick="MenuBuilder.uploadHeader()" style="width: 100%; display: flex; align-items: center; gap: 8px; justify-content: center;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
Upload Header
</button>
<button class="btn btn-secondary" onclick="MenuBuilder.showBrandColorPicker()" style="width: 100%; display: flex; align-items: center; gap: 8px; justify-content: center;">
<span id="brandColorSwatch" style="width: 16px; height: 16px; border-radius: 3px; background: #1B4D3E; border: 1px solid rgba(0,0,0,0.2);"></span>
Brand Color
</button>
<p style="font-size: 11px; color: var(--text-muted); margin: 0;">
Header: 1200x400px recommended<br>
Color: Used for category bars in the app
</p>
</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,
expandedCategoryId: null, // For accordion - only one category expanded at a time
expandedItemId: null, // For item accordion - only one item expanded at a time
expandedModifierIds: new Set(), // Track which modifiers are expanded
// Initialize
async init() {
console.log('[MenuBuilder] Initializing...');
// Set back link with BASE_PATH
document.getElementById('backLink').href = BASE_PATH + '/portal/index.html#menu';
// 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 localStorage
this.config.businessId = parseInt(savedBusiness) || null;
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);
}
},
// Select item by ID (shortcut for use in onclick handlers)
selectItem(itemId) {
this.selectElementById('item', itemId);
},
// 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);
// rootItem is the item itself when parent is the item
if (opt) return { option: opt, parent: item, parentType: 'item', category: cat, rootItem: item };
}
// 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) {
// Store rootItem for detach functionality
this.selectedData = {
type: 'option',
data: result.option,
parent: result.parent,
depth: depth,
rootItem: result.rootItem || result.category?.items?.find(i => i.id === parentId)
};
this.showPropertiesForOption(result.option, result.parent, depth, result.rootItem);
}
},
// Show properties for option at any depth
showPropertiesForOption(option, parent, depth, rootItem) {
const hasOptions = option.options && option.options.length > 0;
const levelName = depth === 1 ? 'Modifier Group' : (depth === 2 ? 'Option' : `Level ${depth} Option`);
const isModifierGroup = depth === 1 && hasOptions;
// Check if this is a shared template (only for depth 1 modifiers)
const isShared = depth === 1 && option.dbId && this.isSharedTemplate(option.dbId);
const sharedItems = isShared ? this.getItemsSharingTemplate(option.dbId) : [];
document.getElementById('propertiesContent').innerHTML = `
${isShared ? `
<div style="background: rgba(255, 193, 7, 0.1); border: 1px solid var(--warning); border-radius: 8px; padding: 12px; margin-bottom: 16px;">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<span style="font-size: 16px;">🔗</span>
<strong style="color: var(--warning);">Shared Template</strong>
</div>
<p style="color: var(--text-muted); font-size: 12px; margin: 0 0 8px;">
Changes will apply to all ${sharedItems.length} items using this modifier:
</p>
<p style="color: var(--text-secondary); font-size: 11px; margin: 0; max-height: 60px; overflow-y: auto;">
${sharedItems.map(n => this.escapeHtml(n)).join(', ')}
</p>
${rootItem ? `
<button class="btn btn-secondary" style="margin-top: 12px; font-size: 12px;"
onclick="MenuBuilder.detachFromTemplate('${rootItem.id}', '${option.id}')">
✂️ Detach for This Item Only
</button>
` : ''}
</div>
` : ''}
<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>
${isModifierGroup ? `
<div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border-color);">
<h4 style="margin: 0 0 12px; font-size: 13px; color: var(--text-muted);">Selection Rules</h4>
<div class="property-row">
<div class="property-group">
<label>Required</label>
<select onchange="MenuBuilder.updateOption('${parent.id}', '${option.id}', 'requiresSelection', this.value === 'true')">
<option value="false" ${!option.requiresSelection ? 'selected' : ''}>No</option>
<option value="true" ${option.requiresSelection ? 'selected' : ''}>Yes</option>
</select>
</div>
<div class="property-group">
<label>Max Selections</label>
<input type="number" min="0" value="${option.maxSelections || 0}"
onchange="MenuBuilder.updateOption('${parent.id}', '${option.id}', 'maxSelections', parseInt(this.value))"
title="0 = unlimited">
</div>
</div>
<p style="color: var(--text-muted); font-size: 11px; margin: 4px 0 0;">
Required = customer must select at least one option. Max 0 = unlimited.
</p>
</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
// IMPORTANT: Templates are shared across multiple items, so we need to update ALL copies
updateOption(parentId, optionId, field, value) {
console.log('[MenuBuilder] updateOption called:', { parentId, optionId, field, value });
this.saveState();
// Get the dbId of the option being updated (to find all copies)
const result = this.findOptionWithParent(parentId, optionId);
if (!result || !result.option) {
console.error('[MenuBuilder] Could not find option to update!', { parentId, optionId });
return;
}
const optionDbId = result.option.dbId;
const parentDbId = result.parent.dbId;
const isTopLevelModifier = result.parentType === 'item'; // parent is the menu item itself
console.log('[MenuBuilder] Updating option dbId:', optionDbId, 'in parent dbId:', parentDbId, 'isTopLevel:', isTopLevelModifier);
// Update ALL instances of this option across all items (templates are duplicated)
let updateCount = 0;
for (const cat of this.menu.categories) {
for (const item of cat.items) {
for (const mod of (item.modifiers || [])) {
// If editing a top-level modifier group, match by the modifier's dbId directly
if (isTopLevelModifier && mod.dbId === optionDbId) {
mod[field] = value;
updateCount++;
console.log('[MenuBuilder] Updated top-level modifier in item:', item.name);
}
// If editing a nested option, find within the modifier's options
else if (!isTopLevelModifier && mod.dbId === parentDbId) {
// Find the option within this modifier
const opt = (mod.options || []).find(o => o.dbId === optionDbId);
if (opt) {
opt[field] = value;
updateCount++;
console.log('[MenuBuilder] Updated nested option in item:', item.name);
}
}
// Also check nested options recursively for deeper levels
this.updateOptionInModifiers(mod.options || [], parentDbId, optionDbId, field, value);
}
}
}
console.log('[MenuBuilder] Total copies updated:', updateCount);
this.render();
this.selectOption(parentId, optionId, this.selectedData?.depth || 1);
},
// Helper to recursively update option in nested modifiers
updateOptionInModifiers(options, parentDbId, optionDbId, field, value) {
for (const opt of options) {
if (opt.dbId === parentDbId) {
const child = (opt.options || []).find(o => o.dbId === optionDbId);
if (child) {
child[field] = value;
}
}
if (opt.options && opt.options.length > 0) {
this.updateOptionInModifiers(opt.options, parentDbId, optionDbId, field, value);
}
}
},
// Check if a modifier (by dbId) is shared across multiple items
isSharedTemplate(modifierDbId) {
if (!modifierDbId) return false;
let count = 0;
for (const cat of this.menu.categories) {
for (const item of cat.items) {
for (const mod of (item.modifiers || [])) {
if (mod.dbId === modifierDbId) {
count++;
if (count > 1) return true;
}
}
}
}
return false;
},
// Get list of items that share a template
getItemsSharingTemplate(modifierDbId) {
if (!modifierDbId) return [];
const items = [];
for (const cat of this.menu.categories) {
for (const item of cat.items) {
for (const mod of (item.modifiers || [])) {
if (mod.dbId === modifierDbId) {
items.push(item.name);
}
}
}
}
return items;
},
// Detach a modifier from its template - creates a local copy for this item only
detachFromTemplate(itemId, modifierId) {
this.saveState();
// Find the item and modifier
for (const cat of this.menu.categories) {
const item = cat.items.find(i => i.id === itemId);
if (item) {
const modIndex = item.modifiers.findIndex(m => m.id === modifierId);
if (modIndex !== -1) {
const mod = item.modifiers[modIndex];
// Create a deep copy with new IDs (null dbId means it will be created as new)
const detachedCopy = this.deepCopyWithNewIds(mod);
detachedCopy.name = mod.name + ' (Custom)';
// Replace the original with the detached copy
item.modifiers[modIndex] = detachedCopy;
this.render();
this.toast('Modifier detached - this item now has its own copy', 'success');
// Select the new detached modifier
this.selectOption(itemId, detachedCopy.id, 1);
return;
}
}
}
this.toast('Could not find modifier to detach', 'error');
},
// Create a deep copy of a modifier/option tree with new IDs
deepCopyWithNewIds(obj) {
const newObj = {
id: 'mod_new_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9),
dbId: null, // null = will be created as new in database
name: obj.name,
price: obj.price || 0,
isDefault: obj.isDefault || false,
sortOrder: obj.sortOrder || 0,
requiresSelection: obj.requiresSelection || false,
maxSelections: obj.maxSelections || 0,
options: []
};
// Recursively copy options
if (obj.options && obj.options.length > 0) {
newObj.options = obj.options.map(opt => this.deepCopyWithNewIds(opt));
}
return newObj;
},
// 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;
}
}
}
},
// Find modifier/option and its parent array
findModifierWithParent(modifierId) {
for (const cat of this.menu.categories) {
for (const item of cat.items) {
// Check if it's a top-level modifier
const idx = item.modifiers.findIndex(m => m.id === modifierId);
if (idx !== -1) {
return { array: item.modifiers, index: idx, parentId: item.id, isItem: true };
}
// Check nested
const nested = this.findNestedModifierWithParent(item.modifiers, modifierId);
if (nested) return nested;
}
}
return null;
},
findNestedModifierWithParent(modifiers, modifierId) {
for (const mod of modifiers) {
if (mod.options && mod.options.length > 0) {
const idx = mod.options.findIndex(o => o.id === modifierId);
if (idx !== -1) {
return { array: mod.options, index: idx, parentId: mod.id, isItem: false };
}
const nested = this.findNestedModifierWithParent(mod.options, modifierId);
if (nested) return nested;
}
}
return null;
},
// Get the parent array for a parentId (item or modifier)
getParentModifiersArray(parentId) {
for (const cat of this.menu.categories) {
for (const item of cat.items) {
if (item.id === parentId) {
return item.modifiers;
}
const found = this.findOptionRecursive(item.modifiers, parentId);
if (found) {
if (!found.options) found.options = [];
return found.options;
}
}
}
return null;
},
// Deep clone a modifier with new IDs
cloneModifierDeep(mod) {
const clone = JSON.parse(JSON.stringify(mod));
clone.id = this.generateId();
clone.dbId = null; // Will get new dbId on save
if (clone.options && clone.options.length > 0) {
clone.options = clone.options.map(opt => this.cloneModifierDeep(opt));
}
return clone;
},
// Reorder modifier within same parent
reorderModifier(draggedId, targetId, parentId, position) {
this.saveState();
const parentArray = this.getParentModifiersArray(parentId);
if (!parentArray) return;
const draggedIdx = parentArray.findIndex(m => m.id === draggedId);
const targetIdx = parentArray.findIndex(m => m.id === targetId);
if (draggedIdx === -1 || targetIdx === -1) return;
// Remove dragged
const [dragged] = parentArray.splice(draggedIdx, 1);
// Find new target index (may have shifted)
let newTargetIdx = parentArray.findIndex(m => m.id === targetId);
if (position === 'after') newTargetIdx++;
// Insert at new position
parentArray.splice(newTargetIdx, 0, dragged);
// Update sort orders
parentArray.forEach((m, i) => m.sortOrder = i);
this.render();
this.toast('Modifier reordered', 'success');
},
// Move modifier from one parent to another
moveModifier(draggedId, fromParentId, targetModId, toParentId, position) {
this.saveState();
const fromArray = this.getParentModifiersArray(fromParentId);
const toArray = this.getParentModifiersArray(toParentId);
if (!fromArray || !toArray) return;
const draggedIdx = fromArray.findIndex(m => m.id === draggedId);
if (draggedIdx === -1) return;
// Remove from source
const [dragged] = fromArray.splice(draggedIdx, 1);
// Find target position in destination
const targetIdx = toArray.findIndex(m => m.id === targetModId);
let insertIdx = targetIdx === -1 ? toArray.length : targetIdx;
if (position === 'after') insertIdx++;
// Insert at new position
toArray.splice(insertIdx, 0, dragged);
// Update sort orders in both arrays
fromArray.forEach((m, i) => m.sortOrder = i);
toArray.forEach((m, i) => m.sortOrder = i);
this.render();
this.toast('Modifier moved', 'success');
},
// Copy modifier to position relative to another modifier
copyModifier(draggedId, fromParentId, targetModId, toParentId, position) {
this.saveState();
const fromArray = this.getParentModifiersArray(fromParentId);
const toArray = this.getParentModifiersArray(toParentId);
if (!fromArray || !toArray) return;
const dragged = fromArray.find(m => m.id === draggedId);
if (!dragged) return;
// Clone with new IDs
const clone = this.cloneModifierDeep(dragged);
// Find target position
const targetIdx = toArray.findIndex(m => m.id === targetModId);
let insertIdx = targetIdx === -1 ? toArray.length : targetIdx;
if (position === 'after') insertIdx++;
// Insert clone
toArray.splice(insertIdx, 0, clone);
// Update sort orders
toArray.forEach((m, i) => m.sortOrder = i);
this.render();
this.toast('Modifier copied', 'success');
},
// Move modifier to an item (as top-level modifier)
moveModifierToItem(draggedId, fromParentId, toItemId) {
this.saveState();
const fromArray = this.getParentModifiersArray(fromParentId);
if (!fromArray) return;
// Find target item
let targetItem = null;
for (const cat of this.menu.categories) {
targetItem = cat.items.find(i => i.id === toItemId);
if (targetItem) break;
}
if (!targetItem) return;
const draggedIdx = fromArray.findIndex(m => m.id === draggedId);
if (draggedIdx === -1) return;
// Remove from source
const [dragged] = fromArray.splice(draggedIdx, 1);
// Add to target item's modifiers
dragged.sortOrder = targetItem.modifiers.length;
targetItem.modifiers.push(dragged);
// Update sort orders
fromArray.forEach((m, i) => m.sortOrder = i);
this.render();
this.toast('Modifier moved to item', 'success');
},
// Copy modifier to an item (as top-level modifier)
copyModifierToItem(draggedId, fromParentId, toItemId) {
this.saveState();
const fromArray = this.getParentModifiersArray(fromParentId);
if (!fromArray) return;
// Find target item
let targetItem = null;
for (const cat of this.menu.categories) {
targetItem = cat.items.find(i => i.id === toItemId);
if (targetItem) break;
}
if (!targetItem) return;
const dragged = fromArray.find(m => m.id === draggedId);
if (!dragged) return;
// Clone with new IDs
const clone = this.cloneModifierDeep(dragged);
clone.sortOrder = targetItem.modifiers.length;
// Add to target item
targetItem.modifiers.push(clone);
this.render();
this.toast('Modifier copied to item', 'success');
},
// Update category
// 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();
},
// Reorder item within same category (drag and drop)
reorderItemInCategory(draggedItemId, targetItemId, position = 'before') {
// Find the category containing these items
for (const cat of this.menu.categories) {
const draggedIdx = cat.items.findIndex(i => i.id === draggedItemId);
const targetIdx = cat.items.findIndex(i => i.id === targetItemId);
if (draggedIdx !== -1 && targetIdx !== -1 && draggedIdx !== targetIdx) {
this.saveState();
// Remove dragged item
const [draggedItem] = cat.items.splice(draggedIdx, 1);
// Find new target index (it may have shifted)
const newTargetIdx = cat.items.findIndex(i => i.id === targetItemId);
// Insert at new position
const insertIdx = position === 'before' ? newTargetIdx : newTargetIdx + 1;
cat.items.splice(insertIdx, 0, draggedItem);
// Update sortOrder for all items
cat.items.forEach((item, idx) => item.sortOrder = idx);
this.render();
return;
}
}
},
// Reorder category (drag and drop)
reorderCategory(draggedCatId, targetCatId, position = 'before') {
const draggedIdx = this.menu.categories.findIndex(c => c.id === draggedCatId);
const targetIdx = this.menu.categories.findIndex(c => c.id === targetCatId);
if (draggedIdx !== -1 && targetIdx !== -1 && draggedIdx !== targetIdx) {
this.saveState();
const [draggedCat] = this.menu.categories.splice(draggedIdx, 1);
const newTargetIdx = this.menu.categories.findIndex(c => c.id === targetCatId);
const insertIdx = position === 'before' ? newTargetIdx : newTargetIdx + 1;
this.menu.categories.splice(insertIdx, 0, draggedCat);
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);
},
// Show outline modal - full menu in hierarchical text format
showOutlineModal() {
document.getElementById('modalTitle').textContent = 'Menu Outline';
let outline = '';
const categories = this.menu.categories || [];
if (categories.length === 0) {
outline = '<div style="color: var(--text-muted); text-align: center; padding: 40px;">No menu items yet</div>';
} else {
outline = '<div class="menu-outline">';
for (const cat of categories) {
outline += `<div class="outline-category">${this.escapeHtml(cat.name)}</div>`;
const items = cat.items || [];
for (const item of items) {
const itemPrice = item.price ? `$${parseFloat(item.price).toFixed(2)}` : '';
outline += `<div class="outline-item">${this.escapeHtml(item.name)}${itemPrice ? ' <span class="outline-price">' + itemPrice + '</span>' : ''}</div>`;
// Render modifiers recursively
outline += this.renderOutlineModifiers(item.modifiers || [], 2);
}
}
outline += '</div>';
}
document.getElementById('modalBody').innerHTML = `
<style>
.menu-outline {
font-family: monospace;
font-size: 13px;
line-height: 1.6;
max-height: 60vh;
overflow-y: auto;
padding: 16px;
background: var(--bg-secondary);
border-radius: 8px;
}
.outline-category {
font-weight: bold;
font-size: 15px;
color: var(--primary);
margin-top: 16px;
margin-bottom: 4px;
text-transform: uppercase;
}
.outline-category:first-child {
margin-top: 0;
}
.outline-item {
padding-left: 20px;
color: var(--text-primary);
}
.outline-modifier {
color: var(--text-muted);
}
.outline-price {
color: var(--success);
}
.outline-modifier .outline-price {
color: var(--warning);
}
</style>
${outline}
`;
this.showModal();
},
// Helper to render modifiers recursively for outline
renderOutlineModifiers(modifiers, depth) {
if (!modifiers || modifiers.length === 0) return '';
let html = '';
const indent = '\u00A0\u00A0\u00A0\u00A0'.repeat(depth); // non-breaking spaces for indent
for (const mod of modifiers) {
const modPrice = mod.price ? `+$${parseFloat(mod.price).toFixed(2)}` : '';
html += `<div class="outline-modifier" style="padding-left: ${depth * 20}px;">${indent.substring(0, 4)}${this.escapeHtml(mod.name)}${modPrice ? ' <span class="outline-price">' + modPrice + '</span>' : ''}</div>`;
// Recurse into nested options
if (mod.options && mod.options.length > 0) {
html += this.renderOutlineModifiers(mod.options, depth + 1);
}
}
return html;
},
// Upload header image (1200x400)
uploadHeader() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
// Validate file type
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!validTypes.includes(file.type)) {
this.toast('Please select a valid image (JPG, PNG, GIF, or WebP)', 'error');
return;
}
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
this.toast('Image must be under 5MB', 'error');
return;
}
// Show loading toast
this.toast('Uploading header...', 'info');
try {
const formData = new FormData();
formData.append('header', file);
formData.append('BusinessID', this.config.businessId);
const response = await fetch(`${this.config.apiBaseUrl}/menu/uploadHeader.cfm`, {
method: 'POST',
body: formData,
credentials: 'include'
});
const data = await response.json();
if (data.OK) {
this.toast('Header uploaded successfully! Recommended size: 600x200 JPG', 'success');
} else {
this.toast(data.MESSAGE || 'Failed to upload header', 'error');
}
} catch (err) {
console.error('Header upload error:', err);
this.toast('Failed to upload header', 'error');
}
};
input.click();
},
// Show brand color picker modal
showBrandColorPicker() {
const currentColor = this.brandColor || '#1B4D3E';
document.getElementById('modalTitle').textContent = 'Brand Color';
document.getElementById('modalBody').innerHTML = `
<p style="margin-bottom: 16px; color: var(--text-muted);">
This color is used for the category bar gradients in the customer app.
</p>
<div style="display: flex; align-items: center; gap: 16px; margin-bottom: 16px;">
<input type="color" id="brandColorInput" value="${currentColor}"
style="width: 60px; height: 40px; border: none; cursor: pointer; border-radius: 4px;">
<input type="text" id="brandColorHex" value="${currentColor}"
style="width: 100px; padding: 8px; border: 1px solid var(--border); border-radius: 4px; font-family: monospace;"
pattern="^#[0-9A-Fa-f]{6}$" maxlength="7">
<div id="brandColorPreview" style="flex: 1; height: 40px; border-radius: 4px; background: linear-gradient(to bottom, ${currentColor}44, ${currentColor}00, ${currentColor}66);"></div>
</div>
<div style="display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px;">
<button type="button" class="color-preset" data-color="#1B4D3E" style="width: 32px; height: 32px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; background: #1B4D3E;" title="Forest Green (Default)"></button>
<button type="button" class="color-preset" data-color="#2C3E50" style="width: 32px; height: 32px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; background: #2C3E50;" title="Midnight Blue"></button>
<button type="button" class="color-preset" data-color="#8B4513" style="width: 32px; height: 32px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; background: #8B4513;" title="Saddle Brown"></button>
<button type="button" class="color-preset" data-color="#800020" style="width: 32px; height: 32px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; background: #800020;" title="Burgundy"></button>
<button type="button" class="color-preset" data-color="#1A1A2E" style="width: 32px; height: 32px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; background: #1A1A2E;" title="Dark Navy"></button>
<button type="button" class="color-preset" data-color="#4A0E4E" style="width: 32px; height: 32px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; background: #4A0E4E;" title="Deep Purple"></button>
<button type="button" class="color-preset" data-color="#CC5500" style="width: 32px; height: 32px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; background: #CC5500;" title="Burnt Orange"></button>
<button type="button" class="color-preset" data-color="#355E3B" style="width: 32px; height: 32px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; background: #355E3B;" title="Hunter Green"></button>
</div>
<div style="display: flex; gap: 8px; justify-content: flex-end;">
<button type="button" class="btn btn-secondary" onclick="MenuBuilder.closeModal()">Cancel</button>
<button type="button" class="btn btn-primary" onclick="MenuBuilder.saveBrandColor()">Save Color</button>
</div>
`;
this.showModal();
// Wire up color picker sync
const colorInput = document.getElementById('brandColorInput');
const hexInput = document.getElementById('brandColorHex');
const preview = document.getElementById('brandColorPreview');
const updatePreview = (color) => {
preview.style.background = `linear-gradient(to bottom, ${color}44, ${color}00, ${color}66)`;
};
colorInput.addEventListener('input', (e) => {
hexInput.value = e.target.value.toUpperCase();
updatePreview(e.target.value);
});
hexInput.addEventListener('input', (e) => {
let val = e.target.value;
if (!val.startsWith('#')) val = '#' + val;
if (/^#[0-9A-Fa-f]{6}$/.test(val)) {
colorInput.value = val;
updatePreview(val);
}
});
// Preset buttons
document.querySelectorAll('.color-preset').forEach(btn => {
btn.addEventListener('click', () => {
const color = btn.dataset.color;
colorInput.value = color;
hexInput.value = color;
updatePreview(color);
});
});
},
// Save brand color
async saveBrandColor() {
const color = document.getElementById('brandColorHex').value.toUpperCase();
if (!/^#[0-9A-Fa-f]{6}$/.test(color)) {
this.toast('Invalid color format. Use #RRGGBB', 'error');
return;
}
try {
const response = await fetch(`${this.config.apiBaseUrl}/businesses/saveBrandColor.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ BusinessID: this.config.businessId, BrandColor: color })
});
const data = await response.json();
if (data.OK) {
this.brandColor = color;
document.getElementById('brandColorSwatch').style.background = color;
this.closeModal();
this.toast('Brand color saved!', 'success');
} else {
this.toast(data.MESSAGE || 'Failed to save color', 'error');
}
} catch (err) {
console.error('Save brand color error:', err);
this.toast('Failed to save color', 'error');
}
},
// Load menu from API
async loadMenu() {
try {
console.log('[MenuBuilder] Loading menu for BusinessID:', this.config.businessId);
console.log('[MenuBuilder] API URL:', `${this.config.apiBaseUrl}/menu/getForBuilder.cfm`);
const response = await fetch(`${this.config.apiBaseUrl}/menu/getForBuilder.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ BusinessID: this.config.businessId })
});
console.log('[MenuBuilder] Response status:', response.status);
const data = await response.json();
console.log('[MenuBuilder] Response data:', data);
console.log('[MenuBuilder] CATEGORY_COUNT:', data.CATEGORY_COUNT);
console.log('[MenuBuilder] ITEM_COUNT:', data.ITEM_COUNT);
if (data.OK && data.MENU) {
this.menu = data.MENU;
console.log('[MenuBuilder] Menu categories:', this.menu.categories?.length || 0);
// Store templates from API (default to empty array if not provided)
this.templates = data.TEMPLATES || [];
// Load brand color if set
if (data.BRANDCOLOR && data.BRANDCOLOR.length > 0) {
this.brandColor = data.BRANDCOLOR;
const swatch = document.getElementById('brandColorSwatch');
if (swatch) swatch.style.background = data.BRANDCOLOR;
}
this.renderTemplateLibrary();
this.render();
} else {
console.log('[MenuBuilder] No MENU in response or OK=false');
// Still clear the loading message
this.templates = [];
this.renderTemplateLibrary();
}
} catch (err) {
console.error('[MenuBuilder] Error loading menu:', err);
// Clear loading message on error
this.templates = [];
this.renderTemplateLibrary();
}
},
// 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 {
console.log('[MenuBuilder] Saving menu...');
console.log('[MenuBuilder] BusinessID:', this.config.businessId);
// Debug: Find and log the specific option we're testing
for (const cat of this.menu.categories) {
for (const item of cat.items) {
for (const mod of (item.modifiers || [])) {
if (mod.name === 'Select Drink') {
console.log('[MenuBuilder] Select Drink modifier at save time:', JSON.stringify(mod, null, 2));
}
}
}
}
console.log('[MenuBuilder] Menu data:', JSON.stringify(this.menu, null, 2));
const payload = {
BusinessID: this.config.businessId,
Menu: this.menu
};
console.log('[MenuBuilder] Full payload:', JSON.stringify(payload, null, 2));
const response = await fetch(`${this.config.apiBaseUrl}/menu/saveFromBuilder.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
console.log('[MenuBuilder] Response status:', response.status);
const responseText = await response.text();
console.log('[MenuBuilder] Raw response:', responseText);
let data;
try {
data = JSON.parse(responseText);
} catch (parseErr) {
console.error('[MenuBuilder] Failed to parse response as JSON:', parseErr);
this.toast('Server returned invalid response', 'error');
return;
}
console.log('[MenuBuilder] Parsed response:', data);
if (data.OK) {
this.toast('Menu saved successfully!', 'success');
// Reload menu to get updated dbIds
await this.loadMenu();
} else {
console.error('[MenuBuilder] Save failed:', data.ERROR, data.DETAIL);
this.toast(data.ERROR || 'Failed to save', 'error');
}
} catch (err) {
console.error('[MenuBuilder] Save error:', err);
this.toast('Error saving menu: ' + err.message, '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;
const modExpanded = this.expandedModifierIds.has(mod.id);
return `
<div class="item-card modifier depth-${depth} ${modExpanded ? 'expanded' : ''}"
data-modifier-id="${mod.id}" data-parent-id="${parentItemId}" data-depth="${depth}"
draggable="true"
style="margin-left: ${indent}px;"
onclick="event.stopPropagation(); MenuBuilder.selectOption('${parentItemId}', '${mod.id}', ${depth})">
${hasOptions ? `
<div class="item-toggle ${modExpanded ? 'expanded' : ''}" onclick="event.stopPropagation(); MenuBuilder.toggleModifier('${mod.id}')">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 18l6-6-6-6"/>
</svg>
</div>
` : ''}
<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" style="width: ${iconSize}px; height: ${iconSize}px; font-size: ${iconSize/2}px;">${icon}</div>
<div class="item-info" onclick="event.stopPropagation(); ${hasOptions ? `MenuBuilder.toggleModifier('${mod.id}')` : `MenuBuilder.selectOption('${parentItemId}', '${mod.id}', ${depth})`}" style="cursor: pointer;">
<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>' : ''}
${mod.requiresSelection ? '<span class="item-type-badge" style="background: #dc3545; color: white;">Required</span>' : ''}
${mod.maxSelections > 0 ? `<span class="item-type-badge">Max ${mod.maxSelections}</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 && modExpanded ? this.renderModifiers(mod.options, mod.id, depth + 1) : ''}
`;
}).join('');
},
// Render menu structure
// 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 => {
const isExpanded = this.expandedCategoryId === category.id;
return `
<div class="category-card" data-category-id="${category.id}" draggable="true"
onclick="MenuBuilder.selectElement(this)">
<div class="category-header">
<div class="category-toggle ${isExpanded ? 'expanded' : ''}" onclick="event.stopPropagation(); MenuBuilder.toggleCategory('${category.id}')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 18l6-6-6-6"/>
</svg>
</div>
<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" onclick="event.stopPropagation(); MenuBuilder.toggleCategory('${category.id}')" style="cursor: pointer;">
<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 ${isExpanded ? '' : 'collapsed'}">
${category.items.length === 0 ? `
<div class="item-drop-zone">Drag items here or click + to add</div>
` : category.items.map(item => {
const itemExpanded = this.expandedItemId === item.id;
const hasModifiers = item.modifiers && item.modifiers.length > 0;
return `
<div class="item-card ${itemExpanded ? 'expanded' : ''}" data-item-id="${item.id}" draggable="true"
onclick="event.stopPropagation(); MenuBuilder.selectElement(this)">
${hasModifiers ? `
<div class="item-toggle ${itemExpanded ? 'expanded' : ''}" onclick="event.stopPropagation(); MenuBuilder.toggleItem('${item.id}')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 18l6-6-6-6"/>
</svg>
</div>
` : ''}
<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" onclick="event.stopPropagation(); ${hasModifiers ? `MenuBuilder.toggleItem('${item.id}')` : `MenuBuilder.selectItem('${item.id}')`}" style="cursor: pointer;">
<div class="item-name">${this.escapeHtml(item.name)}</div>
<div class="item-meta">
<span class="item-price">$${(item.price || 0).toFixed(2)}</span>
${hasModifiers ? `<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>
${itemExpanded ? 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');
document.querySelectorAll('.drop-before, .drop-after').forEach(el => {
el.classList.remove('drop-before', 'drop-after');
});
});
card.addEventListener('dragover', (e) => {
e.preventDefault();
const isDraggingCategory = e.dataTransfer.types.includes('categoryid');
const isDraggingItem = e.dataTransfer.types.includes('itemid');
if (isDraggingItem) {
card.classList.add('drag-over');
} else if (isDraggingCategory && !card.classList.contains('dragging')) {
// Category reordering - show drop indicator
const rect = card.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2;
card.classList.remove('drop-before', 'drop-after', 'drag-over');
if (e.clientY < midpoint) {
card.classList.add('drop-before');
} else {
card.classList.add('drop-after');
}
}
});
card.addEventListener('dragleave', () => {
card.classList.remove('drag-over', 'drop-before', 'drop-after');
});
card.addEventListener('drop', (e) => {
e.preventDefault();
card.classList.remove('drag-over', 'drop-before', 'drop-after');
const itemId = e.dataTransfer.getData('itemId');
const draggedCategoryId = e.dataTransfer.getData('categoryId');
const targetCategoryId = card.dataset.categoryId;
if (itemId) {
// Moving item to category
this.moveItemToCategory(itemId, targetCategoryId);
} else if (draggedCategoryId && draggedCategoryId !== targetCategoryId) {
// Reordering categories
const rect = card.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2;
const position = e.clientY < midpoint ? 'before' : 'after';
this.reorderCategory(draggedCategoryId, targetCategoryId, position);
}
});
});
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');
// Remove all drop indicators
document.querySelectorAll('.drop-before, .drop-after').forEach(el => {
el.classList.remove('drop-before', 'drop-after');
});
});
card.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
const draggedItemId = e.dataTransfer.types.includes('itemid');
if (draggedItemId && !card.classList.contains('dragging')) {
// Determine if dropping above or below based on mouse position
const rect = card.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2;
card.classList.remove('drop-before', 'drop-after');
if (e.clientY < midpoint) {
card.classList.add('drop-before');
} else {
card.classList.add('drop-after');
}
}
});
card.addEventListener('dragleave', () => {
card.classList.remove('drop-before', 'drop-after');
});
card.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
const draggedItemId = e.dataTransfer.getData('itemId');
const targetItemId = card.dataset.itemId;
if (draggedItemId && targetItemId && draggedItemId !== targetItemId) {
const position = card.classList.contains('drop-before') ? 'before' : 'after';
this.reorderItemInCategory(draggedItemId, targetItemId, position);
}
card.classList.remove('drop-before', 'drop-after');
});
});
// Modifier drag handlers
document.querySelectorAll('.item-card.modifier').forEach(card => {
card.addEventListener('dragstart', (e) => {
e.stopPropagation();
e.dataTransfer.setData('modifierId', card.dataset.modifierId);
e.dataTransfer.setData('parentId', card.dataset.parentId);
e.dataTransfer.setData('depth', card.dataset.depth);
e.dataTransfer.setData('source', 'modifier');
// Default is copy, hold Shift for move
const isMove = e.shiftKey;
e.dataTransfer.setData('isMove', isMove ? 'true' : 'false');
e.dataTransfer.effectAllowed = isMove ? 'move' : 'copy';
card.classList.add('dragging');
});
card.addEventListener('dragend', () => {
card.classList.remove('dragging');
document.querySelectorAll('.drop-before, .drop-after, .drag-over').forEach(el => {
el.classList.remove('drop-before', 'drop-after', 'drag-over');
});
});
card.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
const isDraggingModifier = e.dataTransfer.types.includes('modifierid');
if (isDraggingModifier && !card.classList.contains('dragging')) {
const rect = card.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2;
card.classList.remove('drop-before', 'drop-after');
if (e.clientY < midpoint) {
card.classList.add('drop-before');
} else {
card.classList.add('drop-after');
}
}
});
card.addEventListener('dragleave', (e) => {
card.classList.remove('drop-before', 'drop-after');
});
card.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
const draggedModId = e.dataTransfer.getData('modifierId');
const draggedParentId = e.dataTransfer.getData('parentId');
const targetModId = card.dataset.modifierId;
const targetParentId = card.dataset.parentId;
const isMove = e.dataTransfer.getData('isMove') === 'true';
if (draggedModId && targetModId && draggedModId !== targetModId) {
const position = card.classList.contains('drop-before') ? 'before' : 'after';
if (isMove && draggedParentId === targetParentId) {
// Same parent + move = reorder
this.reorderModifier(draggedModId, targetModId, targetParentId, position);
} else if (isMove) {
// Different parent + move = move modifier
this.moveModifier(draggedModId, draggedParentId, targetModId, targetParentId, position);
} else {
// Copy (default)
this.copyModifier(draggedModId, draggedParentId, targetModId, targetParentId, position);
}
}
card.classList.remove('drop-before', 'drop-after');
});
});
// Allow dropping modifiers onto items (to add as top-level modifier)
document.querySelectorAll('.item-card:not(.modifier)').forEach(card => {
card.addEventListener('dragover', (e) => {
const isDraggingModifier = e.dataTransfer.types.includes('modifierid');
if (isDraggingModifier) {
e.preventDefault();
e.stopPropagation();
card.classList.add('drag-over');
}
});
card.addEventListener('dragleave', (e) => {
if (e.dataTransfer.types.includes('modifierid')) {
card.classList.remove('drag-over');
}
});
card.addEventListener('drop', (e) => {
const isDraggingModifier = e.dataTransfer.types.includes('modifierid');
if (isDraggingModifier) {
e.preventDefault();
e.stopPropagation();
const draggedModId = e.dataTransfer.getData('modifierId');
const draggedParentId = e.dataTransfer.getData('parentId');
const targetItemId = card.dataset.itemId;
const isMove = e.dataTransfer.getData('isMove') === 'true';
if (draggedModId && targetItemId) {
if (isMove) {
this.moveModifierToItem(draggedModId, draggedParentId, targetItemId);
} else {
this.copyModifierToItem(draggedModId, draggedParentId, targetItemId);
}
}
card.classList.remove('drag-over');
}
});
});
},
// Toggle category expanded/collapsed (accordion behavior)
toggleCategory(categoryId) {
if (this.expandedCategoryId === categoryId) {
this.expandedCategoryId = null;
} else {
this.expandedCategoryId = categoryId;
this.expandedItemId = null;
this.expandedModifierIds.clear();
}
this.render();
},
// Toggle item expanded/collapsed
toggleItem(itemId) {
if (this.expandedItemId === itemId) {
this.expandedItemId = null;
} else {
this.expandedItemId = itemId;
this.expandedModifierIds.clear();
}
this.render();
},
// Toggle modifier expanded/collapsed
toggleModifier(modifierId) {
if (this.expandedModifierIds.has(modifierId)) {
this.expandedModifierIds.delete(modifierId);
} else {
this.expandedModifierIds.add(modifierId);
}
this.render();
},
// 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>