Features: - Multi-menu support with time-based availability - Menu hours validation against business operating hours - Setup wizard now creates Menu records and links categories - New menus.cfm API for menu CRUD operations - Category schedule filtering (day/time based visibility) - Beacon UUID lookup API for customer app - Parent/child business relationships for franchises - Category listing API for menu builder Portal improvements: - Menu builder theming to match admin UI - Brand color picker fix - Header image preview improvements API fixes: - Filter demo/hidden businesses from restaurant list - Improved error handling throughout Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
4090 lines
147 KiB
HTML
4090 lines
147 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Menu Builder - Payfrit</title>
|
|
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
|
<link rel="stylesheet" href="portal.css">
|
|
<style>
|
|
/* Menu Builder Specific Styles */
|
|
.builder-wrapper {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
}
|
|
|
|
.builder-container {
|
|
display: flex;
|
|
flex: 1;
|
|
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(--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: #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: #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;
|
|
background: #fff;
|
|
border-radius: var(--radius);
|
|
box-shadow: var(--shadow);
|
|
padding: 16px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.properties-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.properties-header h3 {
|
|
margin: 0;
|
|
font-size: 14px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
color: var(--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;
|
|
}
|
|
|
|
.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 (same as portal) -->
|
|
<aside class="sidebar" id="sidebar">
|
|
<div class="sidebar-header">
|
|
<div class="logo">
|
|
<span class="logo-icon">P</span>
|
|
<span class="logo-text">Payfrit</span>
|
|
</div>
|
|
<button class="sidebar-toggle" id="sidebarToggle">
|
|
<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="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#beacons" 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>Beacons</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#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>
|
|
|
|
<div class="sidebar-footer">
|
|
<div class="business-info">
|
|
<div class="business-avatar" id="businessAvatar">B</div>
|
|
<div class="business-details">
|
|
<div class="business-name" id="businessName">Loading...</div>
|
|
<div class="business-status online">Online</div>
|
|
</div>
|
|
</div>
|
|
<a href="#logout" class="nav-item logout" onclick="MenuBuilder.logout()">
|
|
<svg class="nav-icon" width="20" height="20" 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>
|
|
<span>Logout</span>
|
|
</a>
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- Main Content -->
|
|
<main class="main-content">
|
|
<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 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>
|
|
<span style="font-size: 13px; color: var(--gray-500); font-weight: normal;" id="businessLabel"></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>
|
|
<button class="btn btn-sm btn-secondary" onclick="MenuBuilder.showMenuManager()" title="Manage Menus">
|
|
<svg width="16" height="16" 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>
|
|
</button>
|
|
</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()">×</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
|
|
expandedItemId: null, // For item accordion - only one item expanded at a time
|
|
expandedModifierIds: new Set(), // Track which modifiers are expanded
|
|
|
|
// Initialize
|
|
async init() {
|
|
console.log('[MenuBuilder] Initializing...');
|
|
|
|
// Check authentication
|
|
const token = localStorage.getItem('payfrit_portal_token');
|
|
const savedBusiness = localStorage.getItem('payfrit_portal_business');
|
|
|
|
if (!token || !savedBusiness) {
|
|
window.location.href = BASE_PATH + '/portal/login.html';
|
|
return;
|
|
}
|
|
|
|
// Get business ID from localStorage
|
|
this.config.businessId = parseInt(savedBusiness) || null;
|
|
this.config.token = token;
|
|
|
|
// 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/login.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;
|
|
document.getElementById('businessLabel').textContent = `- ${biz.BusinessName}`;
|
|
// Update sidebar business info
|
|
const businessName = document.getElementById('businessName');
|
|
const businessAvatar = document.getElementById('businessAvatar');
|
|
if (businessName) businessName.textContent = biz.BusinessName;
|
|
if (businessAvatar) businessAvatar.textContent = biz.BusinessName ? biz.BusinessName.charAt(0).toUpperCase() : 'B';
|
|
}
|
|
} catch (err) {
|
|
console.error('[MenuBuilder] Error loading business:', err);
|
|
}
|
|
},
|
|
|
|
// Setup drag and drop
|
|
setupDragAndDrop() {
|
|
// Palette items
|
|
document.querySelectorAll('.palette-item').forEach(item => {
|
|
item.addEventListener('dragstart', (e) => {
|
|
e.dataTransfer.setData('type', item.dataset.type);
|
|
e.dataTransfer.setData('source', 'palette');
|
|
});
|
|
});
|
|
|
|
// Canvas drop zone
|
|
const structure = document.getElementById('menuStructure');
|
|
structure.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'copy';
|
|
});
|
|
|
|
structure.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
const type = e.dataTransfer.getData('type');
|
|
const source = e.dataTransfer.getData('source');
|
|
|
|
if (source === 'palette') {
|
|
this.handlePaletteDrop(type, e.target);
|
|
}
|
|
});
|
|
},
|
|
|
|
// Handle palette drop
|
|
handlePaletteDrop(type, target) {
|
|
switch (type) {
|
|
case 'category':
|
|
this.addCategory();
|
|
break;
|
|
case 'item':
|
|
this.addItem(this.findCategoryFromTarget(target));
|
|
break;
|
|
case 'modifier':
|
|
this.addModifier(this.findItemFromTarget(target));
|
|
break;
|
|
case 'modifier-group':
|
|
this.addModifierGroup(this.findItemFromTarget(target));
|
|
break;
|
|
case 'template-size':
|
|
this.addSizeTemplate(this.findItemFromTarget(target));
|
|
break;
|
|
case 'template-protein':
|
|
this.addProteinTemplate(this.findItemFromTarget(target));
|
|
break;
|
|
case 'template-temp':
|
|
this.addTempTemplate(this.findItemFromTarget(target));
|
|
break;
|
|
}
|
|
},
|
|
|
|
// Find category from drop target
|
|
findCategoryFromTarget(target) {
|
|
const card = target.closest('.category-card');
|
|
if (card) {
|
|
return parseInt(card.dataset.categoryId);
|
|
}
|
|
return this.menu.categories.length > 0 ? this.menu.categories[0].id : null;
|
|
},
|
|
|
|
// Find item from drop target
|
|
findItemFromTarget(target) {
|
|
const card = target.closest('.item-card');
|
|
if (card) {
|
|
return parseInt(card.dataset.itemId);
|
|
}
|
|
return null;
|
|
},
|
|
|
|
// Setup keyboard shortcuts
|
|
setupKeyboardShortcuts() {
|
|
document.addEventListener('keydown', (e) => {
|
|
// Delete
|
|
if (e.key === 'Delete' && this.selectedElement) {
|
|
e.preventDefault();
|
|
this.deleteSelected();
|
|
}
|
|
|
|
// Clone (Ctrl+D)
|
|
if (e.key === 'd' && e.ctrlKey && this.selectedElement) {
|
|
e.preventDefault();
|
|
this.cloneSelected();
|
|
}
|
|
|
|
// Undo (Ctrl+Z)
|
|
if (e.key === 'z' && e.ctrlKey && !e.shiftKey) {
|
|
e.preventDefault();
|
|
this.undo();
|
|
}
|
|
|
|
// Redo (Ctrl+Y or Ctrl+Shift+Z)
|
|
if ((e.key === 'y' && e.ctrlKey) || (e.key === 'z' && e.ctrlKey && e.shiftKey)) {
|
|
e.preventDefault();
|
|
this.redo();
|
|
}
|
|
|
|
// Move up (Alt+Up)
|
|
if (e.key === 'ArrowUp' && e.altKey && this.selectedElement) {
|
|
e.preventDefault();
|
|
this.moveUp();
|
|
}
|
|
|
|
// Move down (Alt+Down)
|
|
if (e.key === 'ArrowDown' && e.altKey && this.selectedElement) {
|
|
e.preventDefault();
|
|
this.moveDown();
|
|
}
|
|
|
|
// Edit (Enter)
|
|
if (e.key === 'Enter' && this.selectedElement) {
|
|
e.preventDefault();
|
|
this.editSelected();
|
|
}
|
|
|
|
// Escape - deselect
|
|
if (e.key === 'Escape') {
|
|
this.clearSelection();
|
|
this.hideContextMenu();
|
|
}
|
|
});
|
|
},
|
|
|
|
// Setup context menu
|
|
setupContextMenu() {
|
|
document.addEventListener('contextmenu', (e) => {
|
|
const element = e.target.closest('.category-card, .item-card');
|
|
if (element) {
|
|
e.preventDefault();
|
|
this.selectElement(element);
|
|
this.showContextMenu(e.clientX, e.clientY);
|
|
}
|
|
});
|
|
|
|
document.addEventListener('click', () => {
|
|
this.hideContextMenu();
|
|
});
|
|
},
|
|
|
|
// Show context menu
|
|
showContextMenu(x, y) {
|
|
const menu = document.getElementById('contextMenu');
|
|
menu.style.left = x + 'px';
|
|
menu.style.top = y + 'px';
|
|
menu.classList.add('visible');
|
|
},
|
|
|
|
// Hide context menu
|
|
hideContextMenu() {
|
|
document.getElementById('contextMenu').classList.remove('visible');
|
|
},
|
|
|
|
// Generate unique ID
|
|
generateId() {
|
|
return `temp_${Date.now()}_${this.idCounter++}`;
|
|
},
|
|
|
|
// Save state for undo
|
|
saveState() {
|
|
this.undoStack.push(JSON.stringify(this.menu));
|
|
this.redoStack = [];
|
|
if (this.undoStack.length > 50) {
|
|
this.undoStack.shift();
|
|
}
|
|
},
|
|
|
|
// Undo
|
|
undo() {
|
|
if (this.undoStack.length === 0) return;
|
|
this.redoStack.push(JSON.stringify(this.menu));
|
|
this.menu = JSON.parse(this.undoStack.pop());
|
|
this.render();
|
|
this.toast('Undo', 'info');
|
|
},
|
|
|
|
// Redo
|
|
redo() {
|
|
if (this.redoStack.length === 0) return;
|
|
this.undoStack.push(JSON.stringify(this.menu));
|
|
this.menu = JSON.parse(this.redoStack.pop());
|
|
this.render();
|
|
this.toast('Redo', 'info');
|
|
},
|
|
|
|
// Add category
|
|
addCategory(name = null) {
|
|
this.saveState();
|
|
|
|
const category = {
|
|
id: this.generateId(),
|
|
name: name || `New Category ${this.menu.categories.length + 1}`,
|
|
description: '',
|
|
sortOrder: this.menu.categories.length,
|
|
items: []
|
|
};
|
|
|
|
this.menu.categories.push(category);
|
|
this.render();
|
|
this.selectElementById('category', category.id);
|
|
this.showPropertiesForCategory(category);
|
|
},
|
|
|
|
// Add item
|
|
addItem(categoryId = null) {
|
|
this.saveState();
|
|
|
|
// Find category
|
|
let category = null;
|
|
if (categoryId) {
|
|
category = this.menu.categories.find(c => c.id === categoryId);
|
|
} else if (this.menu.categories.length > 0) {
|
|
category = this.menu.categories[0];
|
|
} else {
|
|
this.toast('Create a category first', 'error');
|
|
return;
|
|
}
|
|
|
|
const item = {
|
|
id: this.generateId(),
|
|
name: 'New Item',
|
|
description: '',
|
|
price: 0,
|
|
imageUrl: null,
|
|
photoTaskId: null,
|
|
modifiers: [],
|
|
sortOrder: category.items.length
|
|
};
|
|
|
|
category.items.push(item);
|
|
this.render();
|
|
this.selectElementById('item', item.id);
|
|
this.showPropertiesForItem(item, category);
|
|
},
|
|
|
|
// Add modifier
|
|
addModifier(itemId) {
|
|
if (!itemId) {
|
|
this.toast('Select an item first', 'error');
|
|
return;
|
|
}
|
|
|
|
this.saveState();
|
|
|
|
// Find item
|
|
let item = null;
|
|
let parentCategory = null;
|
|
for (const cat of this.menu.categories) {
|
|
const found = cat.items.find(i => i.id === itemId);
|
|
if (found) {
|
|
item = found;
|
|
parentCategory = cat;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!item) return;
|
|
|
|
const modifier = {
|
|
id: this.generateId(),
|
|
name: 'New Modifier',
|
|
price: 0,
|
|
isDefault: false,
|
|
sortOrder: item.modifiers.length
|
|
};
|
|
|
|
item.modifiers.push(modifier);
|
|
this.render();
|
|
},
|
|
|
|
// Add modifier group
|
|
addModifierGroup(itemId) {
|
|
if (!itemId) {
|
|
this.toast('Select an item first', 'error');
|
|
return;
|
|
}
|
|
// For now, just add a modifier with a group indicator
|
|
this.addModifier(itemId);
|
|
},
|
|
|
|
// Add size template
|
|
addSizeTemplate(itemId) {
|
|
if (!itemId) {
|
|
this.toast('Select an item first', 'error');
|
|
return;
|
|
}
|
|
|
|
this.saveState();
|
|
|
|
let item = null;
|
|
for (const cat of this.menu.categories) {
|
|
const found = cat.items.find(i => i.id === itemId);
|
|
if (found) {
|
|
item = found;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!item) return;
|
|
|
|
const sizes = [
|
|
{ name: 'Small', price: 0, isDefault: true },
|
|
{ name: 'Medium', price: 1.00, isDefault: false },
|
|
{ name: 'Large', price: 2.00, isDefault: false }
|
|
];
|
|
|
|
sizes.forEach((s, i) => {
|
|
item.modifiers.push({
|
|
id: this.generateId(),
|
|
name: s.name,
|
|
price: s.price,
|
|
isDefault: s.isDefault,
|
|
group: 'Size',
|
|
sortOrder: item.modifiers.length + i
|
|
});
|
|
});
|
|
|
|
this.render();
|
|
this.toast('Size options added', 'success');
|
|
},
|
|
|
|
// Add protein template
|
|
addProteinTemplate(itemId) {
|
|
if (!itemId) {
|
|
this.toast('Select an item first', 'error');
|
|
return;
|
|
}
|
|
|
|
this.saveState();
|
|
|
|
let item = null;
|
|
for (const cat of this.menu.categories) {
|
|
const found = cat.items.find(i => i.id === itemId);
|
|
if (found) {
|
|
item = found;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!item) return;
|
|
|
|
const proteins = [
|
|
{ name: 'Chicken', price: 0, isDefault: true },
|
|
{ name: 'Beef', price: 2.00, isDefault: false },
|
|
{ name: 'Lamb', price: 3.00, isDefault: false },
|
|
{ name: 'Falafel (V)', price: 0, isDefault: false }
|
|
];
|
|
|
|
proteins.forEach((p, i) => {
|
|
item.modifiers.push({
|
|
id: this.generateId(),
|
|
name: p.name,
|
|
price: p.price,
|
|
isDefault: p.isDefault,
|
|
group: 'Protein',
|
|
sortOrder: item.modifiers.length + i
|
|
});
|
|
});
|
|
|
|
this.render();
|
|
this.toast('Protein options added', 'success');
|
|
},
|
|
|
|
// Add temperature template
|
|
addTempTemplate(itemId) {
|
|
if (!itemId) {
|
|
this.toast('Select an item first', 'error');
|
|
return;
|
|
}
|
|
|
|
this.saveState();
|
|
|
|
let item = null;
|
|
for (const cat of this.menu.categories) {
|
|
const found = cat.items.find(i => i.id === itemId);
|
|
if (found) {
|
|
item = found;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!item) return;
|
|
|
|
const temps = [
|
|
{ name: 'Hot', price: 0, isDefault: true },
|
|
{ name: 'Iced', price: 0.50, isDefault: false },
|
|
{ name: 'Blended', price: 1.00, isDefault: false }
|
|
];
|
|
|
|
temps.forEach((t, i) => {
|
|
item.modifiers.push({
|
|
id: this.generateId(),
|
|
name: t.name,
|
|
price: t.price,
|
|
isDefault: t.isDefault,
|
|
group: 'Temperature',
|
|
sortOrder: item.modifiers.length + i
|
|
});
|
|
});
|
|
|
|
this.render();
|
|
this.toast('Temperature options added', 'success');
|
|
},
|
|
|
|
// Select element
|
|
selectElement(element) {
|
|
this.clearSelection();
|
|
this.selectedElement = element;
|
|
element.classList.add('selected');
|
|
|
|
// Get data
|
|
if (element.classList.contains('category-card')) {
|
|
const catId = element.dataset.categoryId;
|
|
const category = this.menu.categories.find(c => c.id === catId);
|
|
this.selectedData = { type: 'category', data: category };
|
|
this.showPropertiesForCategory(category);
|
|
} else if (element.classList.contains('item-card')) {
|
|
const itemId = element.dataset.itemId;
|
|
for (const cat of this.menu.categories) {
|
|
const item = cat.items.find(i => i.id === itemId);
|
|
if (item) {
|
|
this.selectedData = { type: 'item', data: item, category: cat };
|
|
this.showPropertiesForItem(item, cat);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
// Select element by ID
|
|
selectElementById(type, id) {
|
|
const selector = type === 'category'
|
|
? `.category-card[data-category-id="${id}"]`
|
|
: `.item-card[data-item-id="${id}"]`;
|
|
const element = document.querySelector(selector);
|
|
if (element) {
|
|
this.selectElement(element);
|
|
}
|
|
},
|
|
|
|
// Select item by ID (shortcut for use in onclick handlers)
|
|
selectItem(itemId) {
|
|
this.selectElementById('item', itemId);
|
|
},
|
|
|
|
// Clear selection
|
|
clearSelection() {
|
|
if (this.selectedElement) {
|
|
this.selectedElement.classList.remove('selected');
|
|
}
|
|
this.selectedElement = null;
|
|
this.selectedData = null;
|
|
document.getElementById('propertiesContent').innerHTML = `
|
|
<p style="color: var(--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('');
|
|
|
|
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>
|
|
${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 =>
|
|
`<option value="${c.id}" ${c.id === category.id ? 'selected' : ''}>${this.escapeHtml(c.name)}</option>`
|
|
).join('')}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="photo-task-section">
|
|
<div class="photo-task-header">
|
|
<h4>Item Photo</h4>
|
|
${item.photoTaskId ?
|
|
`<span class="task-badge ${item.imageUrl ? 'completed' : ''}">
|
|
${item.imageUrl ? 'Photo Added' : 'Task Pending'}
|
|
</span>` : ''}
|
|
</div>
|
|
<div class="photo-preview">
|
|
${item.imageUrl ?
|
|
`<img src="${item.imageUrl}" alt="${this.escapeHtml(item.name)}">` :
|
|
`<span>No photo yet</span>`}
|
|
</div>
|
|
<div class="photo-actions">
|
|
<button class="btn btn-secondary" onclick="MenuBuilder.uploadPhoto('${item.id}')">
|
|
Upload
|
|
</button>
|
|
<button class="btn btn-secondary" onclick="MenuBuilder.createPhotoTask('${item.id}')">
|
|
Create Task
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="property-group" style="margin-top: 16px;">
|
|
<label>Modifiers (${item.modifiers.length})</label>
|
|
<button class="btn btn-secondary" onclick="MenuBuilder.addModifier('${item.id}')" style="margin-top: 8px;">
|
|
+ Add Modifier
|
|
</button>
|
|
</div>
|
|
|
|
<div class="property-group" style="margin-top: 16px;">
|
|
<button class="btn btn-danger" onclick="MenuBuilder.deleteItem('${category.id}', '${item.id}')">
|
|
Delete Item
|
|
</button>
|
|
</div>
|
|
`;
|
|
},
|
|
|
|
// Select modifier and show its properties
|
|
selectModifier(itemId, modifierId) {
|
|
this.clearSelection();
|
|
|
|
// Find the modifier element and highlight it
|
|
const element = document.querySelector(`.item-card.modifier[data-modifier-id="${modifierId}"]`);
|
|
if (element) {
|
|
this.selectedElement = element;
|
|
element.classList.add('selected');
|
|
}
|
|
|
|
// Find the modifier data
|
|
for (const cat of this.menu.categories) {
|
|
const item = cat.items.find(i => i.id === itemId);
|
|
if (item) {
|
|
const modifier = item.modifiers.find(m => m.id === modifierId);
|
|
if (modifier) {
|
|
this.selectedData = { type: 'modifier', data: modifier, item: item, category: cat };
|
|
this.showPropertiesForModifier(modifier, item, cat);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
// Show properties for modifier
|
|
showPropertiesForModifier(modifier, item, category) {
|
|
document.getElementById('propertiesContent').innerHTML = `
|
|
<div class="property-group">
|
|
<label>Modifier Name</label>
|
|
<input type="text" id="propModName" value="${this.escapeHtml(modifier.name)}"
|
|
onchange="MenuBuilder.updateModifier('${item.id}', '${modifier.id}', 'name', this.value)">
|
|
</div>
|
|
<div class="property-row">
|
|
<div class="property-group">
|
|
<label>Extra Price ($)</label>
|
|
<input type="number" step="0.01" id="propModPrice" value="${modifier.price || 0}"
|
|
onchange="MenuBuilder.updateModifier('${item.id}', '${modifier.id}', 'price', parseFloat(this.value))">
|
|
</div>
|
|
<div class="property-group">
|
|
<label>Default</label>
|
|
<select id="propModDefault"
|
|
onchange="MenuBuilder.updateModifier('${item.id}', '${modifier.id}', 'isDefault', this.value === 'true')">
|
|
<option value="false" ${!modifier.isDefault ? 'selected' : ''}>No</option>
|
|
<option value="true" ${modifier.isDefault ? 'selected' : ''}>Yes</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="property-group">
|
|
<label>Group Name (optional)</label>
|
|
<input type="text" id="propModGroup" value="${this.escapeHtml(modifier.group || '')}"
|
|
placeholder="e.g., Size, Protein, Temperature"
|
|
onchange="MenuBuilder.updateModifier('${item.id}', '${modifier.id}', 'group', this.value)">
|
|
<small style="color: var(--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,
|
|
options: []
|
|
};
|
|
|
|
// Recursively copy options
|
|
if (obj.options && obj.options.length > 0) {
|
|
newObj.options = obj.options.map(opt => this.deepCopyWithNewIds(opt));
|
|
}
|
|
|
|
return newObj;
|
|
},
|
|
|
|
// Delete option at any depth
|
|
deleteOption(parentId, optionId, depth) {
|
|
if (!confirm('Delete this option and all its sub-options?')) return;
|
|
this.saveState();
|
|
|
|
// Find and remove from parent's options array
|
|
for (const cat of this.menu.categories) {
|
|
for (const item of cat.items) {
|
|
// Check if parent is the item itself
|
|
if (item.id === parentId) {
|
|
item.modifiers = item.modifiers.filter(m => m.id !== optionId);
|
|
this.clearSelection();
|
|
this.render();
|
|
this.toast('Option deleted', 'success');
|
|
return;
|
|
}
|
|
// Check nested
|
|
if (this.deleteFromModifiers(item.modifiers, parentId, optionId)) {
|
|
this.clearSelection();
|
|
this.render();
|
|
this.toast('Option deleted', 'success');
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
deleteFromModifiers(modifiers, parentId, optionId) {
|
|
for (const mod of modifiers) {
|
|
if (mod.id === parentId && mod.options) {
|
|
const idx = mod.options.findIndex(o => o.id === optionId);
|
|
if (idx !== -1) {
|
|
mod.options.splice(idx, 1);
|
|
return true;
|
|
}
|
|
}
|
|
if (mod.options && mod.options.length > 0) {
|
|
if (this.deleteFromModifiers(mod.options, parentId, optionId)) return true;
|
|
}
|
|
}
|
|
return false;
|
|
},
|
|
|
|
// Add sub-option to any option
|
|
addSubOption(parentOptionId) {
|
|
this.saveState();
|
|
|
|
const newOption = {
|
|
id: 'opt_new_' + Date.now(),
|
|
dbId: null,
|
|
name: 'New Option',
|
|
price: 0,
|
|
isDefault: false,
|
|
sortOrder: 0,
|
|
options: []
|
|
};
|
|
|
|
// Find the parent option and add to its options array
|
|
for (const cat of this.menu.categories) {
|
|
for (const item of cat.items) {
|
|
const found = this.findOptionRecursive(item.modifiers, parentOptionId);
|
|
if (found) {
|
|
if (!found.options) found.options = [];
|
|
found.options.push(newOption);
|
|
this.render();
|
|
this.toast('Sub-option added', 'success');
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
// Find modifier/option and its parent array
|
|
findModifierWithParent(modifierId) {
|
|
for (const cat of this.menu.categories) {
|
|
for (const item of cat.items) {
|
|
// Check if it's a top-level modifier
|
|
const idx = item.modifiers.findIndex(m => m.id === modifierId);
|
|
if (idx !== -1) {
|
|
return { array: item.modifiers, index: idx, parentId: item.id, isItem: true };
|
|
}
|
|
// Check nested
|
|
const nested = this.findNestedModifierWithParent(item.modifiers, modifierId);
|
|
if (nested) return nested;
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
|
|
findNestedModifierWithParent(modifiers, modifierId) {
|
|
for (const mod of modifiers) {
|
|
if (mod.options && mod.options.length > 0) {
|
|
const idx = mod.options.findIndex(o => o.id === modifierId);
|
|
if (idx !== -1) {
|
|
return { array: mod.options, index: idx, parentId: mod.id, isItem: false };
|
|
}
|
|
const nested = this.findNestedModifierWithParent(mod.options, modifierId);
|
|
if (nested) return nested;
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
|
|
// Get the parent array for a parentId (item or modifier)
|
|
getParentModifiersArray(parentId) {
|
|
for (const cat of this.menu.categories) {
|
|
for (const item of cat.items) {
|
|
if (item.id === parentId) {
|
|
return item.modifiers;
|
|
}
|
|
const found = this.findOptionRecursive(item.modifiers, parentId);
|
|
if (found) {
|
|
if (!found.options) found.options = [];
|
|
return found.options;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
|
|
// Deep clone a modifier with new IDs
|
|
cloneModifierDeep(mod) {
|
|
const clone = JSON.parse(JSON.stringify(mod));
|
|
clone.id = this.generateId();
|
|
clone.dbId = null; // Will get new dbId on save
|
|
if (clone.options && clone.options.length > 0) {
|
|
clone.options = clone.options.map(opt => this.cloneModifierDeep(opt));
|
|
}
|
|
return clone;
|
|
},
|
|
|
|
// Reorder modifier within same parent
|
|
reorderModifier(draggedId, targetId, parentId, position) {
|
|
this.saveState();
|
|
const parentArray = this.getParentModifiersArray(parentId);
|
|
if (!parentArray) return;
|
|
|
|
const draggedIdx = parentArray.findIndex(m => m.id === draggedId);
|
|
const targetIdx = parentArray.findIndex(m => m.id === targetId);
|
|
if (draggedIdx === -1 || targetIdx === -1) return;
|
|
|
|
// Remove dragged
|
|
const [dragged] = parentArray.splice(draggedIdx, 1);
|
|
|
|
// Find new target index (may have shifted)
|
|
let newTargetIdx = parentArray.findIndex(m => m.id === targetId);
|
|
if (position === 'after') newTargetIdx++;
|
|
|
|
// Insert at new position
|
|
parentArray.splice(newTargetIdx, 0, dragged);
|
|
|
|
// Update sort orders
|
|
parentArray.forEach((m, i) => m.sortOrder = i);
|
|
|
|
this.render();
|
|
this.toast('Modifier reordered', 'success');
|
|
},
|
|
|
|
// Move modifier from one parent to another
|
|
moveModifier(draggedId, fromParentId, targetModId, toParentId, position) {
|
|
this.saveState();
|
|
const fromArray = this.getParentModifiersArray(fromParentId);
|
|
const toArray = this.getParentModifiersArray(toParentId);
|
|
if (!fromArray || !toArray) return;
|
|
|
|
const draggedIdx = fromArray.findIndex(m => m.id === draggedId);
|
|
if (draggedIdx === -1) return;
|
|
|
|
// Remove from source
|
|
const [dragged] = fromArray.splice(draggedIdx, 1);
|
|
|
|
// Find target position in destination
|
|
const targetIdx = toArray.findIndex(m => m.id === targetModId);
|
|
let insertIdx = targetIdx === -1 ? toArray.length : targetIdx;
|
|
if (position === 'after') insertIdx++;
|
|
|
|
// Insert at new position
|
|
toArray.splice(insertIdx, 0, dragged);
|
|
|
|
// Update sort orders in both arrays
|
|
fromArray.forEach((m, i) => m.sortOrder = i);
|
|
toArray.forEach((m, i) => m.sortOrder = i);
|
|
|
|
this.render();
|
|
this.toast('Modifier moved', 'success');
|
|
},
|
|
|
|
// Copy modifier to position relative to another modifier
|
|
copyModifier(draggedId, fromParentId, targetModId, toParentId, position) {
|
|
this.saveState();
|
|
const fromArray = this.getParentModifiersArray(fromParentId);
|
|
const toArray = this.getParentModifiersArray(toParentId);
|
|
if (!fromArray || !toArray) return;
|
|
|
|
const dragged = fromArray.find(m => m.id === draggedId);
|
|
if (!dragged) return;
|
|
|
|
// Clone with new IDs
|
|
const clone = this.cloneModifierDeep(dragged);
|
|
|
|
// Find target position
|
|
const targetIdx = toArray.findIndex(m => m.id === targetModId);
|
|
let insertIdx = targetIdx === -1 ? toArray.length : targetIdx;
|
|
if (position === 'after') insertIdx++;
|
|
|
|
// Insert clone
|
|
toArray.splice(insertIdx, 0, clone);
|
|
|
|
// Update sort orders
|
|
toArray.forEach((m, i) => m.sortOrder = i);
|
|
|
|
this.render();
|
|
this.toast('Modifier copied', 'success');
|
|
},
|
|
|
|
// Move modifier to an item (as top-level modifier)
|
|
moveModifierToItem(draggedId, fromParentId, toItemId) {
|
|
this.saveState();
|
|
const fromArray = this.getParentModifiersArray(fromParentId);
|
|
if (!fromArray) return;
|
|
|
|
// Find target item
|
|
let targetItem = null;
|
|
for (const cat of this.menu.categories) {
|
|
targetItem = cat.items.find(i => i.id === toItemId);
|
|
if (targetItem) break;
|
|
}
|
|
if (!targetItem) return;
|
|
|
|
const draggedIdx = fromArray.findIndex(m => m.id === draggedId);
|
|
if (draggedIdx === -1) return;
|
|
|
|
// Remove from source
|
|
const [dragged] = fromArray.splice(draggedIdx, 1);
|
|
|
|
// Add to target item's modifiers
|
|
dragged.sortOrder = targetItem.modifiers.length;
|
|
targetItem.modifiers.push(dragged);
|
|
|
|
// Update sort orders
|
|
fromArray.forEach((m, i) => m.sortOrder = i);
|
|
|
|
this.render();
|
|
this.toast('Modifier moved to item', 'success');
|
|
},
|
|
|
|
// Copy modifier to an item (as top-level modifier)
|
|
copyModifierToItem(draggedId, fromParentId, toItemId) {
|
|
this.saveState();
|
|
const fromArray = this.getParentModifiersArray(fromParentId);
|
|
if (!fromArray) return;
|
|
|
|
// Find target item
|
|
let targetItem = null;
|
|
for (const cat of this.menu.categories) {
|
|
targetItem = cat.items.find(i => i.id === toItemId);
|
|
if (targetItem) break;
|
|
}
|
|
if (!targetItem) return;
|
|
|
|
const dragged = fromArray.find(m => m.id === draggedId);
|
|
if (!dragged) return;
|
|
|
|
// Clone with new IDs
|
|
const clone = this.cloneModifierDeep(dragged);
|
|
clone.sortOrder = targetItem.modifiers.length;
|
|
|
|
// Add to target item
|
|
targetItem.modifiers.push(clone);
|
|
|
|
this.render();
|
|
this.toast('Modifier copied to item', 'success');
|
|
},
|
|
|
|
// Update category
|
|
// Update category
|
|
updateCategory(categoryId, field, value) {
|
|
this.saveState();
|
|
const category = this.menu.categories.find(c => c.id === categoryId);
|
|
if (category) {
|
|
category[field] = value;
|
|
this.render();
|
|
}
|
|
},
|
|
|
|
// Update item
|
|
updateItem(categoryId, itemId, field, value) {
|
|
this.saveState();
|
|
const category = this.menu.categories.find(c => c.id === categoryId);
|
|
if (category) {
|
|
const item = category.items.find(i => i.id === itemId);
|
|
if (item) {
|
|
item[field] = value;
|
|
this.render();
|
|
}
|
|
}
|
|
},
|
|
|
|
// Move item to category
|
|
moveItemToCategory(itemId, newCategoryId) {
|
|
this.saveState();
|
|
|
|
// Find and remove item from current category
|
|
let item = null;
|
|
for (const cat of this.menu.categories) {
|
|
const idx = cat.items.findIndex(i => i.id === itemId);
|
|
if (idx !== -1) {
|
|
item = cat.items.splice(idx, 1)[0];
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Add to new category
|
|
if (item) {
|
|
const newCat = this.menu.categories.find(c => c.id === newCategoryId);
|
|
if (newCat) {
|
|
item.sortOrder = newCat.items.length;
|
|
newCat.items.push(item);
|
|
}
|
|
}
|
|
|
|
this.render();
|
|
},
|
|
|
|
// Reorder item within same category (drag and drop)
|
|
reorderItemInCategory(draggedItemId, targetItemId, position = 'before') {
|
|
// Find the category containing these items
|
|
for (const cat of this.menu.categories) {
|
|
const draggedIdx = cat.items.findIndex(i => i.id === draggedItemId);
|
|
const targetIdx = cat.items.findIndex(i => i.id === targetItemId);
|
|
|
|
if (draggedIdx !== -1 && targetIdx !== -1 && draggedIdx !== targetIdx) {
|
|
this.saveState();
|
|
// Remove dragged item
|
|
const [draggedItem] = cat.items.splice(draggedIdx, 1);
|
|
// Find new target index (it may have shifted)
|
|
const newTargetIdx = cat.items.findIndex(i => i.id === targetItemId);
|
|
// Insert at new position
|
|
const insertIdx = position === 'before' ? newTargetIdx : newTargetIdx + 1;
|
|
cat.items.splice(insertIdx, 0, draggedItem);
|
|
// Update sortOrder for all items
|
|
cat.items.forEach((item, idx) => item.sortOrder = idx);
|
|
this.render();
|
|
return;
|
|
}
|
|
}
|
|
},
|
|
|
|
// Reorder category (drag and drop)
|
|
reorderCategory(draggedCatId, targetCatId, position = 'before') {
|
|
const draggedIdx = this.menu.categories.findIndex(c => c.id === draggedCatId);
|
|
const targetIdx = this.menu.categories.findIndex(c => c.id === targetCatId);
|
|
|
|
if (draggedIdx !== -1 && targetIdx !== -1 && draggedIdx !== targetIdx) {
|
|
this.saveState();
|
|
const [draggedCat] = this.menu.categories.splice(draggedIdx, 1);
|
|
const newTargetIdx = this.menu.categories.findIndex(c => c.id === targetCatId);
|
|
const insertIdx = position === 'before' ? newTargetIdx : newTargetIdx + 1;
|
|
this.menu.categories.splice(insertIdx, 0, draggedCat);
|
|
this.render();
|
|
}
|
|
},
|
|
|
|
// Delete category
|
|
deleteCategory(categoryId) {
|
|
if (!confirm('Delete this category and all its items?')) return;
|
|
this.saveState();
|
|
this.menu.categories = this.menu.categories.filter(c => c.id !== categoryId);
|
|
this.clearSelection();
|
|
this.render();
|
|
this.toast('Category deleted', 'success');
|
|
},
|
|
|
|
// Delete item
|
|
deleteItem(categoryId, itemId) {
|
|
if (!confirm('Delete this item?')) return;
|
|
this.saveState();
|
|
const category = this.menu.categories.find(c => c.id === categoryId);
|
|
if (category) {
|
|
category.items = category.items.filter(i => i.id !== itemId);
|
|
}
|
|
this.clearSelection();
|
|
this.render();
|
|
this.toast('Item deleted', 'success');
|
|
},
|
|
|
|
// Clone selected
|
|
cloneSelected() {
|
|
if (!this.selectedData) return;
|
|
this.saveState();
|
|
|
|
if (this.selectedData.type === 'category') {
|
|
const original = this.selectedData.data;
|
|
const clone = JSON.parse(JSON.stringify(original));
|
|
clone.id = this.generateId();
|
|
clone.name = original.name + ' (Copy)';
|
|
clone.items.forEach(item => {
|
|
item.id = this.generateId();
|
|
item.modifiers.forEach(mod => mod.id = this.generateId());
|
|
});
|
|
this.menu.categories.push(clone);
|
|
} else if (this.selectedData.type === 'item') {
|
|
const original = this.selectedData.data;
|
|
const category = this.selectedData.category;
|
|
const clone = JSON.parse(JSON.stringify(original));
|
|
clone.id = this.generateId();
|
|
clone.name = original.name + ' (Copy)';
|
|
clone.modifiers.forEach(mod => mod.id = this.generateId());
|
|
category.items.push(clone);
|
|
}
|
|
|
|
this.render();
|
|
this.toast('Cloned', 'success');
|
|
},
|
|
|
|
// Delete selected
|
|
deleteSelected() {
|
|
if (!this.selectedData) return;
|
|
|
|
if (this.selectedData.type === 'category') {
|
|
this.deleteCategory(this.selectedData.data.id);
|
|
} else if (this.selectedData.type === 'item') {
|
|
this.deleteItem(this.selectedData.category.id, this.selectedData.data.id);
|
|
}
|
|
},
|
|
|
|
// Edit selected
|
|
editSelected() {
|
|
if (!this.selectedElement) return;
|
|
// Focus on the name input in properties panel
|
|
const nameInput = document.getElementById('propCatName') || document.getElementById('propItemName');
|
|
if (nameInput) {
|
|
nameInput.focus();
|
|
nameInput.select();
|
|
}
|
|
},
|
|
|
|
// Move up
|
|
moveUp() {
|
|
if (!this.selectedData) return;
|
|
this.saveState();
|
|
|
|
if (this.selectedData.type === 'category') {
|
|
const idx = this.menu.categories.findIndex(c => c.id === this.selectedData.data.id);
|
|
if (idx > 0) {
|
|
[this.menu.categories[idx - 1], this.menu.categories[idx]] =
|
|
[this.menu.categories[idx], this.menu.categories[idx - 1]];
|
|
}
|
|
} else if (this.selectedData.type === 'item') {
|
|
const items = this.selectedData.category.items;
|
|
const idx = items.findIndex(i => i.id === this.selectedData.data.id);
|
|
if (idx > 0) {
|
|
[items[idx - 1], items[idx]] = [items[idx], items[idx - 1]];
|
|
}
|
|
}
|
|
|
|
this.render();
|
|
this.selectElementById(this.selectedData.type, this.selectedData.data.id);
|
|
},
|
|
|
|
// Move down
|
|
moveDown() {
|
|
if (!this.selectedData) return;
|
|
this.saveState();
|
|
|
|
if (this.selectedData.type === 'category') {
|
|
const idx = this.menu.categories.findIndex(c => c.id === this.selectedData.data.id);
|
|
if (idx < this.menu.categories.length - 1) {
|
|
[this.menu.categories[idx], this.menu.categories[idx + 1]] =
|
|
[this.menu.categories[idx + 1], this.menu.categories[idx]];
|
|
}
|
|
} else if (this.selectedData.type === 'item') {
|
|
const items = this.selectedData.category.items;
|
|
const idx = items.findIndex(i => i.id === this.selectedData.data.id);
|
|
if (idx < items.length - 1) {
|
|
[items[idx], items[idx + 1]] = [items[idx + 1], items[idx]];
|
|
}
|
|
}
|
|
|
|
this.render();
|
|
this.selectElementById(this.selectedData.type, this.selectedData.data.id);
|
|
},
|
|
|
|
// Create photo task
|
|
createPhotoTask(itemId = null) {
|
|
const id = itemId || (this.selectedData?.type === 'item' ? this.selectedData.data.id : null);
|
|
if (!id) {
|
|
this.toast('Select an item first', 'error');
|
|
return;
|
|
}
|
|
|
|
// Find item
|
|
let item = null;
|
|
for (const cat of this.menu.categories) {
|
|
const found = cat.items.find(i => i.id === id);
|
|
if (found) {
|
|
item = found;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!item) return;
|
|
|
|
document.getElementById('modalTitle').textContent = 'Create Photo Task';
|
|
document.getElementById('modalBody').innerHTML = `
|
|
<form id="photoTaskForm" class="form">
|
|
<div class="form-group">
|
|
<label>Item</label>
|
|
<input type="text" class="form-input" value="${this.escapeHtml(item.name)}" disabled>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Task Type</label>
|
|
<select id="taskType" class="form-select">
|
|
<option value="employee">Employee Task</option>
|
|
<option value="user">User Participation (earn PYT)</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group" id="pytRewardGroup" style="display: none;">
|
|
<label>PYT Reward</label>
|
|
<input type="number" id="pytReward" class="form-input" value="10" min="1" max="100">
|
|
<small style="color: var(--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');
|
|
}
|
|
},
|
|
|
|
// Upload photo
|
|
uploadPhoto(itemId) {
|
|
// Create file input
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.accept = 'image/*';
|
|
input.onchange = async (e) => {
|
|
const file = e.target.files[0];
|
|
if (file) {
|
|
// For demo, just use a data URL
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
for (const cat of this.menu.categories) {
|
|
const item = cat.items.find(i => i.id === itemId);
|
|
if (item) {
|
|
item.imageUrl = reader.result;
|
|
break;
|
|
}
|
|
}
|
|
this.render();
|
|
this.toast('Photo uploaded', 'success');
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
};
|
|
input.click();
|
|
},
|
|
|
|
// Show import modal
|
|
showImportModal() {
|
|
document.getElementById('modalTitle').textContent = 'Import Menu JSON';
|
|
document.getElementById('modalBody').innerHTML = `
|
|
<form id="importForm" class="form">
|
|
<div class="form-group">
|
|
<label>Paste JSON data</label>
|
|
<textarea id="importData" class="form-textarea import-export-area"
|
|
placeholder='{"categories": [{"name": "...", "items": [...]}]}'></textarea>
|
|
</div>
|
|
<div style="display: flex; gap: 8px;">
|
|
<button type="button" class="btn btn-secondary" onclick="MenuBuilder.loadSampleMenu()">
|
|
Load Sample
|
|
</button>
|
|
<button type="submit" class="btn btn-primary">Import</button>
|
|
</div>
|
|
</form>
|
|
`;
|
|
this.showModal();
|
|
|
|
document.getElementById('importForm').addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
this.importMenu();
|
|
});
|
|
},
|
|
|
|
// Import menu
|
|
importMenu() {
|
|
try {
|
|
const data = JSON.parse(document.getElementById('importData').value);
|
|
this.saveState();
|
|
|
|
// Handle different JSON formats
|
|
if (data.categories) {
|
|
this.menu = data;
|
|
} else if (Array.isArray(data)) {
|
|
// Array of categories
|
|
this.menu.categories = data.map((cat, i) => ({
|
|
id: this.generateId(),
|
|
name: cat.name || cat.category || `Category ${i + 1}`,
|
|
description: cat.description || '',
|
|
sortOrder: i,
|
|
items: (cat.items || cat.children || []).map((item, j) => ({
|
|
id: this.generateId(),
|
|
name: item.name || item.title || 'Item',
|
|
description: item.description || '',
|
|
price: parseFloat(item.price) || 0,
|
|
imageUrl: item.imageUrl || item.image || null,
|
|
photoTaskId: null,
|
|
modifiers: (item.modifiers || item.children || []).map((mod, k) => ({
|
|
id: this.generateId(),
|
|
name: mod.name || 'Modifier',
|
|
price: parseFloat(mod.price) || 0,
|
|
isDefault: mod.isDefault || mod.checked || false,
|
|
sortOrder: k
|
|
})),
|
|
sortOrder: j
|
|
}))
|
|
}));
|
|
}
|
|
|
|
// Ensure all IDs are unique
|
|
this.menu.categories.forEach(cat => {
|
|
if (!cat.id || typeof cat.id === 'number') cat.id = this.generateId();
|
|
cat.items.forEach(item => {
|
|
if (!item.id || typeof item.id === 'number') item.id = this.generateId();
|
|
if (item.modifiers) {
|
|
item.modifiers.forEach(mod => {
|
|
if (!mod.id || typeof mod.id === 'number') mod.id = this.generateId();
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
this.closeModal();
|
|
this.render();
|
|
this.toast(`Imported ${this.menu.categories.length} categories`, 'success');
|
|
} catch (err) {
|
|
console.error('[MenuBuilder] Import error:', err);
|
|
this.toast('Invalid JSON format', 'error');
|
|
}
|
|
},
|
|
|
|
// Load sample menu
|
|
loadSampleMenu() {
|
|
const sample = {
|
|
categories: [
|
|
{
|
|
name: "Appetizers",
|
|
description: "Start your meal right",
|
|
items: [
|
|
{ name: "Hummus", description: "Classic chickpea dip with olive oil", price: 8.99 },
|
|
{ name: "Falafel", description: "Crispy fried chickpea balls", price: 7.99 },
|
|
{ name: "Baba Ganoush", description: "Smoky eggplant dip", price: 9.99 }
|
|
]
|
|
},
|
|
{
|
|
name: "Entrees",
|
|
description: "Main dishes",
|
|
items: [
|
|
{ name: "Chicken Shawarma", description: "Slow-roasted chicken with garlic sauce", price: 14.99 },
|
|
{ name: "Lamb Kebab", description: "Grilled lamb skewers", price: 16.99 },
|
|
{ name: "Mixed Grill", description: "Assortment of grilled meats", price: 22.99 }
|
|
]
|
|
},
|
|
{
|
|
name: "Beverages",
|
|
description: "Drinks",
|
|
items: [
|
|
{ name: "Mint Lemonade", description: "Fresh squeezed with mint", price: 4.99 },
|
|
{ name: "Turkish Coffee", description: "Traditional preparation", price: 3.99 }
|
|
]
|
|
}
|
|
]
|
|
};
|
|
document.getElementById('importData').value = JSON.stringify(sample, null, 2);
|
|
},
|
|
|
|
// Show export modal
|
|
showExportModal() {
|
|
document.getElementById('modalTitle').textContent = 'Export Menu JSON';
|
|
document.getElementById('modalBody').innerHTML = `
|
|
<div class="form-group">
|
|
<label>Menu Data</label>
|
|
<textarea id="exportData" class="form-textarea import-export-area" readonly>${JSON.stringify(this.menu, null, 2)}</textarea>
|
|
</div>
|
|
<div style="display: flex; gap: 8px;">
|
|
<button class="btn btn-secondary" onclick="MenuBuilder.copyExport()">
|
|
Copy to Clipboard
|
|
</button>
|
|
<button class="btn btn-primary" onclick="MenuBuilder.downloadExport()">
|
|
Download JSON
|
|
</button>
|
|
</div>
|
|
`;
|
|
this.showModal();
|
|
},
|
|
|
|
// Copy export
|
|
copyExport() {
|
|
const textarea = document.getElementById('exportData');
|
|
textarea.select();
|
|
document.execCommand('copy');
|
|
this.toast('Copied to clipboard', 'success');
|
|
},
|
|
|
|
// Download export
|
|
downloadExport() {
|
|
const data = document.getElementById('exportData').value;
|
|
const blob = new Blob([data], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'menu-export.json';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
},
|
|
|
|
// Show outline modal - full menu in hierarchical text format
|
|
showOutlineModal() {
|
|
document.getElementById('modalTitle').textContent = 'Menu Outline';
|
|
|
|
let outline = '';
|
|
const categories = this.menu.categories || [];
|
|
|
|
if (categories.length === 0) {
|
|
outline = '<div style="color: var(--gray-500); text-align: center; padding: 40px;">No menu items yet</div>';
|
|
} else {
|
|
outline = '<div class="menu-outline">';
|
|
|
|
for (const cat of categories) {
|
|
outline += `<div class="outline-category">${this.escapeHtml(cat.name)}</div>`;
|
|
|
|
const items = cat.items || [];
|
|
for (const item of items) {
|
|
const itemPrice = item.price ? `$${parseFloat(item.price).toFixed(2)}` : '';
|
|
outline += `<div class="outline-item">${this.escapeHtml(item.name)}${itemPrice ? ' <span class="outline-price">' + itemPrice + '</span>' : ''}</div>`;
|
|
|
|
// Render modifiers recursively
|
|
outline += this.renderOutlineModifiers(item.modifiers || [], 2);
|
|
}
|
|
}
|
|
|
|
outline += '</div>';
|
|
}
|
|
|
|
document.getElementById('modalBody').innerHTML = `
|
|
<style>
|
|
.menu-outline {
|
|
font-family: monospace;
|
|
font-size: 13px;
|
|
line-height: 1.6;
|
|
max-height: 60vh;
|
|
overflow-y: auto;
|
|
padding: 16px;
|
|
background: var(--bg-secondary);
|
|
border-radius: 8px;
|
|
}
|
|
.outline-category {
|
|
font-weight: bold;
|
|
font-size: 15px;
|
|
color: var(--primary);
|
|
margin-top: 16px;
|
|
margin-bottom: 4px;
|
|
text-transform: uppercase;
|
|
}
|
|
.outline-category:first-child {
|
|
margin-top: 0;
|
|
}
|
|
.outline-item {
|
|
padding-left: 20px;
|
|
color: var(--text-primary);
|
|
}
|
|
.outline-modifier {
|
|
color: var(--text-muted);
|
|
}
|
|
.outline-price {
|
|
color: var(--success);
|
|
}
|
|
.outline-modifier .outline-price {
|
|
color: var(--warning);
|
|
}
|
|
</style>
|
|
${outline}
|
|
`;
|
|
this.showModal();
|
|
},
|
|
|
|
// Helper to render modifiers recursively for outline
|
|
renderOutlineModifiers(modifiers, depth) {
|
|
if (!modifiers || modifiers.length === 0) return '';
|
|
|
|
let html = '';
|
|
const indent = '\u00A0\u00A0\u00A0\u00A0'.repeat(depth); // non-breaking spaces for indent
|
|
|
|
for (const mod of modifiers) {
|
|
const modPrice = mod.price ? `+$${parseFloat(mod.price).toFixed(2)}` : '';
|
|
html += `<div class="outline-modifier" style="padding-left: ${depth * 20}px;">${indent.substring(0, 4)}${this.escapeHtml(mod.name)}${modPrice ? ' <span class="outline-price">' + modPrice + '</span>' : ''}</div>`;
|
|
|
|
// Recurse into nested options
|
|
if (mod.options && mod.options.length > 0) {
|
|
html += this.renderOutlineModifiers(mod.options, depth + 1);
|
|
}
|
|
}
|
|
|
|
return html;
|
|
},
|
|
|
|
// Upload header image (1200x400)
|
|
uploadHeader() {
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
|
|
input.onchange = async (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
|
|
// Validate file type
|
|
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
|
if (!validTypes.includes(file.type)) {
|
|
this.toast('Please select a valid image (JPG, PNG, GIF, or WebP)', 'error');
|
|
return;
|
|
}
|
|
|
|
// Validate file size (max 5MB)
|
|
if (file.size > 5 * 1024 * 1024) {
|
|
this.toast('Image must be under 5MB', 'error');
|
|
return;
|
|
}
|
|
|
|
// Show loading toast
|
|
this.toast('Uploading header...', 'info');
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('header', file);
|
|
formData.append('BusinessID', this.config.businessId);
|
|
|
|
const response = await fetch(`${this.config.apiBaseUrl}/menu/uploadHeader.cfm`, {
|
|
method: 'POST',
|
|
body: formData,
|
|
credentials: 'include'
|
|
});
|
|
|
|
const data = await response.json();
|
|
if (data.OK) {
|
|
this.toast('Header uploaded successfully! Recommended size: 600x200 JPG', 'success');
|
|
} else {
|
|
this.toast(data.MESSAGE || 'Failed to upload header', 'error');
|
|
}
|
|
} catch (err) {
|
|
console.error('Header upload error:', err);
|
|
this.toast('Failed to upload header', 'error');
|
|
}
|
|
};
|
|
input.click();
|
|
},
|
|
|
|
// Show brand color picker modal
|
|
showBrandColorPicker() {
|
|
const currentColor = this.brandColor || '#1B4D3E';
|
|
document.getElementById('modalTitle').textContent = 'Brand Color';
|
|
document.getElementById('modalBody').innerHTML = `
|
|
<p style="margin-bottom: 16px; color: var(--text-muted);">
|
|
This color is used for the category bar gradients in the customer app.
|
|
</p>
|
|
<div style="display: flex; align-items: center; gap: 16px; margin-bottom: 16px;">
|
|
<input type="color" id="brandColorInput" value="${currentColor}"
|
|
style="width: 60px; height: 40px; border: none; cursor: pointer; border-radius: 4px;">
|
|
<input type="text" id="brandColorHex" value="${currentColor}"
|
|
style="width: 100px; padding: 8px; border: 1px solid var(--border); border-radius: 4px; font-family: monospace;"
|
|
pattern="^#[0-9A-Fa-f]{6}$" maxlength="7">
|
|
<div id="brandColorPreview" style="flex: 1; height: 40px; border-radius: 4px; background: linear-gradient(to bottom, ${currentColor}44, ${currentColor}00, ${currentColor}66);"></div>
|
|
</div>
|
|
<div style="display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px;">
|
|
<button type="button" class="color-preset" data-color="#1B4D3E" style="width: 32px; height: 32px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; background: #1B4D3E;" title="Forest Green (Default)"></button>
|
|
<button type="button" class="color-preset" data-color="#2C3E50" style="width: 32px; height: 32px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; background: #2C3E50;" title="Midnight Blue"></button>
|
|
<button type="button" class="color-preset" data-color="#8B4513" style="width: 32px; height: 32px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; background: #8B4513;" title="Saddle Brown"></button>
|
|
<button type="button" class="color-preset" data-color="#800020" style="width: 32px; height: 32px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; background: #800020;" title="Burgundy"></button>
|
|
<button type="button" class="color-preset" data-color="#1A1A2E" style="width: 32px; height: 32px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; background: #1A1A2E;" title="Dark Navy"></button>
|
|
<button type="button" class="color-preset" data-color="#4A0E4E" style="width: 32px; height: 32px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; background: #4A0E4E;" title="Deep Purple"></button>
|
|
<button type="button" class="color-preset" data-color="#CC5500" style="width: 32px; height: 32px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; background: #CC5500;" title="Burnt Orange"></button>
|
|
<button type="button" class="color-preset" data-color="#355E3B" style="width: 32px; height: 32px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; background: #355E3B;" title="Hunter Green"></button>
|
|
</div>
|
|
<div style="display: flex; gap: 8px; justify-content: flex-end;">
|
|
<button type="button" class="btn btn-secondary" onclick="MenuBuilder.closeModal()">Cancel</button>
|
|
<button type="button" class="btn btn-primary" onclick="MenuBuilder.saveBrandColor()">Save Color</button>
|
|
</div>
|
|
`;
|
|
this.showModal();
|
|
|
|
// Wire up color picker sync
|
|
const colorInput = document.getElementById('brandColorInput');
|
|
const hexInput = document.getElementById('brandColorHex');
|
|
const preview = document.getElementById('brandColorPreview');
|
|
|
|
const updatePreview = (color) => {
|
|
preview.style.background = `linear-gradient(to bottom, ${color}44, ${color}00, ${color}66)`;
|
|
};
|
|
|
|
colorInput.addEventListener('input', (e) => {
|
|
hexInput.value = e.target.value.toUpperCase();
|
|
updatePreview(e.target.value);
|
|
});
|
|
|
|
hexInput.addEventListener('input', (e) => {
|
|
let val = e.target.value;
|
|
if (!val.startsWith('#')) val = '#' + val;
|
|
if (/^#[0-9A-Fa-f]{6}$/.test(val)) {
|
|
colorInput.value = val;
|
|
updatePreview(val);
|
|
}
|
|
});
|
|
|
|
// Preset buttons
|
|
document.querySelectorAll('.color-preset').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const color = btn.dataset.color;
|
|
colorInput.value = color;
|
|
hexInput.value = color;
|
|
updatePreview(color);
|
|
});
|
|
});
|
|
},
|
|
|
|
// Save brand color
|
|
async saveBrandColor() {
|
|
const color = document.getElementById('brandColorHex').value.toUpperCase();
|
|
if (!/^#[0-9A-Fa-f]{6}$/.test(color)) {
|
|
this.toast('Invalid color format. Use #RRGGBB', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${this.config.apiBaseUrl}/businesses/saveBrandColor.cfm`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ BusinessID: this.config.businessId, BrandColor: color })
|
|
});
|
|
const data = await response.json();
|
|
if (data.OK) {
|
|
this.brandColor = color;
|
|
document.getElementById('brandColorSwatch').style.background = color;
|
|
this.closeModal();
|
|
this.toast('Brand color saved!', 'success');
|
|
} else {
|
|
this.toast(data.MESSAGE || 'Failed to save color', 'error');
|
|
}
|
|
} catch (err) {
|
|
console.error('Save brand color error:', err);
|
|
this.toast('Failed to save color', 'error');
|
|
}
|
|
},
|
|
|
|
// Load menu from API
|
|
async loadMenu(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.updateMenuSelector();
|
|
|
|
// Store templates from API (default to empty array if not provided)
|
|
this.templates = data.TEMPLATES || [];
|
|
// Load brand color if set
|
|
if (data.BRANDCOLOR && data.BRANDCOLOR.length > 0) {
|
|
this.brandColor = data.BRANDCOLOR;
|
|
const swatch = document.getElementById('brandColorSwatch');
|
|
if (swatch) swatch.style.background = data.BRANDCOLOR;
|
|
}
|
|
this.renderTemplateLibrary();
|
|
this.render();
|
|
} else {
|
|
console.log('[MenuBuilder] No MENU in response or OK=false');
|
|
// Still clear the loading message
|
|
this.templates = [];
|
|
this.menus = data.MENUS || [];
|
|
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 Categories</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;">
|
|
<div style="flex: 1;">
|
|
<div style="font-weight: 600;">${this.escapeHtml(menu.MenuName)}</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>
|
|
<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>
|
|
`;
|
|
this.showModal('Manage Menus', content, [
|
|
{ text: 'Close', primary: false, action: () => this.closeModal() }
|
|
]);
|
|
},
|
|
|
|
// 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) {
|
|
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');
|
|
}
|
|
},
|
|
|
|
// 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));
|
|
|
|
const payload = {
|
|
BusinessID: this.config.businessId,
|
|
Menu: this.menu
|
|
};
|
|
console.log('[MenuBuilder] Full payload:', JSON.stringify(payload, null, 2));
|
|
|
|
const response = await fetch(`${this.config.apiBaseUrl}/menu/saveFromBuilder.cfm`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
console.log('[MenuBuilder] Response status:', response.status);
|
|
const responseText = await response.text();
|
|
console.log('[MenuBuilder] Raw response:', responseText);
|
|
|
|
let data;
|
|
try {
|
|
data = JSON.parse(responseText);
|
|
} catch (parseErr) {
|
|
console.error('[MenuBuilder] Failed to parse response as JSON:', parseErr);
|
|
this.toast('Server returned invalid response', 'error');
|
|
return;
|
|
}
|
|
|
|
console.log('[MenuBuilder] Parsed response:', data);
|
|
|
|
if (data.OK) {
|
|
this.toast('Menu saved successfully!', 'success');
|
|
// Reload menu to get updated dbIds
|
|
await this.loadMenu();
|
|
} else {
|
|
console.error('[MenuBuilder] Save failed:', data.ERROR, data.DETAIL);
|
|
this.toast(data.ERROR || 'Failed to save', 'error');
|
|
}
|
|
} catch (err) {
|
|
console.error('[MenuBuilder] Save error:', err);
|
|
this.toast('Error saving menu: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
// Recursive function to render modifiers/options at any depth
|
|
renderModifiers(modifiers, parentItemId, depth) {
|
|
if (!modifiers || modifiers.length === 0) return '';
|
|
|
|
const indent = 16 + (depth * 16); // Progressive indentation
|
|
const iconSize = Math.max(24, 32 - (depth * 4)); // Smaller icons for deeper levels
|
|
const icons = ['⚙️', '🔘', '◉', '•']; // Different icons for different levels
|
|
const icon = icons[Math.min(depth - 1, icons.length - 1)];
|
|
|
|
return modifiers.map(mod => {
|
|
const hasOptions = mod.options && mod.options.length > 0;
|
|
const modExpanded = this.expandedModifierIds.has(mod.id);
|
|
return `
|
|
<div class="item-card modifier depth-${depth} ${modExpanded ? 'expanded' : ''}"
|
|
data-modifier-id="${mod.id}" data-parent-id="${parentItemId}" data-depth="${depth}"
|
|
draggable="true"
|
|
style="margin-left: ${indent}px;"
|
|
onclick="event.stopPropagation(); MenuBuilder.selectOption('${parentItemId}', '${mod.id}', ${depth})">
|
|
${hasOptions ? `
|
|
<div class="item-toggle ${modExpanded ? 'expanded' : ''}" onclick="event.stopPropagation(); MenuBuilder.toggleModifier('${mod.id}')">
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M9 18l6-6-6-6"/>
|
|
</svg>
|
|
</div>
|
|
` : ''}
|
|
<div class="drag-handle">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
|
<circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/>
|
|
<circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/>
|
|
<circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/>
|
|
</svg>
|
|
</div>
|
|
<div class="item-image" style="width: ${iconSize}px; height: ${iconSize}px; font-size: ${iconSize/2}px;">${icon}</div>
|
|
<div class="item-info" onclick="event.stopPropagation(); ${hasOptions ? `MenuBuilder.toggleModifier('${mod.id}')` : `MenuBuilder.selectOption('${parentItemId}', '${mod.id}', ${depth})`}" style="cursor: pointer;">
|
|
<div class="item-name">${this.escapeHtml(mod.name)}</div>
|
|
<div class="item-meta">
|
|
${mod.price > 0 ? `<span>+$${mod.price.toFixed(2)}</span>` : ''}
|
|
${mod.isDefault ? '<span class="item-type-badge">Default</span>' : ''}
|
|
${mod.requiresSelection ? '<span class="item-type-badge" style="background: #dc3545; color: white;">Required</span>' : ''}
|
|
${mod.maxSelections > 0 ? `<span class="item-type-badge">Max ${mod.maxSelections}</span>` : ''}
|
|
${hasOptions ? `<span class="item-type-badge">${mod.options.length} options</span>` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="item-actions">
|
|
<button onclick="event.stopPropagation(); MenuBuilder.deleteOption('${parentItemId}', '${mod.id}', ${depth})" title="Delete">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M18 6L6 18M6 6l12 12"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
${hasOptions && modExpanded ? this.renderModifiers(mod.options, mod.id, depth + 1) : ''}
|
|
`;
|
|
}).join('');
|
|
},
|
|
|
|
// Render menu structure
|
|
// Render menu structure
|
|
render() {
|
|
const container = document.getElementById('menuStructure');
|
|
|
|
if (this.menu.categories.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="empty-canvas" id="emptyCanvas">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<path d="M3 6h18M3 12h18M3 18h18"/>
|
|
</svg>
|
|
<h3>Start Building Your Menu</h3>
|
|
<p>Drag categories and items from the left panel, or click the buttons above to add them.</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = this.menu.categories.map(category => {
|
|
const isExpanded = this.expandedCategoryId === category.id;
|
|
return `
|
|
<div class="category-card" data-category-id="${category.id}" draggable="true"
|
|
onclick="MenuBuilder.selectElement(this)">
|
|
<div class="category-header">
|
|
<div class="category-toggle ${isExpanded ? 'expanded' : ''}" onclick="event.stopPropagation(); MenuBuilder.toggleCategory('${category.id}')">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M9 18l6-6-6-6"/>
|
|
</svg>
|
|
</div>
|
|
<div class="drag-handle">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
|
<circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/>
|
|
<circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/>
|
|
<circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/>
|
|
</svg>
|
|
</div>
|
|
<div class="category-info" onclick="event.stopPropagation(); MenuBuilder.toggleCategory('${category.id}')" style="cursor: pointer;">
|
|
<div class="category-name">${this.escapeHtml(category.name)}</div>
|
|
<div class="category-count">${category.items.length} items</div>
|
|
</div>
|
|
<div class="category-actions">
|
|
<button onclick="event.stopPropagation(); MenuBuilder.addItem('${category.id}')" title="Add Item">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M12 5v14M5 12h14"/>
|
|
</svg>
|
|
</button>
|
|
<button class="danger" onclick="event.stopPropagation(); MenuBuilder.deleteCategory('${category.id}')" title="Delete">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="items-list ${isExpanded ? '' : 'collapsed'}">
|
|
${category.items.length === 0 ? `
|
|
<div class="item-drop-zone">Drag items here or click + to add</div>
|
|
` : category.items.map(item => {
|
|
const itemExpanded = this.expandedItemId === item.id;
|
|
const hasModifiers = item.modifiers && item.modifiers.length > 0;
|
|
return `
|
|
<div class="item-card ${itemExpanded ? 'expanded' : ''}" data-item-id="${item.id}" draggable="true"
|
|
onclick="event.stopPropagation(); MenuBuilder.selectElement(this)">
|
|
${hasModifiers ? `
|
|
<div class="item-toggle ${itemExpanded ? 'expanded' : ''}" onclick="event.stopPropagation(); MenuBuilder.toggleItem('${item.id}')">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M9 18l6-6-6-6"/>
|
|
</svg>
|
|
</div>
|
|
` : ''}
|
|
<div class="drag-handle">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
|
<circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/>
|
|
<circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/>
|
|
<circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/>
|
|
</svg>
|
|
</div>
|
|
<div class="item-image">
|
|
${item.imageUrl ? `<img src="${item.imageUrl}" alt="">` : '🍽️'}
|
|
${item.photoTaskId ? `
|
|
<div class="photo-badge ${item.imageUrl ? '' : 'missing'}" title="${item.imageUrl ? 'Has photo' : 'Photo task pending'}">
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
|
<circle cx="8.5" cy="8.5" r="1.5"/>
|
|
<path d="M21 15l-5-5L5 21"/>
|
|
</svg>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
<div class="item-info" onclick="event.stopPropagation(); ${hasModifiers ? `MenuBuilder.toggleItem('${item.id}')` : `MenuBuilder.selectItem('${item.id}')`}" style="cursor: pointer;">
|
|
<div class="item-name">${this.escapeHtml(item.name)}</div>
|
|
<div class="item-meta">
|
|
<span class="item-price">$${(item.price || 0).toFixed(2)}</span>
|
|
${hasModifiers ? `<span>${item.modifiers.length} modifiers</span>` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="item-actions">
|
|
<button onclick="event.stopPropagation(); MenuBuilder.cloneItem('${category.id}', '${item.id}')" title="Clone">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<rect x="9" y="9" width="13" height="13" rx="2"/>
|
|
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
|
|
</svg>
|
|
</button>
|
|
<button class="danger" onclick="event.stopPropagation(); MenuBuilder.deleteItem('${category.id}', '${item.id}')" title="Delete">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M18 6L6 18M6 6l12 12"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
${itemExpanded ? this.renderModifiers(item.modifiers, item.id, 1) : ''}
|
|
`}).join('')}
|
|
</div>
|
|
</div>
|
|
`}).join('');
|
|
|
|
// Update stats
|
|
let totalItems = 0;
|
|
let missingPhotos = 0;
|
|
this.menu.categories.forEach(cat => {
|
|
totalItems += cat.items.length;
|
|
cat.items.forEach(item => {
|
|
if (!item.imageUrl) missingPhotos++;
|
|
});
|
|
});
|
|
|
|
document.getElementById('statCategories').textContent = this.menu.categories.length;
|
|
document.getElementById('statItems').textContent = totalItems;
|
|
document.getElementById('statPhotosMissing').textContent = missingPhotos;
|
|
|
|
// Re-setup drag handlers for new elements
|
|
this.setupItemDragHandlers();
|
|
},
|
|
|
|
// Setup drag handlers for items
|
|
setupItemDragHandlers() {
|
|
document.querySelectorAll('.category-card').forEach(card => {
|
|
card.addEventListener('dragstart', (e) => {
|
|
e.dataTransfer.setData('categoryId', card.dataset.categoryId);
|
|
e.dataTransfer.setData('source', 'canvas');
|
|
card.classList.add('dragging');
|
|
});
|
|
|
|
card.addEventListener('dragend', () => {
|
|
card.classList.remove('dragging');
|
|
document.querySelectorAll('.drop-before, .drop-after').forEach(el => {
|
|
el.classList.remove('drop-before', 'drop-after');
|
|
});
|
|
});
|
|
|
|
card.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
const isDraggingCategory = e.dataTransfer.types.includes('categoryid');
|
|
const isDraggingItem = e.dataTransfer.types.includes('itemid');
|
|
|
|
if (isDraggingItem) {
|
|
card.classList.add('drag-over');
|
|
} else if (isDraggingCategory && !card.classList.contains('dragging')) {
|
|
// Category reordering - show drop indicator
|
|
const rect = card.getBoundingClientRect();
|
|
const midpoint = rect.top + rect.height / 2;
|
|
card.classList.remove('drop-before', 'drop-after', 'drag-over');
|
|
if (e.clientY < midpoint) {
|
|
card.classList.add('drop-before');
|
|
} else {
|
|
card.classList.add('drop-after');
|
|
}
|
|
}
|
|
});
|
|
|
|
card.addEventListener('dragleave', () => {
|
|
card.classList.remove('drag-over', 'drop-before', 'drop-after');
|
|
});
|
|
|
|
card.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
card.classList.remove('drag-over', 'drop-before', 'drop-after');
|
|
|
|
const itemId = e.dataTransfer.getData('itemId');
|
|
const draggedCategoryId = e.dataTransfer.getData('categoryId');
|
|
const targetCategoryId = card.dataset.categoryId;
|
|
|
|
if (itemId) {
|
|
// Moving item to category
|
|
this.moveItemToCategory(itemId, targetCategoryId);
|
|
} else if (draggedCategoryId && draggedCategoryId !== targetCategoryId) {
|
|
// Reordering categories
|
|
const rect = card.getBoundingClientRect();
|
|
const midpoint = rect.top + rect.height / 2;
|
|
const position = e.clientY < midpoint ? 'before' : 'after';
|
|
this.reorderCategory(draggedCategoryId, targetCategoryId, position);
|
|
}
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('.item-card:not(.modifier)').forEach(card => {
|
|
card.addEventListener('dragstart', (e) => {
|
|
e.stopPropagation();
|
|
e.dataTransfer.setData('itemId', card.dataset.itemId);
|
|
e.dataTransfer.setData('source', 'canvas');
|
|
card.classList.add('dragging');
|
|
});
|
|
|
|
card.addEventListener('dragend', () => {
|
|
card.classList.remove('dragging');
|
|
// Remove all drop indicators
|
|
document.querySelectorAll('.drop-before, .drop-after').forEach(el => {
|
|
el.classList.remove('drop-before', 'drop-after');
|
|
});
|
|
});
|
|
|
|
card.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const draggedItemId = e.dataTransfer.types.includes('itemid');
|
|
if (draggedItemId && !card.classList.contains('dragging')) {
|
|
// Determine if dropping above or below based on mouse position
|
|
const rect = card.getBoundingClientRect();
|
|
const midpoint = rect.top + rect.height / 2;
|
|
card.classList.remove('drop-before', 'drop-after');
|
|
if (e.clientY < midpoint) {
|
|
card.classList.add('drop-before');
|
|
} else {
|
|
card.classList.add('drop-after');
|
|
}
|
|
}
|
|
});
|
|
|
|
card.addEventListener('dragleave', () => {
|
|
card.classList.remove('drop-before', 'drop-after');
|
|
});
|
|
|
|
card.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const draggedItemId = e.dataTransfer.getData('itemId');
|
|
const targetItemId = card.dataset.itemId;
|
|
if (draggedItemId && targetItemId && draggedItemId !== targetItemId) {
|
|
const position = card.classList.contains('drop-before') ? 'before' : 'after';
|
|
this.reorderItemInCategory(draggedItemId, targetItemId, position);
|
|
}
|
|
card.classList.remove('drop-before', 'drop-after');
|
|
});
|
|
});
|
|
|
|
// Modifier drag handlers
|
|
document.querySelectorAll('.item-card.modifier').forEach(card => {
|
|
card.addEventListener('dragstart', (e) => {
|
|
e.stopPropagation();
|
|
e.dataTransfer.setData('modifierId', card.dataset.modifierId);
|
|
e.dataTransfer.setData('parentId', card.dataset.parentId);
|
|
e.dataTransfer.setData('depth', card.dataset.depth);
|
|
e.dataTransfer.setData('source', 'modifier');
|
|
// Default is copy, hold Shift for move
|
|
const isMove = e.shiftKey;
|
|
e.dataTransfer.setData('isMove', isMove ? 'true' : 'false');
|
|
e.dataTransfer.effectAllowed = isMove ? 'move' : 'copy';
|
|
card.classList.add('dragging');
|
|
});
|
|
|
|
card.addEventListener('dragend', () => {
|
|
card.classList.remove('dragging');
|
|
document.querySelectorAll('.drop-before, .drop-after, .drag-over').forEach(el => {
|
|
el.classList.remove('drop-before', 'drop-after', 'drag-over');
|
|
});
|
|
});
|
|
|
|
card.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const isDraggingModifier = e.dataTransfer.types.includes('modifierid');
|
|
if (isDraggingModifier && !card.classList.contains('dragging')) {
|
|
const rect = card.getBoundingClientRect();
|
|
const midpoint = rect.top + rect.height / 2;
|
|
card.classList.remove('drop-before', 'drop-after');
|
|
if (e.clientY < midpoint) {
|
|
card.classList.add('drop-before');
|
|
} else {
|
|
card.classList.add('drop-after');
|
|
}
|
|
}
|
|
});
|
|
|
|
card.addEventListener('dragleave', (e) => {
|
|
card.classList.remove('drop-before', 'drop-after');
|
|
});
|
|
|
|
card.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const draggedModId = e.dataTransfer.getData('modifierId');
|
|
const draggedParentId = e.dataTransfer.getData('parentId');
|
|
const targetModId = card.dataset.modifierId;
|
|
const targetParentId = card.dataset.parentId;
|
|
const isMove = e.dataTransfer.getData('isMove') === 'true';
|
|
|
|
if (draggedModId && targetModId && draggedModId !== targetModId) {
|
|
const position = card.classList.contains('drop-before') ? 'before' : 'after';
|
|
|
|
if (isMove && draggedParentId === targetParentId) {
|
|
// Same parent + move = reorder
|
|
this.reorderModifier(draggedModId, targetModId, targetParentId, position);
|
|
} else if (isMove) {
|
|
// Different parent + move = move modifier
|
|
this.moveModifier(draggedModId, draggedParentId, targetModId, targetParentId, position);
|
|
} else {
|
|
// Copy (default)
|
|
this.copyModifier(draggedModId, draggedParentId, targetModId, targetParentId, position);
|
|
}
|
|
}
|
|
card.classList.remove('drop-before', 'drop-after');
|
|
});
|
|
});
|
|
|
|
// Allow dropping modifiers onto items (to add as top-level modifier)
|
|
document.querySelectorAll('.item-card:not(.modifier)').forEach(card => {
|
|
card.addEventListener('dragover', (e) => {
|
|
const isDraggingModifier = e.dataTransfer.types.includes('modifierid');
|
|
if (isDraggingModifier) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
card.classList.add('drag-over');
|
|
}
|
|
});
|
|
|
|
card.addEventListener('dragleave', (e) => {
|
|
if (e.dataTransfer.types.includes('modifierid')) {
|
|
card.classList.remove('drag-over');
|
|
}
|
|
});
|
|
|
|
card.addEventListener('drop', (e) => {
|
|
const isDraggingModifier = e.dataTransfer.types.includes('modifierid');
|
|
if (isDraggingModifier) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const draggedModId = e.dataTransfer.getData('modifierId');
|
|
const draggedParentId = e.dataTransfer.getData('parentId');
|
|
const targetItemId = card.dataset.itemId;
|
|
const isMove = e.dataTransfer.getData('isMove') === 'true';
|
|
|
|
if (draggedModId && targetItemId) {
|
|
if (isMove) {
|
|
this.moveModifierToItem(draggedModId, draggedParentId, targetItemId);
|
|
} else {
|
|
this.copyModifierToItem(draggedModId, draggedParentId, targetItemId);
|
|
}
|
|
}
|
|
card.classList.remove('drag-over');
|
|
}
|
|
});
|
|
});
|
|
},
|
|
|
|
// Toggle category expanded/collapsed (accordion behavior)
|
|
toggleCategory(categoryId) {
|
|
if (this.expandedCategoryId === categoryId) {
|
|
this.expandedCategoryId = null;
|
|
} else {
|
|
this.expandedCategoryId = categoryId;
|
|
this.expandedItemId = null;
|
|
this.expandedModifierIds.clear();
|
|
}
|
|
this.render();
|
|
},
|
|
|
|
// Toggle item expanded/collapsed
|
|
toggleItem(itemId) {
|
|
if (this.expandedItemId === itemId) {
|
|
this.expandedItemId = null;
|
|
} else {
|
|
this.expandedItemId = itemId;
|
|
this.expandedModifierIds.clear();
|
|
}
|
|
this.render();
|
|
},
|
|
|
|
// Toggle modifier expanded/collapsed
|
|
toggleModifier(modifierId) {
|
|
if (this.expandedModifierIds.has(modifierId)) {
|
|
this.expandedModifierIds.delete(modifierId);
|
|
} else {
|
|
this.expandedModifierIds.add(modifierId);
|
|
}
|
|
this.render();
|
|
},
|
|
|
|
// Clone item
|
|
cloneItem(categoryId, itemId) {
|
|
this.saveState();
|
|
const category = this.menu.categories.find(c => c.id === categoryId);
|
|
if (category) {
|
|
const item = category.items.find(i => i.id === itemId);
|
|
if (item) {
|
|
const clone = JSON.parse(JSON.stringify(item));
|
|
clone.id = this.generateId();
|
|
clone.name = item.name + ' (Copy)';
|
|
clone.modifiers.forEach(mod => mod.id = this.generateId());
|
|
category.items.push(clone);
|
|
this.render();
|
|
this.toast('Item cloned', 'success');
|
|
}
|
|
}
|
|
},
|
|
|
|
// Show modal
|
|
showModal() {
|
|
document.getElementById('modalOverlay').classList.add('visible');
|
|
},
|
|
|
|
// Close modal
|
|
closeModal() {
|
|
document.getElementById('modalOverlay').classList.remove('visible');
|
|
},
|
|
|
|
// Toast
|
|
toast(message, type = 'info') {
|
|
const container = document.getElementById('toastContainer');
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast ${type}`;
|
|
toast.textContent = message;
|
|
container.appendChild(toast);
|
|
setTimeout(() => toast.remove(), 3000);
|
|
},
|
|
|
|
// Escape HTML
|
|
escapeHtml(str) {
|
|
if (!str) return '';
|
|
return str.replace(/[&<>"']/g, (m) => ({
|
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
})[m]);
|
|
}
|
|
};
|
|
|
|
// Initialize
|
|
document.addEventListener('DOMContentLoaded', () => MenuBuilder.init());
|
|
</script>
|
|
</body>
|
|
</html>
|