This repository has been archived on 2026-03-21. You can view files and clone it, but cannot push or open issues or pull requests.
payfrit-biz/portal/menu-builder.html
John Mizerek 4a13bd7583 Remove Inverted Display toggle; fix template modifier name not saving
- Removed IsInvertedGroup toggle and badge from menu builder UI
- Fixed saveFromBuilder.cfm not updating Name for template modifiers
  (only selection rules were saved, name changes were silently lost)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:40:42 -07:00

4770 lines
180 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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/png" sizes="512x512" href="favicon-512.png">
<link rel="stylesheet" href="portal.css">
<style>
/* Menu Builder Specific Styles */
.main-content {
height: 100vh;
max-height: 100vh;
overflow: hidden;
}
.builder-wrapper {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.builder-container {
display: flex;
flex: 1;
min-height: 0;
gap: 16px;
padding: 16px;
overflow: hidden;
}
/* Sidebar Panel */
.builder-sidebar {
width: 280px;
background: #fff;
border-radius: var(--radius);
box-shadow: var(--shadow);
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(--gray-500);
}
/* Palette Items */
.palette-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.palette-item {
background: var(--gray-50);
border: 2px dashed var(--gray-200);
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(99, 102, 241, 0.05);
}
.palette-item:active {
cursor: grabbing;
}
.palette-item .icon {
width: 32px;
height: 32px;
background: #fff;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.palette-item .label {
font-weight: 500;
color: var(--gray-800);
}
.palette-item .hint {
font-size: 12px;
color: var(--gray-500);
}
/* Main Canvas */
.builder-canvas {
flex: 1;
background: #fff;
border-radius: var(--radius);
box-shadow: var(--shadow);
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(--gray-200);
}
.canvas-header h2 {
margin: 0;
display: flex;
align-items: center;
gap: 8px;
color: var(--gray-900);
}
.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(--gray-200);
border-radius: 8px;
padding: 24px;
text-align: center;
color: var(--gray-500);
transition: all 0.2s;
}
.drop-zone.drag-over {
border-color: var(--primary);
background: rgba(99, 102, 241, 0.05);
}
.drop-zone.drag-over-error {
border-color: var(--danger);
background: rgba(239, 68, 68, 0.05);
}
/* Category Card */
.category-card {
background: var(--brand-tint, var(--gray-50));
border-radius: 12px;
border: 2px solid var(--gray-200);
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(--brand-tint, #fff);
cursor: grab;
gap: 12px;
}
.category-header:active {
cursor: grabbing;
}
.drag-handle {
color: var(--gray-400);
display: flex;
align-items: center;
}
.category-info {
flex: 1;
}
.category-name {
font-weight: 600;
font-size: 16px;
color: var(--gray-900);
}
.category-count {
font-size: 12px;
color: var(--gray-500);
}
.category-actions {
display: flex;
gap: 4px;
}
.category-actions button {
background: transparent;
border: none;
color: var(--gray-400);
padding: 6px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.category-actions button:hover {
background: var(--gray-100);
color: var(--gray-800);
}
.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(--gray-400);
}
.category-toggle:hover {
color: var(--gray-800);
}
.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(--gray-400);
flex-shrink: 0;
}
.item-toggle:hover {
color: var(--gray-800);
}
.item-toggle.expanded {
transform: rotate(90deg);
}
.items-list.drag-over {
background: rgba(99, 102, 241, 0.02);
}
.item-drop-zone {
border: 2px dashed var(--gray-200);
border-radius: 6px;
padding: 16px;
text-align: center;
font-size: 13px;
color: var(--gray-500);
}
/* Item Card */
.item-card {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: var(--brand-tint, #fff);
border-radius: 8px;
border: 1px solid var(--gray-200);
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(--gray-50);
border-style: dashed;
}
.item-card.modifier .item-type-badge {
background: var(--warning);
color: #000;
}
.item-image {
width: 48px;
height: 48px;
background: var(--gray-100);
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;
color: var(--gray-800);
}
.item-meta {
display: flex;
gap: 8px;
font-size: 12px;
color: var(--gray-500);
}
.item-price {
color: var(--primary);
font-weight: 600;
}
.item-type-badge {
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
background: var(--gray-100);
text-transform: uppercase;
color: var(--gray-600);
}
.item-actions {
display: flex;
gap: 2px;
}
.item-actions button {
background: transparent;
border: none;
color: var(--gray-400);
padding: 4px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.item-actions button:hover {
background: var(--gray-100);
color: var(--gray-800);
}
.item-actions button.danger:hover {
color: var(--danger);
}
/* Properties Panel */
.builder-properties {
width: 320px;
min-height: 0;
background: #fff;
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
}
.properties-header {
position: sticky;
top: -16px;
margin: -16px -16px 0 -16px;
padding: 16px;
background: #fff;
z-index: 1;
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(--gray-500);
}
.property-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.property-group label {
font-size: 12px;
font-weight: 500;
color: var(--gray-500);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.property-group input,
.property-group textarea,
.property-group select {
background: var(--gray-50);
border: 1px solid var(--gray-300);
border-radius: 6px;
padding: 10px 12px;
color: var(--gray-800);
font-size: 14px;
}
.property-group input:focus,
.property-group textarea:focus,
.property-group select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.property-row {
display: flex;
gap: 12px;
}
.property-row .property-group {
flex: 1;
}
/* Photo Task Section */
.photo-task-section {
background: var(--gray-50);
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;
color: var(--gray-800);
}
.photo-preview {
width: 100%;
aspect-ratio: 4/3;
background: #fff;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
color: var(--gray-400);
margin-bottom: 12px;
overflow: hidden;
border: 1px solid var(--gray-200);
}
.photo-preview img {
width: 100%;
height: 100%;
object-fit: cover;
cursor: pointer;
}
.photo-preview img:hover {
opacity: 0.9;
}
/* Lightbox for full-size images */
.lightbox-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
padding: 20px;
}
.lightbox-content {
position: relative;
max-width: 90vw;
max-height: 90vh;
}
.lightbox-content img {
max-width: 100%;
max-height: 90vh;
object-fit: contain;
border-radius: 8px;
}
.lightbox-close {
position: absolute;
top: -40px;
right: 0;
background: transparent;
border: none;
color: white;
font-size: 32px;
cursor: pointer;
padding: 0;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.lightbox-close:hover {
color: var(--primary);
}
.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(--gray-500);
text-align: center;
}
.empty-canvas svg {
width: 64px;
height: 64px;
margin-bottom: 16px;
opacity: 0.5;
color: var(--gray-400);
}
.empty-canvas h3 {
margin: 0 0 8px 0;
color: var(--gray-800);
}
.empty-canvas p {
margin: 0;
max-width: 300px;
color: var(--gray-500);
}
/* Toolbar */
.builder-toolbar {
display: flex;
gap: 8px;
padding: 12px 24px;
background: #fff;
border-bottom: 1px solid var(--gray-200);
}
.toolbar-group {
display: flex;
gap: 4px;
padding-right: 12px;
border-right: 1px solid var(--gray-200);
}
.toolbar-group:last-child {
border-right: none;
}
.toolbar-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: var(--gray-50);
border: 1px solid var(--gray-200);
border-radius: var(--radius);
color: var(--gray-700);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.toolbar-btn:hover {
background: #fff;
border-color: var(--primary);
color: var(--gray-900);
}
.toolbar-btn.primary {
background: var(--primary);
border-color: var(--primary);
color: #fff;
}
.toolbar-btn.primary:hover {
background: var(--primary-dark);
}
.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(99, 102, 241, 0.2);
}
/* Context Menu */
.context-menu {
position: fixed;
background: #fff;
border: 1px solid var(--gray-200);
border-radius: var(--radius);
padding: 8px 0;
min-width: 180px;
box-shadow: var(--shadow-lg);
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;
color: var(--gray-700);
}
.context-menu-item:hover {
background: var(--gray-50);
}
.context-menu-item.danger {
color: var(--danger);
}
.context-menu-item svg {
width: 16px;
height: 16px;
}
.context-menu-divider {
height: 1px;
background: var(--gray-200);
margin: 8px 0;
}
/* Keyboard shortcuts hint */
.shortcut-hint {
font-size: 11px;
color: var(--gray-400);
margin-left: auto;
font-family: monospace;
}
</style>
</head>
<body>
<div class="app">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="business-info" style="padding: 12px 16px; border-bottom: 1px solid var(--gray-200); display: flex; align-items: center; gap: 12px;">
<div class="business-avatar" id="businessAvatar">B</div>
<div class="business-details" style="flex: 1;">
<div class="business-name" id="businessName">Loading...</div>
<div class="business-status online">Online</div>
</div>
<button class="sidebar-toggle" id="sidebarToggle" style="background: none; border: none; cursor: pointer; padding: 4px;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12h18M3 6h18M3 18h18"/>
</svg>
</button>
</div>
<nav class="sidebar-nav">
<a href="index.html#dashboard" class="nav-item">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>
</svg>
<span>Dashboard</span>
</a>
<a href="index.html#orders" class="nav-item">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"/>
<rect x="9" y="3" width="6" height="4" rx="1"/>
<path d="M9 12h6M9 16h6"/>
</svg>
<span>Orders</span>
</a>
<a href="#" class="nav-item active">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M3 12h18M3 18h18"/>
</svg>
<span>Menu</span>
</a>
<a href="station-assignment.html" class="nav-item">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="18" rx="2"/>
<path d="M2 9h20M9 21V9"/>
</svg>
<span>Stations</span>
</a>
<a href="index.html#reports" class="nav-item">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 20V10M12 20V4M6 20v-6"/>
</svg>
<span>Reports</span>
</a>
<a href="index.html#team" class="nav-item">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/>
</svg>
<span>Team</span>
</a>
<a href="index.html#services" class="nav-item">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 01-3.46 0"/>
</svg>
<span>Services</span>
</a>
<a href="index.html#service-points" class="nav-item">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M12 2v4M12 18v4M2 12h4M18 12h4"/>
<path d="M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
</svg>
<span>Service Points</span>
</a>
<a href="index.html#admin-tasks" class="nav-item">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>Task Admin</span>
</a>
<a href="index.html#settings" class="nav-item">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/>
</svg>
<span>Settings</span>
</a>
</nav>
</aside>
<!-- Main Content -->
<main class="main-content">
<header class="top-bar">
<div class="page-title">
<h1>Menu Builder</h1>
</div>
<div class="top-bar-actions">
<div class="user-menu" style="position: relative;">
<button class="user-btn" id="userBtn" onclick="var dd=document.getElementById('userDropdown'); dd.style.display = dd.style.display === 'none' ? 'block' : 'none';">
<span class="user-avatar" id="userAvatar">B</span>
</button>
<div class="user-dropdown" id="userDropdown" style="display:none; position:absolute; right:0; top:100%; margin-top:8px; background:var(--bg-card); border:1px solid var(--border-color); border-radius:8px; min-width:200px; box-shadow:0 4px 12px rgba(0,0,0,0.3); z-index:1000; overflow:hidden;">
<a href="index.html#settings" style="display:flex; align-items:center; gap:10px; padding:12px 16px; color:var(--text-primary); text-decoration:none; transition:background 0.15s;" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background=''">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
Settings
</a>
<a href="#" onclick="MenuBuilder.switchBusiness(); document.getElementById('userDropdown').classList.remove('show'); return false;" style="display:flex; align-items:center; gap:10px; padding:12px 16px; color:var(--text-primary); text-decoration:none; transition:background 0.15s;" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background=''">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 3h5v5M8 3H3v5M3 16v5h5M21 16v5h-5M7 12h10"/></svg>
Switch Business
</a>
<a href="#" onclick="MenuBuilder.addNewBusiness(); document.getElementById('userDropdown').classList.remove('show'); return false;" style="display:flex; align-items:center; gap:10px; padding:12px 16px; color:var(--text-primary); text-decoration:none; transition:background 0.15s;" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background=''">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
Add New Business
</a>
<div style="border-top: 1px solid var(--border-color);"></div>
<a href="#" onclick="MenuBuilder.logout(); return false;" style="display:flex; align-items:center; gap:10px; padding:12px 16px; color:#ef4444; text-decoration:none; transition:background 0.15s;" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background=''">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9"/></svg>
Logout
</a>
</div>
</div>
</div>
</header>
<div class="builder-wrapper">
<!-- Toolbar -->
<div class="builder-toolbar">
<div class="toolbar-group">
<button class="toolbar-btn" onclick="MenuBuilder.undo()" title="Undo (Ctrl+Z)">
<svg width="16" height="16" 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 width="16" height="16" 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 width="16" height="16" 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 width="16" height="16" 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 width="16" height="16" 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 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>
Delete
</button>
</div>
<div class="toolbar-group">
<button class="toolbar-btn" onclick="MenuBuilder.showOutlineModal()" title="View Full Menu Outline">
<svg width="16" height="16" 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" onclick="MenuBuilder.showMenuManager()" title="Manage Menus" style="font-weight: 600;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
Manage Menus
</button>
<button class="toolbar-btn primary" onclick="MenuBuilder.saveMenu()">
<svg width="16" height="16" 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(--gray-500); 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(--gray-500); 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>
</h2>
<div class="canvas-actions" style="display: flex; gap: 8px; align-items: center;">
<select id="menuSelector" onchange="MenuBuilder.onMenuSelect(this.value)" style="padding: 8px 12px; border: 1px solid var(--gray-300); border-radius: 6px; background: #fff; font-size: 14px; cursor: pointer;">
<option value="0">All Categories</option>
</select>
</div>
</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(--gray-500); text-align: center; padding: 20px;">
Select an item to edit its properties
</p>
</div>
</div>
</div>
</div> <!-- end builder-wrapper -->
</main>
</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: []
},
menus: [], // List of all menus for this business
selectedMenuId: 0, // Currently selected menu ID (0 = all/default)
templates: [],
stations: [],
selectedElement: null,
selectedData: null,
undoStack: [],
redoStack: [],
idCounter: 1,
expandedCategoryId: null, // For accordion - only one category expanded at a time
expandedSubCategoryId: null, // For subcategory accordion - independent of parent
expandedItemId: null, // For item accordion - only one item expanded at a time
expandedModifierIds: new Set(), // Track which modifiers are expanded
// Get thumbnail URLs from base image URL
// Thumbnails are always .jpg regardless of original format
// E.g., /uploads/items/123.png -> { thumb: /uploads/items/123_thumb.jpg, medium: /uploads/items/123_medium.jpg, full: /uploads/items/123.png }
getImageUrls(baseUrl) {
if (!baseUrl) return { thumb: null, medium: null, full: null };
// Strip query string for manipulation
const qIdx = baseUrl.indexOf('?');
const query = qIdx > -1 ? baseUrl.substring(qIdx) : '';
const cleanUrl = qIdx > -1 ? baseUrl.substring(0, qIdx) : baseUrl;
// Find extension
const dotIdx = cleanUrl.lastIndexOf('.');
if (dotIdx === -1) return { thumb: baseUrl, medium: baseUrl, full: baseUrl };
const base = cleanUrl.substring(0, dotIdx);
// Thumbnails are always saved as .jpg by the server
return {
thumb: base + '_thumb.jpg' + query,
medium: base + '_medium.jpg' + query,
full: cleanUrl + query
};
},
// Show full-size image in lightbox
showLightbox(imageUrl) {
if (!imageUrl) return;
const overlay = document.createElement('div');
overlay.className = 'lightbox-overlay';
overlay.innerHTML = `
<div class="lightbox-content">
<img src="${imageUrl}" alt="Full size photo">
<button class="lightbox-close">&times;</button>
</div>
`;
overlay.onclick = (e) => {
if (e.target === overlay || e.target.classList.contains('lightbox-close')) {
overlay.remove();
}
};
document.body.appendChild(overlay);
},
// Initialize
async init() {
console.log('[MenuBuilder] Initializing...');
// Check authentication
const token = localStorage.getItem('payfrit_portal_token');
const savedBusiness = localStorage.getItem('payfrit_portal_business');
if (!token || !savedBusiness) {
window.location.href = BASE_PATH + '/portal/signup.html';
return;
}
// Get business ID from localStorage
this.config.businessId = parseInt(savedBusiness) || null;
this.config.token = token;
// Setup sidebar toggle
this.setupSidebar();
// 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');
},
// Setup sidebar toggle
setupSidebar() {
const sidebar = document.getElementById('sidebar');
const toggle = document.getElementById('sidebarToggle');
if (toggle && sidebar) {
toggle.addEventListener('click', () => {
sidebar.classList.toggle('collapsed');
});
}
},
// Logout
logout() {
localStorage.removeItem('payfrit_portal_token');
localStorage.removeItem('payfrit_portal_business');
window.location.href = BASE_PATH + '/portal/signup.html';
},
// Switch to a different business
switchBusiness() {
localStorage.removeItem('payfrit_portal_business');
window.location.href = BASE_PATH + '/portal/index.html';
},
// Add a new business
addNewBusiness() {
window.location.href = BASE_PATH + '/portal/setup-wizard.html';
},
// 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) {
const biz = data.BUSINESS;
const initial = biz.Name ? biz.Name.charAt(0).toUpperCase() : 'B';
// Update sidebar business info
const businessName = document.getElementById('businessName');
const businessAvatar = document.getElementById('businessAvatar');
if (businessName) businessName.textContent = biz.Name;
if (businessAvatar) businessAvatar.textContent = initial;
// Update top bar user avatar
const userAvatar = document.getElementById('userAvatar');
if (userAvatar) userAvatar.textContent = initial;
}
} 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', (e) => {
this.hideContextMenu();
// Close user dropdown if clicking outside
const userDropdown = document.getElementById('userDropdown');
const userBtn = document.getElementById('userBtn');
if (userDropdown && !userDropdown.contains(e.target) && !userBtn.contains(e.target)) {
userDropdown.classList.remove('show');
userDropdown.style.display = 'none';
}
});
},
// 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 (or subcategory if parentCategoryId is provided)
addCategory(name = null, parentCategoryId = null) {
this.saveState();
const category = {
id: this.generateId(),
name: name || `New Category ${this.menu.categories.length + 1}`,
description: '',
parentCategoryId: parentCategoryId || 0,
parentCategoryDbId: parentCategoryId ? (this.menu.categories.find(c => c.id === parentCategoryId)?.dbId || 0) : 0,
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(--gray-500); text-align: center; padding: 20px;">
Select an item to edit its properties
</p>
`;
},
// Show properties for category
showPropertiesForCategory(category) {
const menuOptions = this.menus.map(m =>
`<option value="${m.MenuID}" ${category.menuId === m.MenuID ? 'selected' : ''}>${this.escapeHtml(m.MenuName)}</option>`
).join('');
// Build parent category options (only top-level categories, exclude self and existing subcategories)
const isSubcategory = category.parentCategoryId && category.parentCategoryId !== 0;
const hasSubcategories = this.menu.categories.some(c =>
c.parentCategoryId === category.id || c.parentCategoryId === category.dbId
);
const parentOptions = this.menu.categories
.filter(c => {
// Must be top-level (not a subcategory itself)
if (c.parentCategoryId && c.parentCategoryId !== 0) return false;
// Can't be self
if (c.id === category.id) return false;
return true;
})
.map(c => {
const isSelected = (category.parentCategoryId === c.id || category.parentCategoryDbId === c.dbId);
return `<option value="${c.id}" data-dbid="${c.dbId || 0}" ${isSelected ? 'selected' : ''}>${this.escapeHtml(c.name)}</option>`;
}).join('');
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>
${!hasSubcategories ? `
<div class="property-group">
<label>Parent Category</label>
<select onchange="MenuBuilder.setParentCategory('${category.id}', this)">
<option value="0" ${!category.parentCategoryId || category.parentCategoryId === 0 ? 'selected' : ''}>None (top-level)</option>
${parentOptions}
</select>
</div>
` : `
<div class="property-group">
<label>Parent Category</label>
<p style="color: var(--gray-500); font-size: 13px; margin: 4px 0 0;">This category has subcategories, so it must remain top-level.</p>
</div>
`}
${this.menus.length > 0 ? `
<div class="property-group">
<label>Assign to Menu</label>
<select onchange="MenuBuilder.updateCategory('${category.id}', 'menuId', parseInt(this.value))">
<option value="0" ${!category.menuId ? 'selected' : ''}>No Menu (Always Show)</option>
${menuOptions}
</select>
</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 => {
const isSubcat = c.parentCategoryId && c.parentCategoryId !== 0;
const prefix = isSubcat ? '\u00A0\u00A0\u2514 ' : '';
return `<option value="${c.id}" ${c.id === category.id ? 'selected' : ''}>${prefix}${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 ? `onclick="event.preventDefault(); MenuBuilder.showLightbox('${item.imageUrl}')"` : ''}>
${item.imageUrl ?
`<img src="${this.getImageUrls(item.imageUrl).medium}" alt="${this.escapeHtml(item.name)}" onerror="this.onerror=null; this.src='${item.imageUrl}'" title="Tap to view full size">` :
`<span>No photo yet</span>`}
</div>
<div class="photo-actions">
<button class="btn btn-secondary" onclick="MenuBuilder.showUploadModal('${item.id}', '${item.dbId || ''}')">
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(--gray-500); 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(--gray-500); 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(--gray-500); 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(--gray-500); 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(--gray-500); font-size: 12px; margin: 0 0 8px;">
Parent: <strong>${this.escapeHtml(parent.name)}</strong>
</p>
<p style="color: var(--gray-500); 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,
isInverted: obj.isInverted || false,
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();
}
},
// Set parent category (handles both id and dbId tracking)
setParentCategory(categoryId, selectEl) {
this.saveState();
const category = this.menu.categories.find(c => c.id === categoryId);
if (!category) return;
const selectedValue = selectEl.value;
if (selectedValue === '0' || !selectedValue) {
category.parentCategoryId = 0;
category.parentCategoryDbId = 0;
} else {
const parentCat = this.menu.categories.find(c => c.id === selectedValue);
if (parentCat) {
category.parentCategoryId = parentCat.id;
category.parentCategoryDbId = parentCat.dbId || 0;
}
}
this.render();
this.showPropertiesForCategory(category);
},
// 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(--gray-500);">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');
}
},
// Show upload modal - works better on mobile
showUploadModal(itemId, dbId) {
if (!dbId) {
alert('Please save the menu first before uploading photos.');
return;
}
document.getElementById('modalTitle').textContent = 'Upload Item Photo';
document.getElementById('modalBody').innerHTML = `
<form id="photoUploadForm" enctype="multipart/form-data" style="text-align: center;">
<input type="hidden" name="ItemID" value="${dbId}">
<input type="hidden" id="uploadItemId" value="${itemId}">
<p style="color: var(--gray-600); margin-bottom: 16px;">
Select or take a photo for this item
</p>
<div style="margin-bottom: 16px;">
<input type="file" name="photo" id="modalPhotoInput" accept="image/*"
style="font-size: 16px; padding: 12px; border: 2px dashed var(--gray-300);
border-radius: 8px; width: 100%; cursor: pointer;">
</div>
<div id="uploadPreview" style="margin-bottom: 16px; display: none;">
<img id="previewImg" style="max-width: 100%; max-height: 200px; border-radius: 8px;">
</div>
<button type="submit" class="btn btn-primary" id="uploadSubmitBtn" disabled
style="width: 100%; padding: 12px;">
Upload Photo
</button>
</form>
`;
this.showModal();
const fileInput = document.getElementById('modalPhotoInput');
const submitBtn = document.getElementById('uploadSubmitBtn');
const preview = document.getElementById('uploadPreview');
const previewImg = document.getElementById('previewImg');
const form = document.getElementById('photoUploadForm');
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
submitBtn.disabled = false;
// Show preview
const reader = new FileReader();
reader.onload = (ev) => {
previewImg.src = ev.target.result;
preview.style.display = 'block';
};
reader.readAsDataURL(file);
}
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
const file = fileInput.files[0];
if (!file) return;
submitBtn.disabled = true;
submitBtn.textContent = 'Uploading...';
try {
const formData = new FormData();
formData.append('photo', file);
formData.append('ItemID', dbId);
const response = await fetch(`${this.config.apiBaseUrl}/menu/uploadItemPhoto.cfm`, {
method: 'POST',
body: formData,
credentials: 'include'
});
const data = await response.json();
if (data.OK) {
// Update the item
const itemIdVal = document.getElementById('uploadItemId').value;
for (const cat of this.menu.categories) {
const item = cat.items.find(i => i.id === itemIdVal);
if (item) {
item.imageUrl = data.IMAGEURL + '?t=' + Date.now();
break;
}
}
this.closeModal();
this.render();
this.toast('Photo uploaded!', 'success');
} else {
alert('Upload failed: ' + (data.MESSAGE || data.ERROR || 'Unknown error'));
submitBtn.disabled = false;
submitBtn.textContent = 'Upload Photo';
}
} catch (err) {
console.error('Upload error:', err);
alert('Upload failed: ' + err.message);
submitBtn.disabled = false;
submitBtn.textContent = 'Upload Photo';
}
});
},
// Pending photo upload info (for mobile camera flow)
_pendingPhotoUpload: null,
// Upload photo - legacy version (kept for compatibility)
uploadPhoto(itemId) {
// Find the item and check it has a database ID
let targetItem = null;
for (const cat of this.menu.categories) {
const item = cat.items.find(i => i.id === itemId);
if (item) { targetItem = item; break; }
}
if (!targetItem || !targetItem.dbId) {
this.toast('Please save the menu first before uploading photos', 'error');
return;
}
// Store pending upload info (survives page context issues on mobile)
this._pendingPhotoUpload = {
itemId: itemId,
dbId: targetItem.dbId
};
sessionStorage.setItem('pendingPhotoUpload', JSON.stringify(this._pendingPhotoUpload));
// Get or create persistent file input (mobile browsers handle these better)
let input = document.getElementById('photoUploadInput');
if (!input) {
input = document.createElement('input');
input.type = 'file';
input.id = 'photoUploadInput';
input.accept = 'image/*'; // Simpler accept for better mobile compatibility
input.style.display = 'none';
document.body.appendChild(input);
input.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
// Get pending upload info
const pending = this._pendingPhotoUpload || JSON.parse(sessionStorage.getItem('pendingPhotoUpload') || 'null');
if (!pending) {
this.toast('Upload context lost - please try again', 'error');
return;
}
if (file.size > 5 * 1024 * 1024) {
this.toast('Image must be under 5MB', 'error');
return;
}
this.toast('Uploading photo...', 'info');
try {
const formData = new FormData();
formData.append('photo', file);
formData.append('ItemID', pending.dbId);
const response = await fetch(`${this.config.apiBaseUrl}/menu/uploadItemPhoto.cfm`, {
method: 'POST',
body: formData,
credentials: 'include'
});
const data = await response.json();
if (data.OK) {
// Find and update the item
for (const cat of this.menu.categories) {
const item = cat.items.find(i => i.id === pending.itemId);
if (item) {
item.imageUrl = data.IMAGEURL + '?t=' + Date.now();
break;
}
}
this.render();
this.toast('Photo uploaded!', 'success');
} else {
this.toast(data.MESSAGE || 'Failed to upload photo', 'error');
}
} catch (err) {
console.error('Photo upload error:', err);
this.toast('Failed to upload photo', 'error');
}
// Clean up
sessionStorage.removeItem('pendingPhotoUpload');
this._pendingPhotoUpload = null;
input.value = ''; // Reset for next upload
});
}
// Reset and trigger
input.value = '';
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';
this._outlineShowMods = false;
const buildOutline = () => {
const categories = this.menu.categories || [];
if (categories.length === 0)
return '<div style="color: var(--gray-500); text-align: center; padding: 40px;">No menu items yet</div>';
let outline = '<div class="menu-outline">';
const topLevel = categories.filter(c => !c.parentCategoryId || c.parentCategoryId === 0);
const getSubcats = (parent) => categories.filter(c =>
c.parentCategoryId === parent.id || (parent.dbId && c.parentCategoryDbId === parent.dbId)
);
const renderOutlineItems = (items, indentLevel) => {
let html = '';
for (const item of (items || [])) {
const itemPrice = item.price ? `$${parseFloat(item.price).toFixed(2)}` : '';
html += `<div class="outline-item" style="padding-left: ${indentLevel * 20}px;">${this.escapeHtml(item.name)}${itemPrice ? ' <span class="outline-price">' + itemPrice + '</span>' : ''}</div>`;
if (this._outlineShowMods) {
html += this.renderOutlineModifierGroups(item.modifiers || [], indentLevel + 1);
}
}
return html;
};
for (const cat of topLevel) {
outline += `<div class="outline-category">${this.escapeHtml(cat.name)}</div>`;
outline += renderOutlineItems(cat.items, 1);
const subcats = getSubcats(cat);
for (const subcat of subcats) {
outline += `<div class="outline-subcategory">${this.escapeHtml(subcat.name)}</div>`;
outline += renderOutlineItems(subcat.items, 2);
}
}
outline += '</div>';
return outline;
};
const render = () => {
const btnLabel = this._outlineShowMods ? 'Hide Modifiers' : 'Show Modifiers';
document.getElementById('modalBody').innerHTML = `
<div style="margin-bottom: 12px;">
<button class="outline-mods-btn" onclick="MenuBuilder.toggleOutlineMods()">${btnLabel}</button>
</div>
${buildOutline()}
`;
};
// Inject styles once
document.getElementById('modalBody').innerHTML = '';
if (!document.getElementById('outlineStyles')) {
const style = document.createElement('style');
style.id = 'outlineStyles';
style.textContent = `
.menu-outline {
font-family: monospace;
font-size: 13px;
line-height: 1.6;
max-height: 55vh;
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-subcategory {
font-weight: 600;
font-size: 13px;
color: var(--text-secondary);
margin-top: 8px;
margin-bottom: 2px;
padding-left: 20px;
}
.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); }
.outline-mods-btn {
padding: 6px 14px;
font-size: 13px;
border: 1px solid var(--border-color, #ccc);
border-radius: 6px;
background: var(--bg-primary, #fff);
color: var(--text-secondary);
cursor: pointer;
}
.outline-mods-btn:hover { background: var(--bg-secondary, #f5f5f5); }
.outline-mod-group {
padding-left: 20px;
color: var(--text-muted);
cursor: pointer;
user-select: none;
}
.outline-mod-group:hover { color: var(--text-secondary); }
.outline-mod-expand { display: inline-block; width: 14px; font-size: 11px; color: var(--text-muted); }
.outline-mod-opts { display: none; }
.outline-mod-opts.open { display: block; }
`;
document.head.appendChild(style);
}
this._outlineRender = render;
render();
this.showModal();
},
toggleOutlineMods() {
this._outlineShowMods = !this._outlineShowMods;
if (this._outlineRender) this._outlineRender();
},
// Render first-level modifier groups with expandable options
renderOutlineModifierGroups(modifiers, depth) {
if (!modifiers || modifiers.length === 0) return '';
let html = '';
const pad = depth * 20;
for (let i = 0; i < modifiers.length; i++) {
const mod = modifiers[i];
const modPrice = mod.price ? `+$${parseFloat(mod.price).toFixed(2)}` : '';
const opts = mod.options || [];
const uid = 'om_' + Math.random().toString(36).substring(2, 8);
const countLabel = opts.length ? ` (${opts.length})` : '';
if (opts.length > 0) {
html += `<div class="outline-mod-group" style="padding-left: ${pad}px;" onclick="var el=document.getElementById('${uid}');el.classList.toggle('open');this.querySelector('.outline-mod-expand').textContent=el.classList.contains('open')?'':'+'">`;
html += `<span class="outline-mod-expand">+</span> ${this.escapeHtml(mod.name)}${modPrice ? ' <span class="outline-price">' + modPrice + '</span>' : ''}${countLabel}`;
html += `</div>`;
html += `<div id="${uid}" class="outline-mod-opts">`;
for (const opt of opts) {
const optPrice = opt.price ? `+$${parseFloat(opt.price).toFixed(2)}` : '';
html += `<div class="outline-modifier" style="padding-left: ${(depth + 1) * 20}px;">${this.escapeHtml(opt.name)}${optPrice ? ' <span class="outline-price">' + optPrice + '</span>' : ''}</div>`;
}
html += `</div>`;
} else {
html += `<div class="outline-modifier" style="padding-left: ${pad}px;">${this.escapeHtml(mod.name)}${modPrice ? ' <span class="outline-price">' + modPrice + '</span>' : ''}</div>`;
}
}
return html;
},
// 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,image/heic,image/heif,.heic,.heif';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
// Validate file type (be permissive for mobile cameras)
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/heic', 'image/heif', ''];
if (file.type && !validTypes.includes(file.type) && !file.type.startsWith('image/')) {
this.toast('Please select a valid image file', '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';
const currentLight = this.brandColorLight || '';
document.getElementById('modalTitle').textContent = 'Brand Colors';
document.getElementById('modalBody').innerHTML = `
<div style="margin-bottom: 20px;">
<label style="display: block; font-weight: 600; margin-bottom: 6px; font-size: 13px;">Brand Color (Dark)</label>
<p style="margin-bottom: 10px; color: var(--text-muted); font-size: 12px;">
Used for category bar gradients in the customer app.
</p>
<div style="display: flex; align-items: center; gap: 16px; margin-bottom: 12px;">
<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">
${'EyeDropper' in window ? '<button type="button" id="eyedropperDark" style="width:40px;height:40px;border:1px solid var(--border);border-radius:4px;cursor:pointer;background:var(--bg);display:flex;align-items:center;justify-content:center;font-size:18px;" title="Pick from screen">&#128065;</button>' : ''}
<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;">
<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"></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>
<div style="border-top: 1px solid var(--border); padding-top: 16px; margin-bottom: 20px;">
<label style="display: block; font-weight: 600; margin-bottom: 6px; font-size: 13px;">Brand Color (Light)</label>
<p style="margin-bottom: 10px; color: var(--text-muted); font-size: 12px;">
Subtle tint for menu builder cards and backgrounds. Leave empty for white.
</p>
<div style="display: flex; align-items: center; gap: 16px;">
<input type="color" id="brandColorLightInput" value="${currentLight || '#F5F5F5'}"
style="width: 60px; height: 40px; border: none; cursor: pointer; border-radius: 4px;">
<input type="text" id="brandColorLightHex" value="${currentLight}"
style="width: 100px; padding: 8px; border: 1px solid var(--border); border-radius: 4px; font-family: monospace;"
placeholder="#F5F5F5" maxlength="7">
${'EyeDropper' in window ? '<button type="button" id="eyedropperLight" style="width:40px;height:40px;border:1px solid var(--border);border-radius:4px;cursor:pointer;background:var(--bg);display:flex;align-items:center;justify-content:center;font-size:18px;" title="Pick from screen">&#128065;</button>' : ''}
<div id="brandColorLightPreview" style="flex: 1; height: 40px; border-radius: 4px; border: 1px solid var(--border); background: ${currentLight || '#fff'};"></div>
<button type="button" class="btn btn-secondary" style="font-size: 12px; padding: 6px 10px;" onclick="document.getElementById('brandColorLightHex').value='';document.getElementById('brandColorLightPreview').style.background='#fff';">Clear</button>
</div>
</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 Colors</button>
</div>
`;
this.showModal();
// Wire up dark 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 (dark only)
document.querySelectorAll('.color-preset').forEach(btn => {
btn.addEventListener('click', () => {
const color = btn.dataset.color;
colorInput.value = color;
hexInput.value = color;
updatePreview(color);
});
});
// Wire up light color picker sync
const lightInput = document.getElementById('brandColorLightInput');
const lightHex = document.getElementById('brandColorLightHex');
const lightPreview = document.getElementById('brandColorLightPreview');
lightInput.addEventListener('input', (e) => {
lightHex.value = e.target.value.toUpperCase();
lightPreview.style.background = e.target.value;
});
lightHex.addEventListener('input', (e) => {
let val = e.target.value;
if (!val.startsWith('#')) val = '#' + val;
if (/^#[0-9A-Fa-f]{6}$/.test(val)) {
lightInput.value = val;
lightPreview.style.background = val;
}
});
// Eyedropper buttons (Chrome/Edge only)
if ('EyeDropper' in window) {
const pickColor = async (colorInput, hexInput, onPick) => {
try {
const dropper = new EyeDropper();
const result = await dropper.open();
const color = result.sRGBHex.toUpperCase();
colorInput.value = color;
hexInput.value = color;
if (onPick) onPick(color);
} catch (e) { /* user cancelled */ }
};
document.getElementById('eyedropperDark')?.addEventListener('click', () => {
pickColor(colorInput, hexInput, updatePreview);
});
document.getElementById('eyedropperLight')?.addEventListener('click', () => {
pickColor(lightInput, lightHex, (c) => { lightPreview.style.background = c; });
});
}
},
// Save brand color
async saveBrandColor() {
const color = document.getElementById('brandColorHex').value.toUpperCase();
if (!/^#[0-9A-Fa-f]{6}$/.test(color)) {
this.toast('Invalid dark color format. Use #RRGGBB', 'error');
return;
}
const lightColor = (document.getElementById('brandColorLightHex')?.value || '').toUpperCase();
if (lightColor && !/^#[0-9A-Fa-f]{6}$/.test(lightColor)) {
this.toast('Invalid light color format. Use #RRGGBB or leave empty', '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,
BrandColorLight: lightColor
})
});
const data = await response.json();
if (data.OK) {
this.brandColor = color;
this.brandColorLight = data.BRANDCOLORLIGHT ? '#' + data.BRANDCOLORLIGHT : '';
document.getElementById('brandColorSwatch').style.background = color;
this.applyBrandTint();
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');
}
},
applyBrandTint() {
const canvas = document.getElementById('menuCanvas');
if (!canvas) return;
if (this.brandColorLight && this.brandColorLight.length >= 4) {
canvas.style.setProperty('--brand-tint', this.brandColorLight + '0A');
} else {
canvas.style.setProperty('--brand-tint', 'transparent');
}
},
// Load menu from API
async loadMenu(menuId = null) {
try {
// Use provided menuId or current selected
const loadMenuId = menuId !== null ? menuId : this.selectedMenuId;
console.log('[MenuBuilder] Loading menu for BusinessID:', this.config.businessId, 'MenuID:', loadMenuId);
const payload = { BusinessID: this.config.businessId };
if (loadMenuId > 0) {
payload.MenuID = loadMenuId;
}
const response = await fetch(`${this.config.apiBaseUrl}/menu/getForBuilder.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
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 menus list and update selector
this.menus = data.MENUS || [];
this.selectedMenuId = data.SELECTED_MENU_ID || 0;
this.defaultMenuId = data.DEFAULT_MENU_ID || 0;
this.updateMenuSelector();
// Store templates from API (default to empty array if not provided)
this.templates = data.TEMPLATES || [];
// Load brand colors
if (data.BRANDCOLOR && data.BRANDCOLOR.length > 0) {
this.brandColor = data.BRANDCOLOR;
const swatch = document.getElementById('brandColorSwatch');
if (swatch) swatch.style.background = data.BRANDCOLOR;
}
this.brandColorLight = data.BRANDCOLORLIGHT || '';
this.applyBrandTint();
this.renderTemplateLibrary();
this.render();
} else {
console.log('[MenuBuilder] No MENU in response or OK=false');
// Still clear the loading message
this.templates = [];
this.menus = data.MENUS || [];
this.defaultMenuId = data.DEFAULT_MENU_ID || 0;
this.updateMenuSelector();
this.renderTemplateLibrary();
}
} catch (err) {
console.error('[MenuBuilder] Error loading menu:', err);
// Clear loading message on error
this.templates = [];
this.renderTemplateLibrary();
}
},
// Update menu selector dropdown
updateMenuSelector() {
const selector = document.getElementById('menuSelector');
if (!selector) return;
let options = '<option value="0">All Menus</option>';
for (const menu of this.menus) {
const selected = menu.MenuID === this.selectedMenuId ? 'selected' : '';
options += `<option value="${menu.MenuID}" ${selected}>${this.escapeHtml(menu.MenuName)}</option>`;
}
selector.innerHTML = options;
},
// Handle menu selection change
async onMenuSelect(menuId) {
this.selectedMenuId = parseInt(menuId) || 0;
await this.loadMenu(this.selectedMenuId);
},
// Show menu manager modal
showMenuManager() {
const content = `
<div style="display: flex; flex-direction: column; gap: 16px;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h4 style="margin: 0;">Your Menus</h4>
<button class="btn btn-sm btn-primary" onclick="MenuBuilder.showCreateMenuForm()">+ Add Menu</button>
</div>
<div id="menuManagerList" style="display: flex; flex-direction: column; gap: 8px;">
${this.menus.length === 0 ? '<p style="color: var(--gray-500); text-align: center;">No menus created yet. Click "Add Menu" to create one.</p>' : ''}
${this.menus.map(menu => `
<div style="display: flex; align-items: center; gap: 12px; padding: 12px; background: var(--gray-50); border-radius: 8px; ${menu.MenuID === this.defaultMenuId ? 'border: 2px solid var(--primary-color);' : ''}">
<div style="flex: 1;">
<div style="font-weight: 600;">
${this.escapeHtml(menu.MenuName)}
${menu.MenuID === this.defaultMenuId ? '<span style="color: var(--primary-color); font-size: 12px; margin-left: 8px;">★ Default</span>' : ''}
</div>
<div style="font-size: 12px; color: var(--gray-500);">
${menu.MenuStartTime && menu.MenuEndTime ? `${menu.MenuStartTime} - ${menu.MenuEndTime}` : 'All day'}
· ${this.formatDaysActive(menu.MenuDaysActive)}
</div>
</div>
${menu.MenuID !== this.defaultMenuId ? `<button class="btn btn-sm" onclick="MenuBuilder.setDefaultMenu(${menu.MenuID})" title="Set as default menu">Set Default</button>` : ''}
<button class="btn btn-sm btn-secondary" onclick="MenuBuilder.editMenu(${menu.MenuID})">Edit</button>
<button class="btn btn-sm btn-danger" onclick="MenuBuilder.deleteMenu(${menu.MenuID})">Delete</button>
</div>
`).join('')}
</div>
<div id="menuFormContainer" style="display: none; padding: 16px; background: var(--gray-50); border-radius: 8px;">
<h4 id="menuFormTitle" style="margin: 0 0 16px 0;">Create Menu</h4>
<input type="hidden" id="editMenuId" value="0">
<div class="property-field">
<label>Menu Name</label>
<input type="text" id="menuNameInput" placeholder="e.g., Lunch Menu, Happy Hour">
</div>
<div class="property-field">
<label>Description (optional)</label>
<input type="text" id="menuDescInput" placeholder="Brief description">
</div>
<div style="display: flex; gap: 12px;">
<div class="property-field" style="flex: 1;">
<label>Start Time</label>
<input type="time" id="menuStartInput">
</div>
<div class="property-field" style="flex: 1;">
<label>End Time</label>
<input type="time" id="menuEndInput">
</div>
</div>
<div class="property-field">
<label>Active Days</label>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
${['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((day, i) => `
<label style="display: flex; align-items: center; gap: 4px; cursor: pointer;">
<input type="checkbox" class="menuDayCheck" data-day="${1 << i}" checked>
${day}
</label>
`).join('')}
</div>
</div>
<div style="display: flex; gap: 8px; margin-top: 16px;">
<button class="btn btn-primary" onclick="MenuBuilder.saveMenuForm()">Save</button>
<button class="btn btn-secondary" onclick="MenuBuilder.cancelMenuForm()">Cancel</button>
</div>
</div>
</div>
`;
document.getElementById('modalTitle').textContent = 'Manage Menus';
document.getElementById('modalBody').innerHTML = content;
this.showModal();
},
// Format days active bitmask to readable string
formatDaysActive(bitmask) {
if (bitmask === 127) return 'Every day';
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const active = [];
for (let i = 0; i < 7; i++) {
if (bitmask & (1 << i)) active.push(days[i]);
}
return active.join(', ') || 'No days';
},
showCreateMenuForm() {
document.getElementById('menuFormContainer').style.display = 'block';
document.getElementById('menuFormTitle').textContent = 'Create Menu';
document.getElementById('editMenuId').value = '0';
document.getElementById('menuNameInput').value = '';
document.getElementById('menuDescInput').value = '';
document.getElementById('menuStartInput').value = '';
document.getElementById('menuEndInput').value = '';
document.querySelectorAll('.menuDayCheck').forEach(cb => cb.checked = true);
},
cancelMenuForm() {
document.getElementById('menuFormContainer').style.display = 'none';
},
async editMenu(menuId) {
const menu = this.menus.find(m => m.MenuID === menuId);
if (!menu) return;
document.getElementById('menuFormContainer').style.display = 'block';
document.getElementById('menuFormTitle').textContent = 'Edit Menu';
document.getElementById('editMenuId').value = menuId;
document.getElementById('menuNameInput').value = menu.MenuName;
document.getElementById('menuDescInput').value = menu.MenuDescription || '';
document.getElementById('menuStartInput').value = menu.MenuStartTime || '';
document.getElementById('menuEndInput').value = menu.MenuEndTime || '';
document.querySelectorAll('.menuDayCheck').forEach(cb => {
const day = parseInt(cb.dataset.day);
cb.checked = (menu.MenuDaysActive & day) !== 0;
});
},
async saveMenuForm() {
const menuId = parseInt(document.getElementById('editMenuId').value) || 0;
const menuName = document.getElementById('menuNameInput').value.trim();
const menuDesc = document.getElementById('menuDescInput').value.trim();
const menuStart = document.getElementById('menuStartInput').value;
const menuEnd = document.getElementById('menuEndInput').value;
let daysActive = 0;
document.querySelectorAll('.menuDayCheck').forEach(cb => {
if (cb.checked) daysActive |= parseInt(cb.dataset.day);
});
if (!menuName) {
this.toast('Menu name is required', 'error');
return;
}
try {
const response = await fetch(`${this.config.apiBaseUrl}/menu/menus.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
BusinessID: this.config.businessId,
action: 'save',
MenuID: menuId,
MenuName: menuName,
MenuDescription: menuDesc,
MenuStartTime: menuStart,
MenuEndTime: menuEnd,
MenuDaysActive: daysActive
})
});
const data = await response.json();
if (data.OK) {
if (data.ACTION === 'created') {
// New menu — store context and redirect to wizard to populate it
sessionStorage.setItem('payfrit_wizard_menu', JSON.stringify({
businessId: this.config.businessId,
menuId: data.MenuID,
menuName: menuName
}));
window.location.href = `${BASE_PATH}/portal/setup-wizard.html`;
return;
}
this.toast(`Menu ${data.ACTION}!`, 'success');
this.closeModal();
await this.loadMenu();
} else {
this.toast(data.MESSAGE || 'Failed to save menu', 'error');
}
} catch (err) {
this.toast('Error saving menu', 'error');
}
},
async deleteMenu(menuId) {
if (!confirm('Are you sure you want to delete this menu? Categories in this menu will become unassigned.')) {
return;
}
try {
const response = await fetch(`${this.config.apiBaseUrl}/menu/menus.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
BusinessID: this.config.businessId,
action: 'delete',
MenuID: menuId
})
});
const data = await response.json();
if (data.OK) {
this.toast('Menu deleted', 'success');
this.closeModal();
await this.loadMenu();
} else {
this.toast(data.MESSAGE || 'Failed to delete menu', 'error');
}
} catch (err) {
this.toast('Error deleting menu', 'error');
}
},
async setDefaultMenu(menuId) {
try {
const response = await fetch(`${this.config.apiBaseUrl}/menu/menus.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
BusinessID: this.config.businessId,
action: 'setDefault',
MenuID: menuId
})
});
const data = await response.json();
if (data.OK) {
this.defaultMenuId = menuId;
this.toast('Default menu updated', 'success');
// Refresh the menu manager display
this.showMenuManager();
} else {
this.toast(data.MESSAGE || 'Failed to set default menu', 'error');
}
} catch (err) {
this.toast('Error setting default menu', 'error');
}
},
// 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(--gray-500); 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));
// Deep clone menu and strip base64 data URLs to avoid huge payloads
const menuClone = JSON.parse(JSON.stringify(this.menu));
for (const cat of menuClone.categories) {
for (const item of (cat.items || [])) {
if (item.imageUrl && item.imageUrl.startsWith('data:')) {
item.imageUrl = null;
}
}
}
const payload = {
BusinessID: this.config.businessId,
Menu: menuClone
};
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 a single item card (reused by categories and subcategories)
renderItemCard(item, category) {
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="${this.getImageUrls(item.imageUrl).thumb}" alt="" onerror="this.onerror=null; this.src='${item.imageUrl}'">` : '🍽️'}
${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) : ''}
`;
},
// 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;
}
// Separate top-level categories and subcategories
const topLevelCategories = this.menu.categories.filter(c => !c.parentCategoryId || c.parentCategoryId === 0);
const getSubcategories = (parentCat) => this.menu.categories.filter(c =>
c.parentCategoryId === parentCat.id || (parentCat.dbId && c.parentCategoryDbId === parentCat.dbId)
);
container.innerHTML = topLevelCategories.map(category => {
const isExpanded = this.expandedCategoryId === category.id;
const subcategories = getSubcategories(category);
const hasSubcats = subcategories.length > 0;
const totalItems = hasSubcats
? subcategories.reduce((sum, sc) => sum + sc.items.length, 0) + category.items.length
: category.items.length;
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">${totalItems} items${hasSubcats ? `, ${subcategories.length} subcategories` : ''}</div>
</div>
<div class="category-actions">
${hasSubcats || category.items.length === 0 ? `
<button onclick="event.stopPropagation(); MenuBuilder.addCategory(null, '${category.id}')" title="Add Subcategory">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
</svg>
</button>
` : ''}
<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'}">
${hasSubcats ? subcategories.map(subcat => {
const subExpanded = this.expandedSubCategoryId === subcat.id;
return `
<div class="category-card subcategory-card" data-category-id="${subcat.id}" draggable="true"
onclick="event.stopPropagation(); MenuBuilder.selectElement(this)" style="margin: 8px 0 8px 20px; border-color: var(--gray-300);">
<div class="category-header" style="padding: 8px 12px;">
<div class="category-toggle ${subExpanded ? 'expanded' : ''}" onclick="event.stopPropagation(); MenuBuilder.toggleSubCategory('${subcat.id}')">
<svg width="14" height="14" 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="category-info" onclick="event.stopPropagation(); MenuBuilder.toggleSubCategory('${subcat.id}')" style="cursor: pointer;">
<div class="category-name" style="font-size: 14px;">${this.escapeHtml(subcat.name)}</div>
<div class="category-count">${subcat.items.length} items</div>
</div>
<div class="category-actions">
<button onclick="event.stopPropagation(); MenuBuilder.addItem('${subcat.id}')" title="Add Item">
<svg width="14" height="14" 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('${subcat.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>
<div class="items-list ${subExpanded ? '' : 'collapsed'}">
${subcat.items.length === 0 ? `
<div class="item-drop-zone">Drag items here or click + to add</div>
` : subcat.items.map(item => this.renderItemCard(item, subcat)).join('')}
</div>
</div>`;
}).join('') : ''}
${!hasSubcats && category.items.length === 0 ? `
<div class="item-drop-zone">Drag items here or click + to add</div>
` : category.items.map(item => this.renderItemCard(item, category)).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.expandedSubCategoryId = null;
this.expandedItemId = null;
this.expandedModifierIds.clear();
}
this.render();
},
// Toggle subcategory expanded/collapsed (independent of parent)
toggleSubCategory(subcatId) {
if (this.expandedSubCategoryId === subcatId) {
this.expandedSubCategoryId = null;
} else {
this.expandedSubCategoryId = subcatId;
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>