Uses the native EyeDropper API (Chrome/Edge) to pick colors from anywhere on screen. Button only shows in supported browsers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
4780 lines
181 KiB
HTML
4780 lines
181 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/png" sizes="512x512" href="favicon-512.png">
|
||
<link rel="stylesheet" href="portal.css">
|
||
<style>
|
||
/* Menu Builder Specific Styles */
|
||
.main-content {
|
||
height: 100vh;
|
||
max-height: 100vh;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.builder-wrapper {
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.builder-container {
|
||
display: flex;
|
||
flex: 1;
|
||
min-height: 0;
|
||
gap: 16px;
|
||
padding: 16px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* Sidebar Panel */
|
||
.builder-sidebar {
|
||
width: 280px;
|
||
background: #fff;
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow);
|
||
padding: 16px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.builder-sidebar h3 {
|
||
margin: 0;
|
||
font-size: 14px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
color: var(--gray-500);
|
||
}
|
||
|
||
/* Palette Items */
|
||
.palette-section {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.palette-item {
|
||
background: var(--gray-50);
|
||
border: 2px dashed var(--gray-200);
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
cursor: grab;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.palette-item:hover {
|
||
border-color: var(--primary);
|
||
background: rgba(99, 102, 241, 0.05);
|
||
}
|
||
|
||
.palette-item:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.palette-item .icon {
|
||
width: 32px;
|
||
height: 32px;
|
||
background: #fff;
|
||
border-radius: 6px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 16px;
|
||
}
|
||
|
||
.palette-item .label {
|
||
font-weight: 500;
|
||
color: var(--gray-800);
|
||
}
|
||
|
||
.palette-item .hint {
|
||
font-size: 12px;
|
||
color: var(--gray-500);
|
||
}
|
||
|
||
/* Main Canvas */
|
||
.builder-canvas {
|
||
flex: 1;
|
||
background: #fff;
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow);
|
||
padding: 16px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.canvas-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 16px;
|
||
padding-bottom: 16px;
|
||
border-bottom: 1px solid var(--gray-200);
|
||
}
|
||
|
||
.canvas-header h2 {
|
||
margin: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
color: var(--gray-900);
|
||
}
|
||
|
||
.canvas-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
/* Menu Structure */
|
||
.menu-structure {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
min-height: 400px;
|
||
}
|
||
|
||
.drop-zone {
|
||
border: 2px dashed var(--gray-200);
|
||
border-radius: 8px;
|
||
padding: 24px;
|
||
text-align: center;
|
||
color: var(--gray-500);
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.drop-zone.drag-over {
|
||
border-color: var(--primary);
|
||
background: rgba(99, 102, 241, 0.05);
|
||
}
|
||
|
||
.drop-zone.drag-over-error {
|
||
border-color: var(--danger);
|
||
background: rgba(239, 68, 68, 0.05);
|
||
}
|
||
|
||
/* Category Card */
|
||
.category-card {
|
||
background: var(--brand-tint, var(--gray-50));
|
||
border-radius: 12px;
|
||
border: 2px solid var(--gray-200);
|
||
overflow: hidden;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.category-card.dragging {
|
||
opacity: 0.5;
|
||
transform: scale(0.98);
|
||
}
|
||
|
||
.category-card.drag-over {
|
||
border-color: var(--primary);
|
||
}
|
||
|
||
.category-header {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 12px 16px;
|
||
background: var(--brand-tint, #fff);
|
||
cursor: grab;
|
||
gap: 12px;
|
||
}
|
||
|
||
.category-header:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.drag-handle {
|
||
color: var(--gray-400);
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.category-info {
|
||
flex: 1;
|
||
}
|
||
|
||
.category-name {
|
||
font-weight: 600;
|
||
font-size: 16px;
|
||
color: var(--gray-900);
|
||
}
|
||
|
||
.category-count {
|
||
font-size: 12px;
|
||
color: var(--gray-500);
|
||
}
|
||
|
||
.category-actions {
|
||
display: flex;
|
||
gap: 4px;
|
||
}
|
||
|
||
.category-actions button {
|
||
background: transparent;
|
||
border: none;
|
||
color: var(--gray-400);
|
||
padding: 6px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.category-actions button:hover {
|
||
background: var(--gray-100);
|
||
color: var(--gray-800);
|
||
}
|
||
|
||
.category-actions button.danger:hover {
|
||
color: var(--danger);
|
||
}
|
||
|
||
/* Items List */
|
||
.items-list {
|
||
padding: 12px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
min-height: 60px;
|
||
overflow: hidden;
|
||
transition: max-height 0.3s ease, padding 0.3s ease, opacity 0.3s ease;
|
||
}
|
||
|
||
.items-list.collapsed {
|
||
max-height: 0;
|
||
padding: 0 12px;
|
||
opacity: 0;
|
||
min-height: 0;
|
||
}
|
||
|
||
/* Chevron toggle */
|
||
.category-toggle {
|
||
width: 24px;
|
||
height: 24px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
transition: transform 0.3s ease;
|
||
color: var(--gray-400);
|
||
}
|
||
|
||
.category-toggle:hover {
|
||
color: var(--gray-800);
|
||
}
|
||
|
||
.category-toggle.expanded {
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
/* Item Toggle (for expanding modifiers) */
|
||
.item-toggle {
|
||
width: 20px;
|
||
height: 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
transition: transform 0.3s ease;
|
||
color: var(--gray-400);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.item-toggle:hover {
|
||
color: var(--gray-800);
|
||
}
|
||
|
||
.item-toggle.expanded {
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
.items-list.drag-over {
|
||
background: rgba(99, 102, 241, 0.02);
|
||
}
|
||
|
||
.item-drop-zone {
|
||
border: 2px dashed var(--gray-200);
|
||
border-radius: 6px;
|
||
padding: 16px;
|
||
text-align: center;
|
||
font-size: 13px;
|
||
color: var(--gray-500);
|
||
}
|
||
|
||
/* Item Card */
|
||
.item-card {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 10px 12px;
|
||
background: var(--brand-tint, #fff);
|
||
border-radius: 8px;
|
||
border: 1px solid var(--gray-200);
|
||
cursor: grab;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.item-card:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.item-card.dragging {
|
||
opacity: 0.5;
|
||
}
|
||
|
||
.item-card.drag-over {
|
||
border-color: var(--primary);
|
||
transform: translateY(2px);
|
||
}
|
||
|
||
/* Drop indicators for reordering */
|
||
.item-card.drop-before,
|
||
.category-card.drop-before {
|
||
position: relative;
|
||
}
|
||
|
||
.item-card.drop-before::before,
|
||
.category-card.drop-before::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: -4px;
|
||
left: 0;
|
||
right: 0;
|
||
height: 4px;
|
||
background: var(--primary);
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.item-card.drop-after,
|
||
.category-card.drop-after {
|
||
position: relative;
|
||
}
|
||
|
||
.item-card.drop-after::after,
|
||
.category-card.drop-after::after {
|
||
content: '';
|
||
position: absolute;
|
||
bottom: -4px;
|
||
left: 0;
|
||
right: 0;
|
||
height: 4px;
|
||
background: var(--primary);
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.item-card.modifier {
|
||
margin-left: 24px;
|
||
background: var(--gray-50);
|
||
border-style: dashed;
|
||
}
|
||
|
||
.item-card.modifier .item-type-badge {
|
||
background: var(--warning);
|
||
color: #000;
|
||
}
|
||
|
||
.item-image {
|
||
width: 48px;
|
||
height: 48px;
|
||
background: var(--gray-100);
|
||
border-radius: 6px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 24px;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
|
||
.item-image img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.item-image .photo-badge {
|
||
position: absolute;
|
||
bottom: 2px;
|
||
right: 2px;
|
||
width: 16px;
|
||
height: 16px;
|
||
background: var(--primary);
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.item-image .photo-badge.missing {
|
||
background: var(--warning);
|
||
}
|
||
|
||
.item-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.item-name {
|
||
font-weight: 500;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
color: var(--gray-800);
|
||
}
|
||
|
||
.item-meta {
|
||
display: flex;
|
||
gap: 8px;
|
||
font-size: 12px;
|
||
color: var(--gray-500);
|
||
}
|
||
|
||
.item-price {
|
||
color: var(--primary);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.item-type-badge {
|
||
font-size: 10px;
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
background: var(--gray-100);
|
||
text-transform: uppercase;
|
||
color: var(--gray-600);
|
||
}
|
||
|
||
.item-actions {
|
||
display: flex;
|
||
gap: 2px;
|
||
}
|
||
|
||
.item-actions button {
|
||
background: transparent;
|
||
border: none;
|
||
color: var(--gray-400);
|
||
padding: 4px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.item-actions button:hover {
|
||
background: var(--gray-100);
|
||
color: var(--gray-800);
|
||
}
|
||
|
||
.item-actions button.danger:hover {
|
||
color: var(--danger);
|
||
}
|
||
|
||
/* Properties Panel */
|
||
.builder-properties {
|
||
width: 320px;
|
||
min-height: 0;
|
||
background: #fff;
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow);
|
||
padding: 16px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.properties-header {
|
||
position: sticky;
|
||
top: -16px;
|
||
margin: -16px -16px 0 -16px;
|
||
padding: 16px;
|
||
background: #fff;
|
||
z-index: 1;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.properties-header h3 {
|
||
margin: 0;
|
||
font-size: 14px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
color: var(--gray-500);
|
||
}
|
||
|
||
.property-group {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.property-group label {
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
color: var(--gray-500);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.property-group input,
|
||
.property-group textarea,
|
||
.property-group select {
|
||
background: var(--gray-50);
|
||
border: 1px solid var(--gray-300);
|
||
border-radius: 6px;
|
||
padding: 10px 12px;
|
||
color: var(--gray-800);
|
||
font-size: 14px;
|
||
}
|
||
|
||
.property-group input:focus,
|
||
.property-group textarea:focus,
|
||
.property-group select:focus {
|
||
outline: none;
|
||
border-color: var(--primary);
|
||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||
}
|
||
|
||
.property-row {
|
||
display: flex;
|
||
gap: 12px;
|
||
}
|
||
|
||
.property-row .property-group {
|
||
flex: 1;
|
||
}
|
||
|
||
/* Photo Task Section */
|
||
.photo-task-section {
|
||
background: var(--gray-50);
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
}
|
||
|
||
.photo-task-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.photo-task-header h4 {
|
||
margin: 0;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--gray-800);
|
||
}
|
||
|
||
.photo-preview {
|
||
width: 100%;
|
||
aspect-ratio: 4/3;
|
||
background: #fff;
|
||
border-radius: 6px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--gray-400);
|
||
margin-bottom: 12px;
|
||
overflow: hidden;
|
||
border: 1px solid var(--gray-200);
|
||
}
|
||
|
||
.photo-preview img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.photo-preview img:hover {
|
||
opacity: 0.9;
|
||
}
|
||
|
||
/* Lightbox for full-size images */
|
||
.lightbox-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.9);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 10000;
|
||
padding: 20px;
|
||
}
|
||
|
||
.lightbox-content {
|
||
position: relative;
|
||
max-width: 90vw;
|
||
max-height: 90vh;
|
||
}
|
||
|
||
.lightbox-content img {
|
||
max-width: 100%;
|
||
max-height: 90vh;
|
||
object-fit: contain;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.lightbox-close {
|
||
position: absolute;
|
||
top: -40px;
|
||
right: 0;
|
||
background: transparent;
|
||
border: none;
|
||
color: white;
|
||
font-size: 32px;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
width: 40px;
|
||
height: 40px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.lightbox-close:hover {
|
||
color: var(--primary);
|
||
}
|
||
|
||
.photo-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.photo-actions button {
|
||
flex: 1;
|
||
}
|
||
|
||
/* Task Badge */
|
||
.task-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 11px;
|
||
padding: 3px 8px;
|
||
border-radius: 12px;
|
||
background: var(--warning);
|
||
color: #000;
|
||
}
|
||
|
||
.task-badge.completed {
|
||
background: var(--success);
|
||
}
|
||
|
||
/* Empty State */
|
||
.empty-canvas {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 60px 20px;
|
||
color: var(--gray-500);
|
||
text-align: center;
|
||
}
|
||
|
||
.empty-canvas svg {
|
||
width: 64px;
|
||
height: 64px;
|
||
margin-bottom: 16px;
|
||
opacity: 0.5;
|
||
color: var(--gray-400);
|
||
}
|
||
|
||
.empty-canvas h3 {
|
||
margin: 0 0 8px 0;
|
||
color: var(--gray-800);
|
||
}
|
||
|
||
.empty-canvas p {
|
||
margin: 0;
|
||
max-width: 300px;
|
||
color: var(--gray-500);
|
||
}
|
||
|
||
/* Toolbar */
|
||
.builder-toolbar {
|
||
display: flex;
|
||
gap: 8px;
|
||
padding: 12px 24px;
|
||
background: #fff;
|
||
border-bottom: 1px solid var(--gray-200);
|
||
}
|
||
|
||
.toolbar-group {
|
||
display: flex;
|
||
gap: 4px;
|
||
padding-right: 12px;
|
||
border-right: 1px solid var(--gray-200);
|
||
}
|
||
|
||
.toolbar-group:last-child {
|
||
border-right: none;
|
||
}
|
||
|
||
.toolbar-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 8px 12px;
|
||
background: var(--gray-50);
|
||
border: 1px solid var(--gray-200);
|
||
border-radius: var(--radius);
|
||
color: var(--gray-700);
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.toolbar-btn:hover {
|
||
background: #fff;
|
||
border-color: var(--primary);
|
||
color: var(--gray-900);
|
||
}
|
||
|
||
.toolbar-btn.primary {
|
||
background: var(--primary);
|
||
border-color: var(--primary);
|
||
color: #fff;
|
||
}
|
||
|
||
.toolbar-btn.primary:hover {
|
||
background: var(--primary-dark);
|
||
}
|
||
|
||
.toolbar-btn.primary:active {
|
||
background: var(--primary-dark);
|
||
transform: scale(0.98);
|
||
}
|
||
|
||
.toolbar-btn svg {
|
||
width: 16px;
|
||
height: 16px;
|
||
}
|
||
|
||
/* Import/Export Modal */
|
||
.import-export-area {
|
||
width: 100%;
|
||
min-height: 300px;
|
||
font-family: monospace;
|
||
font-size: 12px;
|
||
resize: vertical;
|
||
}
|
||
|
||
/* Selection highlight */
|
||
.category-card.selected,
|
||
.item-card.selected {
|
||
border-color: var(--primary);
|
||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
|
||
}
|
||
|
||
/* Context Menu */
|
||
.context-menu {
|
||
position: fixed;
|
||
background: #fff;
|
||
border: 1px solid var(--gray-200);
|
||
border-radius: var(--radius);
|
||
padding: 8px 0;
|
||
min-width: 180px;
|
||
box-shadow: var(--shadow-lg);
|
||
z-index: 1000;
|
||
display: none;
|
||
}
|
||
|
||
.context-menu.visible {
|
||
display: block;
|
||
}
|
||
|
||
.context-menu-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 8px 16px;
|
||
cursor: pointer;
|
||
transition: background 0.15s;
|
||
color: var(--gray-700);
|
||
}
|
||
|
||
.context-menu-item:hover {
|
||
background: var(--gray-50);
|
||
}
|
||
|
||
.context-menu-item.danger {
|
||
color: var(--danger);
|
||
}
|
||
|
||
.context-menu-item svg {
|
||
width: 16px;
|
||
height: 16px;
|
||
}
|
||
|
||
.context-menu-divider {
|
||
height: 1px;
|
||
background: var(--gray-200);
|
||
margin: 8px 0;
|
||
}
|
||
|
||
/* Keyboard shortcuts hint */
|
||
.shortcut-hint {
|
||
font-size: 11px;
|
||
color: var(--gray-400);
|
||
margin-left: auto;
|
||
font-family: monospace;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="app">
|
||
<!-- Sidebar -->
|
||
<aside class="sidebar" id="sidebar">
|
||
<div class="business-info" style="padding: 12px 16px; border-bottom: 1px solid var(--gray-200); display: flex; align-items: center; gap: 12px;">
|
||
<div class="business-avatar" id="businessAvatar">B</div>
|
||
<div class="business-details" style="flex: 1;">
|
||
<div class="business-name" id="businessName">Loading...</div>
|
||
<div class="business-status online">Online</div>
|
||
</div>
|
||
<button class="sidebar-toggle" id="sidebarToggle" style="background: none; border: none; cursor: pointer; padding: 4px;">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M3 12h18M3 6h18M3 18h18"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<nav class="sidebar-nav">
|
||
<a href="index.html#dashboard" class="nav-item">
|
||
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
|
||
<rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>
|
||
</svg>
|
||
<span>Dashboard</span>
|
||
</a>
|
||
<a href="index.html#orders" class="nav-item">
|
||
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"/>
|
||
<rect x="9" y="3" width="6" height="4" rx="1"/>
|
||
<path d="M9 12h6M9 16h6"/>
|
||
</svg>
|
||
<span>Orders</span>
|
||
</a>
|
||
<a href="#" class="nav-item active">
|
||
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M3 6h18M3 12h18M3 18h18"/>
|
||
</svg>
|
||
<span>Menu</span>
|
||
</a>
|
||
<a href="station-assignment.html" class="nav-item">
|
||
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<rect x="2" y="3" width="20" height="18" rx="2"/>
|
||
<path d="M2 9h20M9 21V9"/>
|
||
</svg>
|
||
<span>Stations</span>
|
||
</a>
|
||
<a href="index.html#reports" class="nav-item">
|
||
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M18 20V10M12 20V4M6 20v-6"/>
|
||
</svg>
|
||
<span>Reports</span>
|
||
</a>
|
||
<a href="index.html#team" class="nav-item">
|
||
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/>
|
||
<circle cx="9" cy="7" r="4"/>
|
||
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/>
|
||
</svg>
|
||
<span>Team</span>
|
||
</a>
|
||
<a href="index.html#services" class="nav-item">
|
||
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
||
<path d="M13.73 21a2 2 0 01-3.46 0"/>
|
||
</svg>
|
||
<span>Services</span>
|
||
</a>
|
||
<a href="index.html#service-points" class="nav-item">
|
||
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<circle cx="12" cy="12" r="3"/>
|
||
<path d="M12 2v4M12 18v4M2 12h4M18 12h4"/>
|
||
<path d="M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
|
||
</svg>
|
||
<span>Service Points</span>
|
||
</a>
|
||
<a href="index.html#admin-tasks" class="nav-item">
|
||
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||
</svg>
|
||
<span>Task Admin</span>
|
||
</a>
|
||
<a href="index.html#settings" class="nav-item">
|
||
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<circle cx="12" cy="12" r="3"/>
|
||
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/>
|
||
</svg>
|
||
<span>Settings</span>
|
||
</a>
|
||
</nav>
|
||
</aside>
|
||
|
||
<!-- Main Content -->
|
||
<main class="main-content">
|
||
<header class="top-bar">
|
||
<div class="page-title">
|
||
<h1>Menu Builder</h1>
|
||
</div>
|
||
<div class="top-bar-actions">
|
||
<div class="user-menu" style="position: relative;">
|
||
<button class="user-btn" id="userBtn" onclick="var dd=document.getElementById('userDropdown'); dd.style.display = dd.style.display === 'none' ? 'block' : 'none';">
|
||
<span class="user-avatar" id="userAvatar">B</span>
|
||
</button>
|
||
<div class="user-dropdown" id="userDropdown" style="display:none; position:absolute; right:0; top:100%; margin-top:8px; background:var(--bg-card); border:1px solid var(--border-color); border-radius:8px; min-width:200px; box-shadow:0 4px 12px rgba(0,0,0,0.3); z-index:1000; overflow:hidden;">
|
||
<a href="index.html#settings" style="display:flex; align-items:center; gap:10px; padding:12px 16px; color:var(--text-primary); text-decoration:none; transition:background 0.15s;" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background=''">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
|
||
Settings
|
||
</a>
|
||
<a href="#" onclick="MenuBuilder.switchBusiness(); document.getElementById('userDropdown').classList.remove('show'); return false;" style="display:flex; align-items:center; gap:10px; padding:12px 16px; color:var(--text-primary); text-decoration:none; transition:background 0.15s;" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background=''">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 3h5v5M8 3H3v5M3 16v5h5M21 16v5h-5M7 12h10"/></svg>
|
||
Switch Business
|
||
</a>
|
||
<a href="#" onclick="MenuBuilder.addNewBusiness(); document.getElementById('userDropdown').classList.remove('show'); return false;" style="display:flex; align-items:center; gap:10px; padding:12px 16px; color:var(--text-primary); text-decoration:none; transition:background 0.15s;" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background=''">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
|
||
Add New Business
|
||
</a>
|
||
<div style="border-top: 1px solid var(--border-color);"></div>
|
||
<a href="#" onclick="MenuBuilder.logout(); return false;" style="display:flex; align-items:center; gap:10px; padding:12px 16px; color:#ef4444; text-decoration:none; transition:background 0.15s;" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background=''">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9"/></svg>
|
||
Logout
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
<div class="builder-wrapper">
|
||
<!-- Toolbar -->
|
||
<div class="builder-toolbar">
|
||
<div class="toolbar-group">
|
||
<button class="toolbar-btn" onclick="MenuBuilder.undo()" title="Undo (Ctrl+Z)">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M3 7v6h6M3 13a9 9 0 103-7.5"/>
|
||
</svg>
|
||
Undo
|
||
</button>
|
||
<button class="toolbar-btn" onclick="MenuBuilder.redo()" title="Redo (Ctrl+Y)">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M21 7v6h-6M21 13a9 9 0 10-3-7.5"/>
|
||
</svg>
|
||
Redo
|
||
</button>
|
||
</div>
|
||
<div class="toolbar-group">
|
||
<button class="toolbar-btn" onclick="MenuBuilder.addCategory()" title="Add Category">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M12 5v14M5 12h14"/>
|
||
</svg>
|
||
Category
|
||
</button>
|
||
<button class="toolbar-btn" onclick="MenuBuilder.addItem()" title="Add Item">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M12 5v14M5 12h14"/>
|
||
</svg>
|
||
Item
|
||
</button>
|
||
</div>
|
||
<div class="toolbar-group">
|
||
<button class="toolbar-btn" onclick="MenuBuilder.cloneSelected()" title="Clone (Ctrl+D)">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<rect x="9" y="9" width="13" height="13" rx="2"/>
|
||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
|
||
</svg>
|
||
Clone
|
||
</button>
|
||
<button class="toolbar-btn" onclick="MenuBuilder.deleteSelected()" title="Delete (Del)">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
|
||
</svg>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
<div class="toolbar-group">
|
||
<button class="toolbar-btn" onclick="MenuBuilder.showOutlineModal()" title="View Full Menu Outline">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M4 6h16M4 12h16M4 18h12"/>
|
||
</svg>
|
||
Outline
|
||
</button>
|
||
</div>
|
||
<div class="toolbar-group" style="margin-left: auto;">
|
||
<button class="toolbar-btn" onclick="MenuBuilder.showMenuManager()" title="Manage Menus" style="font-weight: 600;">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
|
||
</svg>
|
||
Manage Menus
|
||
</button>
|
||
<button class="toolbar-btn primary" onclick="MenuBuilder.saveMenu()">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/>
|
||
<path d="M17 21v-8H7v8M7 3v5h8"/>
|
||
</svg>
|
||
Save Menu
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main Builder -->
|
||
<div class="builder-container">
|
||
<!-- Left Sidebar - Palette -->
|
||
<div class="builder-sidebar">
|
||
<h3>Components</h3>
|
||
<div class="palette-section">
|
||
<div class="palette-item" draggable="true" data-type="category">
|
||
<div class="icon">📁</div>
|
||
<div>
|
||
<div class="label">Category</div>
|
||
<div class="hint">Group of menu items</div>
|
||
</div>
|
||
</div>
|
||
<div class="palette-item" draggable="true" data-type="item">
|
||
<div class="icon">🍽️</div>
|
||
<div>
|
||
<div class="label">Menu Item</div>
|
||
<div class="hint">Orderable product</div>
|
||
</div>
|
||
</div>
|
||
<div class="palette-item" draggable="true" data-type="modifier">
|
||
<div class="icon">⚙️</div>
|
||
<div>
|
||
<div class="label">Modifier</div>
|
||
<div class="hint">Add-on or option</div>
|
||
</div>
|
||
</div>
|
||
<div class="palette-item" draggable="true" data-type="modifier-group">
|
||
<div class="icon">📋</div>
|
||
<div>
|
||
<div class="label">Modifier Group</div>
|
||
<div class="hint">Group of options</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<h3>Modifier Templates</h3>
|
||
<div class="palette-section" id="templateLibrary">
|
||
<div style="color: var(--gray-500); font-size: 13px; padding: 8px;">Loading templates...</div>
|
||
</div>
|
||
|
||
<h3>Quick Stats</h3>
|
||
<div style="font-size: 13px; color: var(--text-muted);">
|
||
<div style="display: flex; justify-content: space-between; padding: 6px 0;">
|
||
<span>Categories:</span>
|
||
<strong id="statCategories">0</strong>
|
||
</div>
|
||
<div style="display: flex; justify-content: space-between; padding: 6px 0;">
|
||
<span>Items:</span>
|
||
<strong id="statItems">0</strong>
|
||
</div>
|
||
<div style="display: flex; justify-content: space-between; padding: 6px 0;">
|
||
<span>Templates:</span>
|
||
<strong id="statTemplates">0</strong>
|
||
</div>
|
||
<div style="display: flex; justify-content: space-between; padding: 6px 0;">
|
||
<span>Photos Missing:</span>
|
||
<strong id="statPhotosMissing" style="color: var(--warning);">0</strong>
|
||
</div>
|
||
</div>
|
||
|
||
<h3>Branding</h3>
|
||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||
<button class="btn btn-secondary" onclick="MenuBuilder.uploadHeader()" style="width: 100%; display: flex; align-items: center; gap: 8px; justify-content: center;">
|
||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
|
||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||
<polyline points="21 15 16 10 5 21"/>
|
||
</svg>
|
||
Upload Header
|
||
</button>
|
||
<button class="btn btn-secondary" onclick="MenuBuilder.showBrandColorPicker()" style="width: 100%; display: flex; align-items: center; gap: 8px; justify-content: center;">
|
||
<span id="brandColorSwatch" style="width: 16px; height: 16px; border-radius: 3px; background: #1B4D3E; border: 1px solid rgba(0,0,0,0.2);"></span>
|
||
Brand Color
|
||
</button>
|
||
<p style="font-size: 11px; color: var(--gray-500); margin: 0;">
|
||
Header: 1200x400px recommended<br>
|
||
Color: Used for category bars in the app
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main Canvas -->
|
||
<div class="builder-canvas">
|
||
<div class="canvas-header">
|
||
<h2>
|
||
<span id="menuName">Menu Builder</span>
|
||
</h2>
|
||
<div class="canvas-actions" style="display: flex; gap: 8px; align-items: center;">
|
||
<select id="menuSelector" onchange="MenuBuilder.onMenuSelect(this.value)" style="padding: 8px 12px; border: 1px solid var(--gray-300); border-radius: 6px; background: #fff; font-size: 14px; cursor: pointer;">
|
||
<option value="0">All Categories</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="menu-structure" id="menuStructure">
|
||
<div class="empty-canvas" id="emptyCanvas">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||
<path d="M3 6h18M3 12h18M3 18h18"/>
|
||
</svg>
|
||
<h3>Start Building Your Menu</h3>
|
||
<p>Drag categories and items from the left panel, or click the buttons above to add them.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Right Sidebar - Properties -->
|
||
<div class="builder-properties" id="propertiesPanel">
|
||
<div class="properties-header">
|
||
<h3>Properties</h3>
|
||
</div>
|
||
|
||
<div id="propertiesContent">
|
||
<p style="color: var(--gray-500); text-align: center; padding: 20px;">
|
||
Select an item to edit its properties
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div> <!-- end builder-wrapper -->
|
||
</main>
|
||
</div>
|
||
|
||
<!-- Context Menu -->
|
||
<div class="context-menu" id="contextMenu">
|
||
<div class="context-menu-item" onclick="MenuBuilder.editSelected()">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/>
|
||
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||
</svg>
|
||
Edit
|
||
<span class="shortcut-hint">Enter</span>
|
||
</div>
|
||
<div class="context-menu-item" onclick="MenuBuilder.cloneSelected()">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<rect x="9" y="9" width="13" height="13" rx="2"/>
|
||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
|
||
</svg>
|
||
Clone
|
||
<span class="shortcut-hint">Ctrl+D</span>
|
||
</div>
|
||
<div class="context-menu-item" onclick="MenuBuilder.createPhotoTask()">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||
<path d="M21 15l-5-5L5 21"/>
|
||
</svg>
|
||
Create Photo Task
|
||
</div>
|
||
<div class="context-menu-divider"></div>
|
||
<div class="context-menu-item" onclick="MenuBuilder.moveUp()">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M12 19V5M5 12l7-7 7 7"/>
|
||
</svg>
|
||
Move Up
|
||
<span class="shortcut-hint">Alt+Up</span>
|
||
</div>
|
||
<div class="context-menu-item" onclick="MenuBuilder.moveDown()">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M12 5v14M19 12l-7 7-7-7"/>
|
||
</svg>
|
||
Move Down
|
||
<span class="shortcut-hint">Alt+Down</span>
|
||
</div>
|
||
<div class="context-menu-divider"></div>
|
||
<div class="context-menu-item danger" onclick="MenuBuilder.deleteSelected()">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
|
||
</svg>
|
||
Delete
|
||
<span class="shortcut-hint">Del</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal -->
|
||
<div class="modal-overlay" id="modalOverlay">
|
||
<div class="modal" id="modal" style="max-width: 600px;">
|
||
<div class="modal-header">
|
||
<h3 id="modalTitle">Modal</h3>
|
||
<button class="modal-close" onclick="MenuBuilder.closeModal()">×</button>
|
||
</div>
|
||
<div class="modal-body" id="modalBody"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Toast Container -->
|
||
<div class="toast-container" id="toastContainer"></div>
|
||
|
||
<script>
|
||
// Detect base path for API calls (handles local dev at /biz.payfrit.com/)
|
||
const BASE_PATH = (() => {
|
||
const path = window.location.pathname;
|
||
const portalIndex = path.indexOf('/portal/');
|
||
if (portalIndex > 0) {
|
||
return path.substring(0, portalIndex);
|
||
}
|
||
return '';
|
||
})();
|
||
|
||
/**
|
||
* Payfrit Menu Builder
|
||
* Drag-and-drop menu creation with photo tasks
|
||
*/
|
||
const MenuBuilder = {
|
||
// Config
|
||
config: {
|
||
apiBaseUrl: BASE_PATH + '/api',
|
||
businessId: null,
|
||
},
|
||
|
||
// State
|
||
menu: {
|
||
categories: []
|
||
},
|
||
menus: [], // List of all menus for this business
|
||
selectedMenuId: 0, // Currently selected menu ID (0 = all/default)
|
||
templates: [],
|
||
stations: [],
|
||
selectedElement: null,
|
||
selectedData: null,
|
||
undoStack: [],
|
||
redoStack: [],
|
||
idCounter: 1,
|
||
expandedCategoryId: null, // For accordion - only one category expanded at a time
|
||
expandedSubCategoryId: null, // For subcategory accordion - independent of parent
|
||
expandedItemId: null, // For item accordion - only one item expanded at a time
|
||
expandedModifierIds: new Set(), // Track which modifiers are expanded
|
||
|
||
// Get thumbnail URLs from base image URL
|
||
// Thumbnails are always .jpg regardless of original format
|
||
// E.g., /uploads/items/123.png -> { thumb: /uploads/items/123_thumb.jpg, medium: /uploads/items/123_medium.jpg, full: /uploads/items/123.png }
|
||
getImageUrls(baseUrl) {
|
||
if (!baseUrl) return { thumb: null, medium: null, full: null };
|
||
// Strip query string for manipulation
|
||
const qIdx = baseUrl.indexOf('?');
|
||
const query = qIdx > -1 ? baseUrl.substring(qIdx) : '';
|
||
const cleanUrl = qIdx > -1 ? baseUrl.substring(0, qIdx) : baseUrl;
|
||
// Find extension
|
||
const dotIdx = cleanUrl.lastIndexOf('.');
|
||
if (dotIdx === -1) return { thumb: baseUrl, medium: baseUrl, full: baseUrl };
|
||
const base = cleanUrl.substring(0, dotIdx);
|
||
// Thumbnails are always saved as .jpg by the server
|
||
return {
|
||
thumb: base + '_thumb.jpg' + query,
|
||
medium: base + '_medium.jpg' + query,
|
||
full: cleanUrl + query
|
||
};
|
||
},
|
||
|
||
// Show full-size image in lightbox
|
||
showLightbox(imageUrl) {
|
||
if (!imageUrl) return;
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'lightbox-overlay';
|
||
overlay.innerHTML = `
|
||
<div class="lightbox-content">
|
||
<img src="${imageUrl}" alt="Full size photo">
|
||
<button class="lightbox-close">×</button>
|
||
</div>
|
||
`;
|
||
overlay.onclick = (e) => {
|
||
if (e.target === overlay || e.target.classList.contains('lightbox-close')) {
|
||
overlay.remove();
|
||
}
|
||
};
|
||
document.body.appendChild(overlay);
|
||
},
|
||
|
||
// Initialize
|
||
async init() {
|
||
console.log('[MenuBuilder] Initializing...');
|
||
|
||
// Check authentication
|
||
const token = localStorage.getItem('payfrit_portal_token');
|
||
const savedBusiness = localStorage.getItem('payfrit_portal_business');
|
||
|
||
if (!token || !savedBusiness) {
|
||
window.location.href = BASE_PATH + '/portal/signup.html';
|
||
return;
|
||
}
|
||
|
||
// Get business ID from localStorage
|
||
this.config.businessId = parseInt(savedBusiness) || null;
|
||
this.config.token = token;
|
||
|
||
// Setup sidebar toggle
|
||
this.setupSidebar();
|
||
|
||
// Load business info
|
||
await this.loadBusinessInfo();
|
||
|
||
// Setup drag and drop
|
||
this.setupDragAndDrop();
|
||
|
||
// Setup keyboard shortcuts
|
||
this.setupKeyboardShortcuts();
|
||
|
||
// Setup context menu
|
||
this.setupContextMenu();
|
||
|
||
// Load existing menu if any
|
||
await this.loadMenu();
|
||
|
||
console.log('[MenuBuilder] Ready');
|
||
},
|
||
|
||
// Setup sidebar toggle
|
||
setupSidebar() {
|
||
const sidebar = document.getElementById('sidebar');
|
||
const toggle = document.getElementById('sidebarToggle');
|
||
if (toggle && sidebar) {
|
||
toggle.addEventListener('click', () => {
|
||
sidebar.classList.toggle('collapsed');
|
||
});
|
||
}
|
||
},
|
||
|
||
// Logout
|
||
logout() {
|
||
localStorage.removeItem('payfrit_portal_token');
|
||
localStorage.removeItem('payfrit_portal_business');
|
||
window.location.href = BASE_PATH + '/portal/signup.html';
|
||
},
|
||
|
||
// Switch to a different business
|
||
switchBusiness() {
|
||
localStorage.removeItem('payfrit_portal_business');
|
||
window.location.href = BASE_PATH + '/portal/index.html';
|
||
},
|
||
|
||
// Add a new business
|
||
addNewBusiness() {
|
||
window.location.href = BASE_PATH + '/portal/setup-wizard.html';
|
||
},
|
||
|
||
// Load business info
|
||
async loadBusinessInfo() {
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/businesses/get.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ BusinessID: this.config.businessId })
|
||
});
|
||
const data = await response.json();
|
||
if (data.OK && data.BUSINESS) {
|
||
const biz = data.BUSINESS;
|
||
const initial = biz.Name ? biz.Name.charAt(0).toUpperCase() : 'B';
|
||
// Update sidebar business info
|
||
const businessName = document.getElementById('businessName');
|
||
const businessAvatar = document.getElementById('businessAvatar');
|
||
if (businessName) businessName.textContent = biz.Name;
|
||
if (businessAvatar) businessAvatar.textContent = initial;
|
||
// Update top bar user avatar
|
||
const userAvatar = document.getElementById('userAvatar');
|
||
if (userAvatar) userAvatar.textContent = initial;
|
||
}
|
||
} catch (err) {
|
||
console.error('[MenuBuilder] Error loading business:', err);
|
||
}
|
||
},
|
||
|
||
// Setup drag and drop
|
||
setupDragAndDrop() {
|
||
// Palette items
|
||
document.querySelectorAll('.palette-item').forEach(item => {
|
||
item.addEventListener('dragstart', (e) => {
|
||
e.dataTransfer.setData('type', item.dataset.type);
|
||
e.dataTransfer.setData('source', 'palette');
|
||
});
|
||
});
|
||
|
||
// Canvas drop zone
|
||
const structure = document.getElementById('menuStructure');
|
||
structure.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = 'copy';
|
||
});
|
||
|
||
structure.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
const type = e.dataTransfer.getData('type');
|
||
const source = e.dataTransfer.getData('source');
|
||
|
||
if (source === 'palette') {
|
||
this.handlePaletteDrop(type, e.target);
|
||
}
|
||
});
|
||
},
|
||
|
||
// Handle palette drop
|
||
handlePaletteDrop(type, target) {
|
||
switch (type) {
|
||
case 'category':
|
||
this.addCategory();
|
||
break;
|
||
case 'item':
|
||
this.addItem(this.findCategoryFromTarget(target));
|
||
break;
|
||
case 'modifier':
|
||
this.addModifier(this.findItemFromTarget(target));
|
||
break;
|
||
case 'modifier-group':
|
||
this.addModifierGroup(this.findItemFromTarget(target));
|
||
break;
|
||
case 'template-size':
|
||
this.addSizeTemplate(this.findItemFromTarget(target));
|
||
break;
|
||
case 'template-protein':
|
||
this.addProteinTemplate(this.findItemFromTarget(target));
|
||
break;
|
||
case 'template-temp':
|
||
this.addTempTemplate(this.findItemFromTarget(target));
|
||
break;
|
||
}
|
||
},
|
||
|
||
// Find category from drop target
|
||
findCategoryFromTarget(target) {
|
||
const card = target.closest('.category-card');
|
||
if (card) {
|
||
return parseInt(card.dataset.categoryId);
|
||
}
|
||
return this.menu.categories.length > 0 ? this.menu.categories[0].id : null;
|
||
},
|
||
|
||
// Find item from drop target
|
||
findItemFromTarget(target) {
|
||
const card = target.closest('.item-card');
|
||
if (card) {
|
||
return parseInt(card.dataset.itemId);
|
||
}
|
||
return null;
|
||
},
|
||
|
||
// Setup keyboard shortcuts
|
||
setupKeyboardShortcuts() {
|
||
document.addEventListener('keydown', (e) => {
|
||
// Delete
|
||
if (e.key === 'Delete' && this.selectedElement) {
|
||
e.preventDefault();
|
||
this.deleteSelected();
|
||
}
|
||
|
||
// Clone (Ctrl+D)
|
||
if (e.key === 'd' && e.ctrlKey && this.selectedElement) {
|
||
e.preventDefault();
|
||
this.cloneSelected();
|
||
}
|
||
|
||
// Undo (Ctrl+Z)
|
||
if (e.key === 'z' && e.ctrlKey && !e.shiftKey) {
|
||
e.preventDefault();
|
||
this.undo();
|
||
}
|
||
|
||
// Redo (Ctrl+Y or Ctrl+Shift+Z)
|
||
if ((e.key === 'y' && e.ctrlKey) || (e.key === 'z' && e.ctrlKey && e.shiftKey)) {
|
||
e.preventDefault();
|
||
this.redo();
|
||
}
|
||
|
||
// Move up (Alt+Up)
|
||
if (e.key === 'ArrowUp' && e.altKey && this.selectedElement) {
|
||
e.preventDefault();
|
||
this.moveUp();
|
||
}
|
||
|
||
// Move down (Alt+Down)
|
||
if (e.key === 'ArrowDown' && e.altKey && this.selectedElement) {
|
||
e.preventDefault();
|
||
this.moveDown();
|
||
}
|
||
|
||
// Edit (Enter)
|
||
if (e.key === 'Enter' && this.selectedElement) {
|
||
e.preventDefault();
|
||
this.editSelected();
|
||
}
|
||
|
||
// Escape - deselect
|
||
if (e.key === 'Escape') {
|
||
this.clearSelection();
|
||
this.hideContextMenu();
|
||
}
|
||
});
|
||
},
|
||
|
||
// Setup context menu
|
||
setupContextMenu() {
|
||
document.addEventListener('contextmenu', (e) => {
|
||
const element = e.target.closest('.category-card, .item-card');
|
||
if (element) {
|
||
e.preventDefault();
|
||
this.selectElement(element);
|
||
this.showContextMenu(e.clientX, e.clientY);
|
||
}
|
||
});
|
||
|
||
document.addEventListener('click', (e) => {
|
||
this.hideContextMenu();
|
||
// Close user dropdown if clicking outside
|
||
const userDropdown = document.getElementById('userDropdown');
|
||
const userBtn = document.getElementById('userBtn');
|
||
if (userDropdown && !userDropdown.contains(e.target) && !userBtn.contains(e.target)) {
|
||
userDropdown.classList.remove('show');
|
||
userDropdown.style.display = 'none';
|
||
}
|
||
});
|
||
},
|
||
|
||
// Show context menu
|
||
showContextMenu(x, y) {
|
||
const menu = document.getElementById('contextMenu');
|
||
menu.style.left = x + 'px';
|
||
menu.style.top = y + 'px';
|
||
menu.classList.add('visible');
|
||
},
|
||
|
||
// Hide context menu
|
||
hideContextMenu() {
|
||
document.getElementById('contextMenu').classList.remove('visible');
|
||
},
|
||
|
||
// Generate unique ID
|
||
generateId() {
|
||
return `temp_${Date.now()}_${this.idCounter++}`;
|
||
},
|
||
|
||
// Save state for undo
|
||
saveState() {
|
||
this.undoStack.push(JSON.stringify(this.menu));
|
||
this.redoStack = [];
|
||
if (this.undoStack.length > 50) {
|
||
this.undoStack.shift();
|
||
}
|
||
},
|
||
|
||
// Undo
|
||
undo() {
|
||
if (this.undoStack.length === 0) return;
|
||
this.redoStack.push(JSON.stringify(this.menu));
|
||
this.menu = JSON.parse(this.undoStack.pop());
|
||
this.render();
|
||
this.toast('Undo', 'info');
|
||
},
|
||
|
||
// Redo
|
||
redo() {
|
||
if (this.redoStack.length === 0) return;
|
||
this.undoStack.push(JSON.stringify(this.menu));
|
||
this.menu = JSON.parse(this.redoStack.pop());
|
||
this.render();
|
||
this.toast('Redo', 'info');
|
||
},
|
||
|
||
// Add category (or subcategory if parentCategoryId is provided)
|
||
addCategory(name = null, parentCategoryId = null) {
|
||
this.saveState();
|
||
|
||
const category = {
|
||
id: this.generateId(),
|
||
name: name || `New Category ${this.menu.categories.length + 1}`,
|
||
description: '',
|
||
parentCategoryId: parentCategoryId || 0,
|
||
parentCategoryDbId: parentCategoryId ? (this.menu.categories.find(c => c.id === parentCategoryId)?.dbId || 0) : 0,
|
||
sortOrder: this.menu.categories.length,
|
||
items: []
|
||
};
|
||
|
||
this.menu.categories.push(category);
|
||
this.render();
|
||
this.selectElementById('category', category.id);
|
||
this.showPropertiesForCategory(category);
|
||
},
|
||
|
||
// Add item
|
||
addItem(categoryId = null) {
|
||
this.saveState();
|
||
|
||
// Find category
|
||
let category = null;
|
||
if (categoryId) {
|
||
category = this.menu.categories.find(c => c.id === categoryId);
|
||
} else if (this.menu.categories.length > 0) {
|
||
category = this.menu.categories[0];
|
||
} else {
|
||
this.toast('Create a category first', 'error');
|
||
return;
|
||
}
|
||
|
||
const item = {
|
||
id: this.generateId(),
|
||
name: 'New Item',
|
||
description: '',
|
||
price: 0,
|
||
imageUrl: null,
|
||
photoTaskId: null,
|
||
modifiers: [],
|
||
sortOrder: category.items.length
|
||
};
|
||
|
||
category.items.push(item);
|
||
this.render();
|
||
this.selectElementById('item', item.id);
|
||
this.showPropertiesForItem(item, category);
|
||
},
|
||
|
||
// Add modifier
|
||
addModifier(itemId) {
|
||
if (!itemId) {
|
||
this.toast('Select an item first', 'error');
|
||
return;
|
||
}
|
||
|
||
this.saveState();
|
||
|
||
// Find item
|
||
let item = null;
|
||
let parentCategory = null;
|
||
for (const cat of this.menu.categories) {
|
||
const found = cat.items.find(i => i.id === itemId);
|
||
if (found) {
|
||
item = found;
|
||
parentCategory = cat;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!item) return;
|
||
|
||
const modifier = {
|
||
id: this.generateId(),
|
||
name: 'New Modifier',
|
||
price: 0,
|
||
isDefault: false,
|
||
sortOrder: item.modifiers.length
|
||
};
|
||
|
||
item.modifiers.push(modifier);
|
||
this.render();
|
||
},
|
||
|
||
// Add modifier group
|
||
addModifierGroup(itemId) {
|
||
if (!itemId) {
|
||
this.toast('Select an item first', 'error');
|
||
return;
|
||
}
|
||
// For now, just add a modifier with a group indicator
|
||
this.addModifier(itemId);
|
||
},
|
||
|
||
// Add size template
|
||
addSizeTemplate(itemId) {
|
||
if (!itemId) {
|
||
this.toast('Select an item first', 'error');
|
||
return;
|
||
}
|
||
|
||
this.saveState();
|
||
|
||
let item = null;
|
||
for (const cat of this.menu.categories) {
|
||
const found = cat.items.find(i => i.id === itemId);
|
||
if (found) {
|
||
item = found;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!item) return;
|
||
|
||
const sizes = [
|
||
{ name: 'Small', price: 0, isDefault: true },
|
||
{ name: 'Medium', price: 1.00, isDefault: false },
|
||
{ name: 'Large', price: 2.00, isDefault: false }
|
||
];
|
||
|
||
sizes.forEach((s, i) => {
|
||
item.modifiers.push({
|
||
id: this.generateId(),
|
||
name: s.name,
|
||
price: s.price,
|
||
isDefault: s.isDefault,
|
||
group: 'Size',
|
||
sortOrder: item.modifiers.length + i
|
||
});
|
||
});
|
||
|
||
this.render();
|
||
this.toast('Size options added', 'success');
|
||
},
|
||
|
||
// Add protein template
|
||
addProteinTemplate(itemId) {
|
||
if (!itemId) {
|
||
this.toast('Select an item first', 'error');
|
||
return;
|
||
}
|
||
|
||
this.saveState();
|
||
|
||
let item = null;
|
||
for (const cat of this.menu.categories) {
|
||
const found = cat.items.find(i => i.id === itemId);
|
||
if (found) {
|
||
item = found;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!item) return;
|
||
|
||
const proteins = [
|
||
{ name: 'Chicken', price: 0, isDefault: true },
|
||
{ name: 'Beef', price: 2.00, isDefault: false },
|
||
{ name: 'Lamb', price: 3.00, isDefault: false },
|
||
{ name: 'Falafel (V)', price: 0, isDefault: false }
|
||
];
|
||
|
||
proteins.forEach((p, i) => {
|
||
item.modifiers.push({
|
||
id: this.generateId(),
|
||
name: p.name,
|
||
price: p.price,
|
||
isDefault: p.isDefault,
|
||
group: 'Protein',
|
||
sortOrder: item.modifiers.length + i
|
||
});
|
||
});
|
||
|
||
this.render();
|
||
this.toast('Protein options added', 'success');
|
||
},
|
||
|
||
// Add temperature template
|
||
addTempTemplate(itemId) {
|
||
if (!itemId) {
|
||
this.toast('Select an item first', 'error');
|
||
return;
|
||
}
|
||
|
||
this.saveState();
|
||
|
||
let item = null;
|
||
for (const cat of this.menu.categories) {
|
||
const found = cat.items.find(i => i.id === itemId);
|
||
if (found) {
|
||
item = found;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!item) return;
|
||
|
||
const temps = [
|
||
{ name: 'Hot', price: 0, isDefault: true },
|
||
{ name: 'Iced', price: 0.50, isDefault: false },
|
||
{ name: 'Blended', price: 1.00, isDefault: false }
|
||
];
|
||
|
||
temps.forEach((t, i) => {
|
||
item.modifiers.push({
|
||
id: this.generateId(),
|
||
name: t.name,
|
||
price: t.price,
|
||
isDefault: t.isDefault,
|
||
group: 'Temperature',
|
||
sortOrder: item.modifiers.length + i
|
||
});
|
||
});
|
||
|
||
this.render();
|
||
this.toast('Temperature options added', 'success');
|
||
},
|
||
|
||
// Select element
|
||
selectElement(element) {
|
||
this.clearSelection();
|
||
this.selectedElement = element;
|
||
element.classList.add('selected');
|
||
|
||
// Get data
|
||
if (element.classList.contains('category-card')) {
|
||
const catId = element.dataset.categoryId;
|
||
const category = this.menu.categories.find(c => c.id === catId);
|
||
this.selectedData = { type: 'category', data: category };
|
||
this.showPropertiesForCategory(category);
|
||
} else if (element.classList.contains('item-card')) {
|
||
const itemId = element.dataset.itemId;
|
||
for (const cat of this.menu.categories) {
|
||
const item = cat.items.find(i => i.id === itemId);
|
||
if (item) {
|
||
this.selectedData = { type: 'item', data: item, category: cat };
|
||
this.showPropertiesForItem(item, cat);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
|
||
// Select element by ID
|
||
selectElementById(type, id) {
|
||
const selector = type === 'category'
|
||
? `.category-card[data-category-id="${id}"]`
|
||
: `.item-card[data-item-id="${id}"]`;
|
||
const element = document.querySelector(selector);
|
||
if (element) {
|
||
this.selectElement(element);
|
||
}
|
||
},
|
||
|
||
// Select item by ID (shortcut for use in onclick handlers)
|
||
selectItem(itemId) {
|
||
this.selectElementById('item', itemId);
|
||
},
|
||
|
||
// Clear selection
|
||
clearSelection() {
|
||
if (this.selectedElement) {
|
||
this.selectedElement.classList.remove('selected');
|
||
}
|
||
this.selectedElement = null;
|
||
this.selectedData = null;
|
||
document.getElementById('propertiesContent').innerHTML = `
|
||
<p style="color: var(--gray-500); text-align: center; padding: 20px;">
|
||
Select an item to edit its properties
|
||
</p>
|
||
`;
|
||
},
|
||
|
||
// Show properties for category
|
||
showPropertiesForCategory(category) {
|
||
const menuOptions = this.menus.map(m =>
|
||
`<option value="${m.MenuID}" ${category.menuId === m.MenuID ? 'selected' : ''}>${this.escapeHtml(m.MenuName)}</option>`
|
||
).join('');
|
||
|
||
// Build parent category options (only top-level categories, exclude self and existing subcategories)
|
||
const isSubcategory = category.parentCategoryId && category.parentCategoryId !== 0;
|
||
const hasSubcategories = this.menu.categories.some(c =>
|
||
c.parentCategoryId === category.id || c.parentCategoryId === category.dbId
|
||
);
|
||
const parentOptions = this.menu.categories
|
||
.filter(c => {
|
||
// Must be top-level (not a subcategory itself)
|
||
if (c.parentCategoryId && c.parentCategoryId !== 0) return false;
|
||
// Can't be self
|
||
if (c.id === category.id) return false;
|
||
return true;
|
||
})
|
||
.map(c => {
|
||
const isSelected = (category.parentCategoryId === c.id || category.parentCategoryDbId === c.dbId);
|
||
return `<option value="${c.id}" data-dbid="${c.dbId || 0}" ${isSelected ? 'selected' : ''}>${this.escapeHtml(c.name)}</option>`;
|
||
}).join('');
|
||
|
||
document.getElementById('propertiesContent').innerHTML = `
|
||
<div class="property-group">
|
||
<label>Category Name</label>
|
||
<input type="text" id="propCatName" value="${this.escapeHtml(category.name)}"
|
||
onchange="MenuBuilder.updateCategory('${category.id}', 'name', this.value)">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>Description</label>
|
||
<textarea id="propCatDesc" rows="3"
|
||
onchange="MenuBuilder.updateCategory('${category.id}', 'description', this.value)">${this.escapeHtml(category.description || '')}</textarea>
|
||
</div>
|
||
${!hasSubcategories ? `
|
||
<div class="property-group">
|
||
<label>Parent Category</label>
|
||
<select onchange="MenuBuilder.setParentCategory('${category.id}', this)">
|
||
<option value="0" ${!category.parentCategoryId || category.parentCategoryId === 0 ? 'selected' : ''}>None (top-level)</option>
|
||
${parentOptions}
|
||
</select>
|
||
</div>
|
||
` : `
|
||
<div class="property-group">
|
||
<label>Parent Category</label>
|
||
<p style="color: var(--gray-500); font-size: 13px; margin: 4px 0 0;">This category has subcategories, so it must remain top-level.</p>
|
||
</div>
|
||
`}
|
||
${this.menus.length > 0 ? `
|
||
<div class="property-group">
|
||
<label>Assign to Menu</label>
|
||
<select onchange="MenuBuilder.updateCategory('${category.id}', 'menuId', parseInt(this.value))">
|
||
<option value="0" ${!category.menuId ? 'selected' : ''}>No Menu (Always Show)</option>
|
||
${menuOptions}
|
||
</select>
|
||
</div>
|
||
` : ''}
|
||
<div class="property-group">
|
||
<label>Sort Order</label>
|
||
<input type="number" id="propCatSort" value="${category.sortOrder}"
|
||
onchange="MenuBuilder.updateCategory('${category.id}', 'sortOrder', parseInt(this.value))">
|
||
</div>
|
||
<div class="property-group" style="margin-top: 16px;">
|
||
<button class="btn btn-danger" onclick="MenuBuilder.deleteCategory('${category.id}')">
|
||
Delete Category
|
||
</button>
|
||
</div>
|
||
`;
|
||
},
|
||
|
||
// Show properties for item
|
||
showPropertiesForItem(item, category) {
|
||
document.getElementById('propertiesContent').innerHTML = `
|
||
<div class="property-group">
|
||
<label>Item Name</label>
|
||
<input type="text" id="propItemName" value="${this.escapeHtml(item.name)}"
|
||
onchange="MenuBuilder.updateItem('${category.id}', '${item.id}', 'name', this.value)">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>Description</label>
|
||
<textarea id="propItemDesc" rows="3"
|
||
onchange="MenuBuilder.updateItem('${category.id}', '${item.id}', 'description', this.value)">${this.escapeHtml(item.description || '')}</textarea>
|
||
</div>
|
||
<div class="property-row">
|
||
<div class="property-group">
|
||
<label>Price ($)</label>
|
||
<input type="number" step="0.01" id="propItemPrice" value="${item.price || 0}"
|
||
onchange="MenuBuilder.updateItem('${category.id}', '${item.id}', 'price', parseFloat(this.value))">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>Category</label>
|
||
<select onchange="MenuBuilder.moveItemToCategory('${item.id}', this.value)">
|
||
${this.menu.categories.map(c => {
|
||
const isSubcat = c.parentCategoryId && c.parentCategoryId !== 0;
|
||
const prefix = isSubcat ? '\u00A0\u00A0\u2514 ' : '';
|
||
return `<option value="${c.id}" ${c.id === category.id ? 'selected' : ''}>${prefix}${this.escapeHtml(c.name)}</option>`;
|
||
}).join('')}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="photo-task-section">
|
||
<div class="photo-task-header">
|
||
<h4>Item Photo</h4>
|
||
${item.photoTaskId ?
|
||
`<span class="task-badge ${item.imageUrl ? 'completed' : ''}">
|
||
${item.imageUrl ? 'Photo Added' : 'Task Pending'}
|
||
</span>` : ''}
|
||
</div>
|
||
<div class="photo-preview" ${item.imageUrl ? `onclick="event.preventDefault(); MenuBuilder.showLightbox('${item.imageUrl}')"` : ''}>
|
||
${item.imageUrl ?
|
||
`<img src="${this.getImageUrls(item.imageUrl).medium}" alt="${this.escapeHtml(item.name)}" onerror="this.onerror=null; this.src='${item.imageUrl}'" title="Tap to view full size">` :
|
||
`<span>No photo yet</span>`}
|
||
</div>
|
||
<div class="photo-actions">
|
||
<button class="btn btn-secondary" onclick="MenuBuilder.showUploadModal('${item.id}', '${item.dbId || ''}')">
|
||
Upload
|
||
</button>
|
||
<button class="btn btn-secondary" onclick="MenuBuilder.createPhotoTask('${item.id}')">
|
||
Create Task
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="property-group" style="margin-top: 16px;">
|
||
<label>Modifiers (${item.modifiers.length})</label>
|
||
<button class="btn btn-secondary" onclick="MenuBuilder.addModifier('${item.id}')" style="margin-top: 8px;">
|
||
+ Add Modifier
|
||
</button>
|
||
</div>
|
||
|
||
<div class="property-group" style="margin-top: 16px;">
|
||
<button class="btn btn-danger" onclick="MenuBuilder.deleteItem('${category.id}', '${item.id}')">
|
||
Delete Item
|
||
</button>
|
||
</div>
|
||
`;
|
||
},
|
||
|
||
// Select modifier and show its properties
|
||
selectModifier(itemId, modifierId) {
|
||
this.clearSelection();
|
||
|
||
// Find the modifier element and highlight it
|
||
const element = document.querySelector(`.item-card.modifier[data-modifier-id="${modifierId}"]`);
|
||
if (element) {
|
||
this.selectedElement = element;
|
||
element.classList.add('selected');
|
||
}
|
||
|
||
// Find the modifier data
|
||
for (const cat of this.menu.categories) {
|
||
const item = cat.items.find(i => i.id === itemId);
|
||
if (item) {
|
||
const modifier = item.modifiers.find(m => m.id === modifierId);
|
||
if (modifier) {
|
||
this.selectedData = { type: 'modifier', data: modifier, item: item, category: cat };
|
||
this.showPropertiesForModifier(modifier, item, cat);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
|
||
// Show properties for modifier
|
||
showPropertiesForModifier(modifier, item, category) {
|
||
document.getElementById('propertiesContent').innerHTML = `
|
||
<div class="property-group">
|
||
<label>Modifier Name</label>
|
||
<input type="text" id="propModName" value="${this.escapeHtml(modifier.name)}"
|
||
onchange="MenuBuilder.updateModifier('${item.id}', '${modifier.id}', 'name', this.value)">
|
||
</div>
|
||
<div class="property-row">
|
||
<div class="property-group">
|
||
<label>Extra Price ($)</label>
|
||
<input type="number" step="0.01" id="propModPrice" value="${modifier.price || 0}"
|
||
onchange="MenuBuilder.updateModifier('${item.id}', '${modifier.id}', 'price', parseFloat(this.value))">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>Default</label>
|
||
<select id="propModDefault"
|
||
onchange="MenuBuilder.updateModifier('${item.id}', '${modifier.id}', 'isDefault', this.value === 'true')">
|
||
<option value="false" ${!modifier.isDefault ? 'selected' : ''}>No</option>
|
||
<option value="true" ${modifier.isDefault ? 'selected' : ''}>Yes</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="property-group">
|
||
<label>Group Name (optional)</label>
|
||
<input type="text" id="propModGroup" value="${this.escapeHtml(modifier.group || '')}"
|
||
placeholder="e.g., Size, Protein, Temperature"
|
||
onchange="MenuBuilder.updateModifier('${item.id}', '${modifier.id}', 'group', this.value)">
|
||
<small style="color: var(--gray-500); font-size: 11px;">
|
||
Modifiers with the same group name are shown together
|
||
</small>
|
||
</div>
|
||
<div class="property-group">
|
||
<label>Sort Order</label>
|
||
<input type="number" id="propModSort" value="${modifier.sortOrder || 0}"
|
||
onchange="MenuBuilder.updateModifier('${item.id}', '${modifier.id}', 'sortOrder', parseInt(this.value))">
|
||
</div>
|
||
<div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border-color);">
|
||
<p style="color: var(--gray-500); font-size: 12px; margin: 0 0 8px;">
|
||
Parent Item: <strong>${this.escapeHtml(item.name)}</strong>
|
||
</p>
|
||
</div>
|
||
<div class="property-group" style="margin-top: 16px;">
|
||
<button class="btn btn-danger" onclick="MenuBuilder.deleteModifier('${item.id}', '${modifier.id}')">
|
||
Delete Modifier
|
||
</button>
|
||
</div>
|
||
`;
|
||
},
|
||
|
||
// Update modifier
|
||
updateModifier(itemId, modifierId, field, value) {
|
||
this.saveState();
|
||
for (const cat of this.menu.categories) {
|
||
const item = cat.items.find(i => i.id === itemId);
|
||
if (item) {
|
||
const modifier = item.modifiers.find(m => m.id === modifierId);
|
||
if (modifier) {
|
||
modifier[field] = value;
|
||
this.render();
|
||
// Re-select the modifier to keep properties panel open
|
||
this.selectModifier(itemId, modifierId);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
|
||
// Delete modifier
|
||
deleteModifier(itemId, modifierId) {
|
||
if (!confirm('Delete this modifier?')) return;
|
||
this.saveState();
|
||
for (const cat of this.menu.categories) {
|
||
const item = cat.items.find(i => i.id === itemId);
|
||
if (item) {
|
||
item.modifiers = item.modifiers.filter(m => m.id !== modifierId);
|
||
break;
|
||
}
|
||
}
|
||
this.clearSelection();
|
||
this.render();
|
||
this.toast('Modifier deleted', 'success');
|
||
},
|
||
|
||
// Helper: Find an option recursively in the menu tree
|
||
findOptionRecursive(options, optionId) {
|
||
for (const opt of options) {
|
||
if (opt.id === optionId) return opt;
|
||
if (opt.options && opt.options.length > 0) {
|
||
const found = this.findOptionRecursive(opt.options, optionId);
|
||
if (found) return found;
|
||
}
|
||
}
|
||
return null;
|
||
},
|
||
|
||
// Helper: Find parent info for an option
|
||
findOptionWithParent(parentId, optionId) {
|
||
// First check if parent is a top-level item
|
||
for (const cat of this.menu.categories) {
|
||
for (const item of cat.items) {
|
||
if (item.id === parentId) {
|
||
const opt = item.modifiers.find(m => m.id === optionId);
|
||
// rootItem is the item itself when parent is the item
|
||
if (opt) return { option: opt, parent: item, parentType: 'item', category: cat, rootItem: item };
|
||
}
|
||
// Check nested modifiers
|
||
const result = this.findInModifiers(item.modifiers, parentId, optionId);
|
||
if (result) return { ...result, rootItem: item, category: cat };
|
||
}
|
||
}
|
||
return null;
|
||
},
|
||
|
||
findInModifiers(modifiers, parentId, optionId) {
|
||
for (const mod of modifiers) {
|
||
if (mod.id === parentId) {
|
||
const opt = (mod.options || []).find(o => o.id === optionId);
|
||
if (opt) return { option: opt, parent: mod, parentType: 'modifier' };
|
||
}
|
||
if (mod.options && mod.options.length > 0) {
|
||
const result = this.findInModifiers(mod.options, parentId, optionId);
|
||
if (result) return result;
|
||
}
|
||
}
|
||
return null;
|
||
},
|
||
|
||
// Select option at any depth
|
||
selectOption(parentId, optionId, depth) {
|
||
this.clearSelection();
|
||
|
||
const element = document.querySelector(`.item-card.modifier[data-modifier-id="${optionId}"]`);
|
||
if (element) {
|
||
this.selectedElement = element;
|
||
element.classList.add('selected');
|
||
}
|
||
|
||
const result = this.findOptionWithParent(parentId, optionId);
|
||
if (result) {
|
||
// Store rootItem for detach functionality
|
||
this.selectedData = {
|
||
type: 'option',
|
||
data: result.option,
|
||
parent: result.parent,
|
||
depth: depth,
|
||
rootItem: result.rootItem || result.category?.items?.find(i => i.id === parentId)
|
||
};
|
||
this.showPropertiesForOption(result.option, result.parent, depth, result.rootItem);
|
||
}
|
||
},
|
||
|
||
// Show properties for option at any depth
|
||
showPropertiesForOption(option, parent, depth, rootItem) {
|
||
const hasOptions = option.options && option.options.length > 0;
|
||
const levelName = depth === 1 ? 'Modifier Group' : (depth === 2 ? 'Option' : `Level ${depth} Option`);
|
||
const isModifierGroup = depth === 1 && hasOptions;
|
||
|
||
// Check if this is a shared template (only for depth 1 modifiers)
|
||
const isShared = depth === 1 && option.dbId && this.isSharedTemplate(option.dbId);
|
||
const sharedItems = isShared ? this.getItemsSharingTemplate(option.dbId) : [];
|
||
|
||
document.getElementById('propertiesContent').innerHTML = `
|
||
${isShared ? `
|
||
<div style="background: rgba(255, 193, 7, 0.1); border: 1px solid var(--warning); border-radius: 8px; padding: 12px; margin-bottom: 16px;">
|
||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||
<span style="font-size: 16px;">🔗</span>
|
||
<strong style="color: var(--warning);">Shared Template</strong>
|
||
</div>
|
||
<p style="color: var(--gray-500); font-size: 12px; margin: 0 0 8px;">
|
||
Changes will apply to all ${sharedItems.length} items using this modifier:
|
||
</p>
|
||
<p style="color: var(--text-secondary); font-size: 11px; margin: 0; max-height: 60px; overflow-y: auto;">
|
||
${sharedItems.map(n => this.escapeHtml(n)).join(', ')}
|
||
</p>
|
||
${rootItem ? `
|
||
<button class="btn btn-secondary" style="margin-top: 12px; font-size: 12px;"
|
||
onclick="MenuBuilder.detachFromTemplate('${rootItem.id}', '${option.id}')">
|
||
✂️ Detach for This Item Only
|
||
</button>
|
||
` : ''}
|
||
</div>
|
||
` : ''}
|
||
<div class="property-group">
|
||
<label>${levelName} Name</label>
|
||
<input type="text" value="${this.escapeHtml(option.name)}"
|
||
onchange="MenuBuilder.updateOption('${parent.id}', '${option.id}', 'name', this.value)">
|
||
</div>
|
||
<div class="property-row">
|
||
<div class="property-group">
|
||
<label>Extra Price ($)</label>
|
||
<input type="number" step="0.01" value="${option.price || 0}"
|
||
onchange="MenuBuilder.updateOption('${parent.id}', '${option.id}', 'price', parseFloat(this.value))">
|
||
</div>
|
||
<div class="property-group">
|
||
<label>Default</label>
|
||
<select onchange="MenuBuilder.updateOption('${parent.id}', '${option.id}', 'isDefault', this.value === 'true')">
|
||
<option value="false" ${!option.isDefault ? 'selected' : ''}>No</option>
|
||
<option value="true" ${option.isDefault ? 'selected' : ''}>Yes</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
${isModifierGroup ? `
|
||
<div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border-color);">
|
||
<h4 style="margin: 0 0 12px; font-size: 13px; color: var(--text-muted);">Selection Rules</h4>
|
||
<div class="property-row">
|
||
<div class="property-group">
|
||
<label>Required</label>
|
||
<select onchange="MenuBuilder.updateOption('${parent.id}', '${option.id}', 'requiresSelection', this.value === 'true')">
|
||
<option value="false" ${!option.requiresSelection ? 'selected' : ''}>No</option>
|
||
<option value="true" ${option.requiresSelection ? 'selected' : ''}>Yes</option>
|
||
</select>
|
||
</div>
|
||
<div class="property-group">
|
||
<label>Max Selections</label>
|
||
<input type="number" min="0" value="${option.maxSelections || 0}"
|
||
onchange="MenuBuilder.updateOption('${parent.id}', '${option.id}', 'maxSelections', parseInt(this.value))"
|
||
title="0 = unlimited">
|
||
</div>
|
||
</div>
|
||
<p style="color: var(--gray-500); font-size: 11px; margin: 4px 0 0;">
|
||
Required = customer must select at least one option. Max 0 = unlimited.
|
||
</p>
|
||
<div class="property-group" style="margin-top: 12px;">
|
||
<label>Inverted Display</label>
|
||
<select onchange="MenuBuilder.updateOption('${parent.id}', '${option.id}', 'isInverted', this.value === 'true')">
|
||
<option value="false" ${!option.isInverted ? 'selected' : ''}>No</option>
|
||
<option value="true" ${option.isInverted ? 'selected' : ''}>Yes</option>
|
||
</select>
|
||
<p style="color: var(--gray-500); font-size: 11px; margin: 4px 0 0;">
|
||
When enabled, KDS and cart only show removed items (e.g., "NO Mustard") instead of listing all defaults.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
<div class="property-group">
|
||
<label>Sort Order</label>
|
||
<input type="number" value="${option.sortOrder || 0}"
|
||
onchange="MenuBuilder.updateOption('${parent.id}', '${option.id}', 'sortOrder', parseInt(this.value))">
|
||
</div>
|
||
|
||
<div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border-color);">
|
||
<p style="color: var(--gray-500); font-size: 12px; margin: 0 0 8px;">
|
||
Parent: <strong>${this.escapeHtml(parent.name)}</strong>
|
||
</p>
|
||
<p style="color: var(--gray-500); font-size: 12px; margin: 0;">
|
||
Sub-options: <strong>${hasOptions ? option.options.length : 0}</strong>
|
||
</p>
|
||
</div>
|
||
|
||
<div class="property-group" style="margin-top: 16px;">
|
||
<button class="btn btn-secondary" onclick="MenuBuilder.addSubOption('${option.id}')">
|
||
+ Add Sub-Option
|
||
</button>
|
||
</div>
|
||
|
||
<div class="property-group" style="margin-top: 8px;">
|
||
<button class="btn btn-danger" onclick="MenuBuilder.deleteOption('${parent.id}', '${option.id}', ${depth})">
|
||
Delete ${levelName}
|
||
</button>
|
||
</div>
|
||
`;
|
||
},
|
||
|
||
// Update option at any depth
|
||
// IMPORTANT: Templates are shared across multiple items, so we need to update ALL copies
|
||
updateOption(parentId, optionId, field, value) {
|
||
console.log('[MenuBuilder] updateOption called:', { parentId, optionId, field, value });
|
||
this.saveState();
|
||
|
||
// Get the dbId of the option being updated (to find all copies)
|
||
const result = this.findOptionWithParent(parentId, optionId);
|
||
if (!result || !result.option) {
|
||
console.error('[MenuBuilder] Could not find option to update!', { parentId, optionId });
|
||
return;
|
||
}
|
||
|
||
const optionDbId = result.option.dbId;
|
||
const parentDbId = result.parent.dbId;
|
||
const isTopLevelModifier = result.parentType === 'item'; // parent is the menu item itself
|
||
console.log('[MenuBuilder] Updating option dbId:', optionDbId, 'in parent dbId:', parentDbId, 'isTopLevel:', isTopLevelModifier);
|
||
|
||
// Update ALL instances of this option across all items (templates are duplicated)
|
||
let updateCount = 0;
|
||
for (const cat of this.menu.categories) {
|
||
for (const item of cat.items) {
|
||
for (const mod of (item.modifiers || [])) {
|
||
// If editing a top-level modifier group, match by the modifier's dbId directly
|
||
if (isTopLevelModifier && mod.dbId === optionDbId) {
|
||
mod[field] = value;
|
||
updateCount++;
|
||
console.log('[MenuBuilder] Updated top-level modifier in item:', item.name);
|
||
}
|
||
// If editing a nested option, find within the modifier's options
|
||
else if (!isTopLevelModifier && mod.dbId === parentDbId) {
|
||
// Find the option within this modifier
|
||
const opt = (mod.options || []).find(o => o.dbId === optionDbId);
|
||
if (opt) {
|
||
opt[field] = value;
|
||
updateCount++;
|
||
console.log('[MenuBuilder] Updated nested option in item:', item.name);
|
||
}
|
||
}
|
||
// Also check nested options recursively for deeper levels
|
||
this.updateOptionInModifiers(mod.options || [], parentDbId, optionDbId, field, value);
|
||
}
|
||
}
|
||
}
|
||
console.log('[MenuBuilder] Total copies updated:', updateCount);
|
||
|
||
this.render();
|
||
this.selectOption(parentId, optionId, this.selectedData?.depth || 1);
|
||
},
|
||
|
||
// Helper to recursively update option in nested modifiers
|
||
updateOptionInModifiers(options, parentDbId, optionDbId, field, value) {
|
||
for (const opt of options) {
|
||
if (opt.dbId === parentDbId) {
|
||
const child = (opt.options || []).find(o => o.dbId === optionDbId);
|
||
if (child) {
|
||
child[field] = value;
|
||
}
|
||
}
|
||
if (opt.options && opt.options.length > 0) {
|
||
this.updateOptionInModifiers(opt.options, parentDbId, optionDbId, field, value);
|
||
}
|
||
}
|
||
},
|
||
|
||
// Check if a modifier (by dbId) is shared across multiple items
|
||
isSharedTemplate(modifierDbId) {
|
||
if (!modifierDbId) return false;
|
||
|
||
let count = 0;
|
||
for (const cat of this.menu.categories) {
|
||
for (const item of cat.items) {
|
||
for (const mod of (item.modifiers || [])) {
|
||
if (mod.dbId === modifierDbId) {
|
||
count++;
|
||
if (count > 1) return true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
},
|
||
|
||
// Get list of items that share a template
|
||
getItemsSharingTemplate(modifierDbId) {
|
||
if (!modifierDbId) return [];
|
||
|
||
const items = [];
|
||
for (const cat of this.menu.categories) {
|
||
for (const item of cat.items) {
|
||
for (const mod of (item.modifiers || [])) {
|
||
if (mod.dbId === modifierDbId) {
|
||
items.push(item.name);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return items;
|
||
},
|
||
|
||
// Detach a modifier from its template - creates a local copy for this item only
|
||
detachFromTemplate(itemId, modifierId) {
|
||
this.saveState();
|
||
|
||
// Find the item and modifier
|
||
for (const cat of this.menu.categories) {
|
||
const item = cat.items.find(i => i.id === itemId);
|
||
if (item) {
|
||
const modIndex = item.modifiers.findIndex(m => m.id === modifierId);
|
||
if (modIndex !== -1) {
|
||
const mod = item.modifiers[modIndex];
|
||
|
||
// Create a deep copy with new IDs (null dbId means it will be created as new)
|
||
const detachedCopy = this.deepCopyWithNewIds(mod);
|
||
detachedCopy.name = mod.name + ' (Custom)';
|
||
|
||
// Replace the original with the detached copy
|
||
item.modifiers[modIndex] = detachedCopy;
|
||
|
||
this.render();
|
||
this.toast('Modifier detached - this item now has its own copy', 'success');
|
||
|
||
// Select the new detached modifier
|
||
this.selectOption(itemId, detachedCopy.id, 1);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
this.toast('Could not find modifier to detach', 'error');
|
||
},
|
||
|
||
// Create a deep copy of a modifier/option tree with new IDs
|
||
deepCopyWithNewIds(obj) {
|
||
const newObj = {
|
||
id: 'mod_new_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9),
|
||
dbId: null, // null = will be created as new in database
|
||
name: obj.name,
|
||
price: obj.price || 0,
|
||
isDefault: obj.isDefault || false,
|
||
sortOrder: obj.sortOrder || 0,
|
||
requiresSelection: obj.requiresSelection || false,
|
||
maxSelections: obj.maxSelections || 0,
|
||
isInverted: obj.isInverted || false,
|
||
options: []
|
||
};
|
||
|
||
// Recursively copy options
|
||
if (obj.options && obj.options.length > 0) {
|
||
newObj.options = obj.options.map(opt => this.deepCopyWithNewIds(opt));
|
||
}
|
||
|
||
return newObj;
|
||
},
|
||
|
||
// Delete option at any depth
|
||
deleteOption(parentId, optionId, depth) {
|
||
if (!confirm('Delete this option and all its sub-options?')) return;
|
||
this.saveState();
|
||
|
||
// Find and remove from parent's options array
|
||
for (const cat of this.menu.categories) {
|
||
for (const item of cat.items) {
|
||
// Check if parent is the item itself
|
||
if (item.id === parentId) {
|
||
item.modifiers = item.modifiers.filter(m => m.id !== optionId);
|
||
this.clearSelection();
|
||
this.render();
|
||
this.toast('Option deleted', 'success');
|
||
return;
|
||
}
|
||
// Check nested
|
||
if (this.deleteFromModifiers(item.modifiers, parentId, optionId)) {
|
||
this.clearSelection();
|
||
this.render();
|
||
this.toast('Option deleted', 'success');
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
|
||
deleteFromModifiers(modifiers, parentId, optionId) {
|
||
for (const mod of modifiers) {
|
||
if (mod.id === parentId && mod.options) {
|
||
const idx = mod.options.findIndex(o => o.id === optionId);
|
||
if (idx !== -1) {
|
||
mod.options.splice(idx, 1);
|
||
return true;
|
||
}
|
||
}
|
||
if (mod.options && mod.options.length > 0) {
|
||
if (this.deleteFromModifiers(mod.options, parentId, optionId)) return true;
|
||
}
|
||
}
|
||
return false;
|
||
},
|
||
|
||
// Add sub-option to any option
|
||
addSubOption(parentOptionId) {
|
||
this.saveState();
|
||
|
||
const newOption = {
|
||
id: 'opt_new_' + Date.now(),
|
||
dbId: null,
|
||
name: 'New Option',
|
||
price: 0,
|
||
isDefault: false,
|
||
sortOrder: 0,
|
||
options: []
|
||
};
|
||
|
||
// Find the parent option and add to its options array
|
||
for (const cat of this.menu.categories) {
|
||
for (const item of cat.items) {
|
||
const found = this.findOptionRecursive(item.modifiers, parentOptionId);
|
||
if (found) {
|
||
if (!found.options) found.options = [];
|
||
found.options.push(newOption);
|
||
this.render();
|
||
this.toast('Sub-option added', 'success');
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
|
||
// Find modifier/option and its parent array
|
||
findModifierWithParent(modifierId) {
|
||
for (const cat of this.menu.categories) {
|
||
for (const item of cat.items) {
|
||
// Check if it's a top-level modifier
|
||
const idx = item.modifiers.findIndex(m => m.id === modifierId);
|
||
if (idx !== -1) {
|
||
return { array: item.modifiers, index: idx, parentId: item.id, isItem: true };
|
||
}
|
||
// Check nested
|
||
const nested = this.findNestedModifierWithParent(item.modifiers, modifierId);
|
||
if (nested) return nested;
|
||
}
|
||
}
|
||
return null;
|
||
},
|
||
|
||
findNestedModifierWithParent(modifiers, modifierId) {
|
||
for (const mod of modifiers) {
|
||
if (mod.options && mod.options.length > 0) {
|
||
const idx = mod.options.findIndex(o => o.id === modifierId);
|
||
if (idx !== -1) {
|
||
return { array: mod.options, index: idx, parentId: mod.id, isItem: false };
|
||
}
|
||
const nested = this.findNestedModifierWithParent(mod.options, modifierId);
|
||
if (nested) return nested;
|
||
}
|
||
}
|
||
return null;
|
||
},
|
||
|
||
// Get the parent array for a parentId (item or modifier)
|
||
getParentModifiersArray(parentId) {
|
||
for (const cat of this.menu.categories) {
|
||
for (const item of cat.items) {
|
||
if (item.id === parentId) {
|
||
return item.modifiers;
|
||
}
|
||
const found = this.findOptionRecursive(item.modifiers, parentId);
|
||
if (found) {
|
||
if (!found.options) found.options = [];
|
||
return found.options;
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
},
|
||
|
||
// Deep clone a modifier with new IDs
|
||
cloneModifierDeep(mod) {
|
||
const clone = JSON.parse(JSON.stringify(mod));
|
||
clone.id = this.generateId();
|
||
clone.dbId = null; // Will get new dbId on save
|
||
if (clone.options && clone.options.length > 0) {
|
||
clone.options = clone.options.map(opt => this.cloneModifierDeep(opt));
|
||
}
|
||
return clone;
|
||
},
|
||
|
||
// Reorder modifier within same parent
|
||
reorderModifier(draggedId, targetId, parentId, position) {
|
||
this.saveState();
|
||
const parentArray = this.getParentModifiersArray(parentId);
|
||
if (!parentArray) return;
|
||
|
||
const draggedIdx = parentArray.findIndex(m => m.id === draggedId);
|
||
const targetIdx = parentArray.findIndex(m => m.id === targetId);
|
||
if (draggedIdx === -1 || targetIdx === -1) return;
|
||
|
||
// Remove dragged
|
||
const [dragged] = parentArray.splice(draggedIdx, 1);
|
||
|
||
// Find new target index (may have shifted)
|
||
let newTargetIdx = parentArray.findIndex(m => m.id === targetId);
|
||
if (position === 'after') newTargetIdx++;
|
||
|
||
// Insert at new position
|
||
parentArray.splice(newTargetIdx, 0, dragged);
|
||
|
||
// Update sort orders
|
||
parentArray.forEach((m, i) => m.sortOrder = i);
|
||
|
||
this.render();
|
||
this.toast('Modifier reordered', 'success');
|
||
},
|
||
|
||
// Move modifier from one parent to another
|
||
moveModifier(draggedId, fromParentId, targetModId, toParentId, position) {
|
||
this.saveState();
|
||
const fromArray = this.getParentModifiersArray(fromParentId);
|
||
const toArray = this.getParentModifiersArray(toParentId);
|
||
if (!fromArray || !toArray) return;
|
||
|
||
const draggedIdx = fromArray.findIndex(m => m.id === draggedId);
|
||
if (draggedIdx === -1) return;
|
||
|
||
// Remove from source
|
||
const [dragged] = fromArray.splice(draggedIdx, 1);
|
||
|
||
// Find target position in destination
|
||
const targetIdx = toArray.findIndex(m => m.id === targetModId);
|
||
let insertIdx = targetIdx === -1 ? toArray.length : targetIdx;
|
||
if (position === 'after') insertIdx++;
|
||
|
||
// Insert at new position
|
||
toArray.splice(insertIdx, 0, dragged);
|
||
|
||
// Update sort orders in both arrays
|
||
fromArray.forEach((m, i) => m.sortOrder = i);
|
||
toArray.forEach((m, i) => m.sortOrder = i);
|
||
|
||
this.render();
|
||
this.toast('Modifier moved', 'success');
|
||
},
|
||
|
||
// Copy modifier to position relative to another modifier
|
||
copyModifier(draggedId, fromParentId, targetModId, toParentId, position) {
|
||
this.saveState();
|
||
const fromArray = this.getParentModifiersArray(fromParentId);
|
||
const toArray = this.getParentModifiersArray(toParentId);
|
||
if (!fromArray || !toArray) return;
|
||
|
||
const dragged = fromArray.find(m => m.id === draggedId);
|
||
if (!dragged) return;
|
||
|
||
// Clone with new IDs
|
||
const clone = this.cloneModifierDeep(dragged);
|
||
|
||
// Find target position
|
||
const targetIdx = toArray.findIndex(m => m.id === targetModId);
|
||
let insertIdx = targetIdx === -1 ? toArray.length : targetIdx;
|
||
if (position === 'after') insertIdx++;
|
||
|
||
// Insert clone
|
||
toArray.splice(insertIdx, 0, clone);
|
||
|
||
// Update sort orders
|
||
toArray.forEach((m, i) => m.sortOrder = i);
|
||
|
||
this.render();
|
||
this.toast('Modifier copied', 'success');
|
||
},
|
||
|
||
// Move modifier to an item (as top-level modifier)
|
||
moveModifierToItem(draggedId, fromParentId, toItemId) {
|
||
this.saveState();
|
||
const fromArray = this.getParentModifiersArray(fromParentId);
|
||
if (!fromArray) return;
|
||
|
||
// Find target item
|
||
let targetItem = null;
|
||
for (const cat of this.menu.categories) {
|
||
targetItem = cat.items.find(i => i.id === toItemId);
|
||
if (targetItem) break;
|
||
}
|
||
if (!targetItem) return;
|
||
|
||
const draggedIdx = fromArray.findIndex(m => m.id === draggedId);
|
||
if (draggedIdx === -1) return;
|
||
|
||
// Remove from source
|
||
const [dragged] = fromArray.splice(draggedIdx, 1);
|
||
|
||
// Add to target item's modifiers
|
||
dragged.sortOrder = targetItem.modifiers.length;
|
||
targetItem.modifiers.push(dragged);
|
||
|
||
// Update sort orders
|
||
fromArray.forEach((m, i) => m.sortOrder = i);
|
||
|
||
this.render();
|
||
this.toast('Modifier moved to item', 'success');
|
||
},
|
||
|
||
// Copy modifier to an item (as top-level modifier)
|
||
copyModifierToItem(draggedId, fromParentId, toItemId) {
|
||
this.saveState();
|
||
const fromArray = this.getParentModifiersArray(fromParentId);
|
||
if (!fromArray) return;
|
||
|
||
// Find target item
|
||
let targetItem = null;
|
||
for (const cat of this.menu.categories) {
|
||
targetItem = cat.items.find(i => i.id === toItemId);
|
||
if (targetItem) break;
|
||
}
|
||
if (!targetItem) return;
|
||
|
||
const dragged = fromArray.find(m => m.id === draggedId);
|
||
if (!dragged) return;
|
||
|
||
// Clone with new IDs
|
||
const clone = this.cloneModifierDeep(dragged);
|
||
clone.sortOrder = targetItem.modifiers.length;
|
||
|
||
// Add to target item
|
||
targetItem.modifiers.push(clone);
|
||
|
||
this.render();
|
||
this.toast('Modifier copied to item', 'success');
|
||
},
|
||
|
||
// Update category
|
||
// Update category
|
||
updateCategory(categoryId, field, value) {
|
||
this.saveState();
|
||
const category = this.menu.categories.find(c => c.id === categoryId);
|
||
if (category) {
|
||
category[field] = value;
|
||
this.render();
|
||
}
|
||
},
|
||
|
||
// Set parent category (handles both id and dbId tracking)
|
||
setParentCategory(categoryId, selectEl) {
|
||
this.saveState();
|
||
const category = this.menu.categories.find(c => c.id === categoryId);
|
||
if (!category) return;
|
||
|
||
const selectedValue = selectEl.value;
|
||
if (selectedValue === '0' || !selectedValue) {
|
||
category.parentCategoryId = 0;
|
||
category.parentCategoryDbId = 0;
|
||
} else {
|
||
const parentCat = this.menu.categories.find(c => c.id === selectedValue);
|
||
if (parentCat) {
|
||
category.parentCategoryId = parentCat.id;
|
||
category.parentCategoryDbId = parentCat.dbId || 0;
|
||
}
|
||
}
|
||
this.render();
|
||
this.showPropertiesForCategory(category);
|
||
},
|
||
|
||
// Update item
|
||
updateItem(categoryId, itemId, field, value) {
|
||
this.saveState();
|
||
const category = this.menu.categories.find(c => c.id === categoryId);
|
||
if (category) {
|
||
const item = category.items.find(i => i.id === itemId);
|
||
if (item) {
|
||
item[field] = value;
|
||
this.render();
|
||
}
|
||
}
|
||
},
|
||
|
||
// Move item to category
|
||
moveItemToCategory(itemId, newCategoryId) {
|
||
this.saveState();
|
||
|
||
// Find and remove item from current category
|
||
let item = null;
|
||
for (const cat of this.menu.categories) {
|
||
const idx = cat.items.findIndex(i => i.id === itemId);
|
||
if (idx !== -1) {
|
||
item = cat.items.splice(idx, 1)[0];
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Add to new category
|
||
if (item) {
|
||
const newCat = this.menu.categories.find(c => c.id === newCategoryId);
|
||
if (newCat) {
|
||
item.sortOrder = newCat.items.length;
|
||
newCat.items.push(item);
|
||
}
|
||
}
|
||
|
||
this.render();
|
||
},
|
||
|
||
// Reorder item within same category (drag and drop)
|
||
reorderItemInCategory(draggedItemId, targetItemId, position = 'before') {
|
||
// Find the category containing these items
|
||
for (const cat of this.menu.categories) {
|
||
const draggedIdx = cat.items.findIndex(i => i.id === draggedItemId);
|
||
const targetIdx = cat.items.findIndex(i => i.id === targetItemId);
|
||
|
||
if (draggedIdx !== -1 && targetIdx !== -1 && draggedIdx !== targetIdx) {
|
||
this.saveState();
|
||
// Remove dragged item
|
||
const [draggedItem] = cat.items.splice(draggedIdx, 1);
|
||
// Find new target index (it may have shifted)
|
||
const newTargetIdx = cat.items.findIndex(i => i.id === targetItemId);
|
||
// Insert at new position
|
||
const insertIdx = position === 'before' ? newTargetIdx : newTargetIdx + 1;
|
||
cat.items.splice(insertIdx, 0, draggedItem);
|
||
// Update sortOrder for all items
|
||
cat.items.forEach((item, idx) => item.sortOrder = idx);
|
||
this.render();
|
||
return;
|
||
}
|
||
}
|
||
},
|
||
|
||
// Reorder category (drag and drop)
|
||
reorderCategory(draggedCatId, targetCatId, position = 'before') {
|
||
const draggedIdx = this.menu.categories.findIndex(c => c.id === draggedCatId);
|
||
const targetIdx = this.menu.categories.findIndex(c => c.id === targetCatId);
|
||
|
||
if (draggedIdx !== -1 && targetIdx !== -1 && draggedIdx !== targetIdx) {
|
||
this.saveState();
|
||
const [draggedCat] = this.menu.categories.splice(draggedIdx, 1);
|
||
const newTargetIdx = this.menu.categories.findIndex(c => c.id === targetCatId);
|
||
const insertIdx = position === 'before' ? newTargetIdx : newTargetIdx + 1;
|
||
this.menu.categories.splice(insertIdx, 0, draggedCat);
|
||
this.render();
|
||
}
|
||
},
|
||
|
||
// Delete category
|
||
deleteCategory(categoryId) {
|
||
if (!confirm('Delete this category and all its items?')) return;
|
||
this.saveState();
|
||
this.menu.categories = this.menu.categories.filter(c => c.id !== categoryId);
|
||
this.clearSelection();
|
||
this.render();
|
||
this.toast('Category deleted', 'success');
|
||
},
|
||
|
||
// Delete item
|
||
deleteItem(categoryId, itemId) {
|
||
if (!confirm('Delete this item?')) return;
|
||
this.saveState();
|
||
const category = this.menu.categories.find(c => c.id === categoryId);
|
||
if (category) {
|
||
category.items = category.items.filter(i => i.id !== itemId);
|
||
}
|
||
this.clearSelection();
|
||
this.render();
|
||
this.toast('Item deleted', 'success');
|
||
},
|
||
|
||
// Clone selected
|
||
cloneSelected() {
|
||
if (!this.selectedData) return;
|
||
this.saveState();
|
||
|
||
if (this.selectedData.type === 'category') {
|
||
const original = this.selectedData.data;
|
||
const clone = JSON.parse(JSON.stringify(original));
|
||
clone.id = this.generateId();
|
||
clone.name = original.name + ' (Copy)';
|
||
clone.items.forEach(item => {
|
||
item.id = this.generateId();
|
||
item.modifiers.forEach(mod => mod.id = this.generateId());
|
||
});
|
||
this.menu.categories.push(clone);
|
||
} else if (this.selectedData.type === 'item') {
|
||
const original = this.selectedData.data;
|
||
const category = this.selectedData.category;
|
||
const clone = JSON.parse(JSON.stringify(original));
|
||
clone.id = this.generateId();
|
||
clone.name = original.name + ' (Copy)';
|
||
clone.modifiers.forEach(mod => mod.id = this.generateId());
|
||
category.items.push(clone);
|
||
}
|
||
|
||
this.render();
|
||
this.toast('Cloned', 'success');
|
||
},
|
||
|
||
// Delete selected
|
||
deleteSelected() {
|
||
if (!this.selectedData) return;
|
||
|
||
if (this.selectedData.type === 'category') {
|
||
this.deleteCategory(this.selectedData.data.id);
|
||
} else if (this.selectedData.type === 'item') {
|
||
this.deleteItem(this.selectedData.category.id, this.selectedData.data.id);
|
||
}
|
||
},
|
||
|
||
// Edit selected
|
||
editSelected() {
|
||
if (!this.selectedElement) return;
|
||
// Focus on the name input in properties panel
|
||
const nameInput = document.getElementById('propCatName') || document.getElementById('propItemName');
|
||
if (nameInput) {
|
||
nameInput.focus();
|
||
nameInput.select();
|
||
}
|
||
},
|
||
|
||
// Move up
|
||
moveUp() {
|
||
if (!this.selectedData) return;
|
||
this.saveState();
|
||
|
||
if (this.selectedData.type === 'category') {
|
||
const idx = this.menu.categories.findIndex(c => c.id === this.selectedData.data.id);
|
||
if (idx > 0) {
|
||
[this.menu.categories[idx - 1], this.menu.categories[idx]] =
|
||
[this.menu.categories[idx], this.menu.categories[idx - 1]];
|
||
}
|
||
} else if (this.selectedData.type === 'item') {
|
||
const items = this.selectedData.category.items;
|
||
const idx = items.findIndex(i => i.id === this.selectedData.data.id);
|
||
if (idx > 0) {
|
||
[items[idx - 1], items[idx]] = [items[idx], items[idx - 1]];
|
||
}
|
||
}
|
||
|
||
this.render();
|
||
this.selectElementById(this.selectedData.type, this.selectedData.data.id);
|
||
},
|
||
|
||
// Move down
|
||
moveDown() {
|
||
if (!this.selectedData) return;
|
||
this.saveState();
|
||
|
||
if (this.selectedData.type === 'category') {
|
||
const idx = this.menu.categories.findIndex(c => c.id === this.selectedData.data.id);
|
||
if (idx < this.menu.categories.length - 1) {
|
||
[this.menu.categories[idx], this.menu.categories[idx + 1]] =
|
||
[this.menu.categories[idx + 1], this.menu.categories[idx]];
|
||
}
|
||
} else if (this.selectedData.type === 'item') {
|
||
const items = this.selectedData.category.items;
|
||
const idx = items.findIndex(i => i.id === this.selectedData.data.id);
|
||
if (idx < items.length - 1) {
|
||
[items[idx], items[idx + 1]] = [items[idx + 1], items[idx]];
|
||
}
|
||
}
|
||
|
||
this.render();
|
||
this.selectElementById(this.selectedData.type, this.selectedData.data.id);
|
||
},
|
||
|
||
// Create photo task
|
||
createPhotoTask(itemId = null) {
|
||
const id = itemId || (this.selectedData?.type === 'item' ? this.selectedData.data.id : null);
|
||
if (!id) {
|
||
this.toast('Select an item first', 'error');
|
||
return;
|
||
}
|
||
|
||
// Find item
|
||
let item = null;
|
||
for (const cat of this.menu.categories) {
|
||
const found = cat.items.find(i => i.id === id);
|
||
if (found) {
|
||
item = found;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!item) return;
|
||
|
||
document.getElementById('modalTitle').textContent = 'Create Photo Task';
|
||
document.getElementById('modalBody').innerHTML = `
|
||
<form id="photoTaskForm" class="form">
|
||
<div class="form-group">
|
||
<label>Item</label>
|
||
<input type="text" class="form-input" value="${this.escapeHtml(item.name)}" disabled>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Task Type</label>
|
||
<select id="taskType" class="form-select">
|
||
<option value="employee">Employee Task</option>
|
||
<option value="user">User Participation (earn PYT)</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group" id="pytRewardGroup" style="display: none;">
|
||
<label>PYT Reward</label>
|
||
<input type="number" id="pytReward" class="form-input" value="10" min="1" max="100">
|
||
<small style="color: var(--gray-500);">Tokens rewarded for approved photo</small>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Instructions (optional)</label>
|
||
<textarea id="taskInstructions" class="form-textarea" rows="3"
|
||
placeholder="e.g., Photo should show the full plate with garnish visible"></textarea>
|
||
</div>
|
||
<button type="submit" class="btn btn-primary">Create Task</button>
|
||
</form>
|
||
`;
|
||
this.showModal();
|
||
|
||
document.getElementById('taskType').addEventListener('change', (e) => {
|
||
document.getElementById('pytRewardGroup').style.display =
|
||
e.target.value === 'user' ? 'block' : 'none';
|
||
});
|
||
|
||
document.getElementById('photoTaskForm').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
await this.submitPhotoTask(item.id);
|
||
});
|
||
},
|
||
|
||
// Submit photo task
|
||
async submitPhotoTask(itemId) {
|
||
const taskType = document.getElementById('taskType').value;
|
||
const instructions = document.getElementById('taskInstructions').value;
|
||
const pytReward = taskType === 'user' ? parseInt(document.getElementById('pytReward').value) : 0;
|
||
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/tasks/create.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
BusinessID: this.config.businessId,
|
||
ItemID: itemId,
|
||
TaskType: taskType === 'user' ? 'user_photo' : 'employee_photo',
|
||
Instructions: instructions,
|
||
PYTReward: pytReward
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.OK) {
|
||
// Update item with task ID
|
||
for (const cat of this.menu.categories) {
|
||
const item = cat.items.find(i => i.id === itemId);
|
||
if (item) {
|
||
item.photoTaskId = data.TASK_ID;
|
||
break;
|
||
}
|
||
}
|
||
this.closeModal();
|
||
this.render();
|
||
this.toast('Photo task created!', 'success');
|
||
} else {
|
||
this.toast(data.ERROR || 'Failed to create task', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('[MenuBuilder] Error creating task:', err);
|
||
// For demo, just mark as having a task
|
||
for (const cat of this.menu.categories) {
|
||
const item = cat.items.find(i => i.id === itemId);
|
||
if (item) {
|
||
item.photoTaskId = 'pending_' + Date.now();
|
||
break;
|
||
}
|
||
}
|
||
this.closeModal();
|
||
this.render();
|
||
this.toast('Photo task created (demo)', 'success');
|
||
}
|
||
},
|
||
|
||
// Show upload modal - works better on mobile
|
||
showUploadModal(itemId, dbId) {
|
||
if (!dbId) {
|
||
alert('Please save the menu first before uploading photos.');
|
||
return;
|
||
}
|
||
|
||
document.getElementById('modalTitle').textContent = 'Upload Item Photo';
|
||
document.getElementById('modalBody').innerHTML = `
|
||
<form id="photoUploadForm" enctype="multipart/form-data" style="text-align: center;">
|
||
<input type="hidden" name="ItemID" value="${dbId}">
|
||
<input type="hidden" id="uploadItemId" value="${itemId}">
|
||
<p style="color: var(--gray-600); margin-bottom: 16px;">
|
||
Select or take a photo for this item
|
||
</p>
|
||
<div style="margin-bottom: 16px;">
|
||
<input type="file" name="photo" id="modalPhotoInput" accept="image/*"
|
||
style="font-size: 16px; padding: 12px; border: 2px dashed var(--gray-300);
|
||
border-radius: 8px; width: 100%; cursor: pointer;">
|
||
</div>
|
||
<div id="uploadPreview" style="margin-bottom: 16px; display: none;">
|
||
<img id="previewImg" style="max-width: 100%; max-height: 200px; border-radius: 8px;">
|
||
</div>
|
||
<button type="submit" class="btn btn-primary" id="uploadSubmitBtn" disabled
|
||
style="width: 100%; padding: 12px;">
|
||
Upload Photo
|
||
</button>
|
||
</form>
|
||
`;
|
||
this.showModal();
|
||
|
||
const fileInput = document.getElementById('modalPhotoInput');
|
||
const submitBtn = document.getElementById('uploadSubmitBtn');
|
||
const preview = document.getElementById('uploadPreview');
|
||
const previewImg = document.getElementById('previewImg');
|
||
const form = document.getElementById('photoUploadForm');
|
||
|
||
fileInput.addEventListener('change', (e) => {
|
||
const file = e.target.files[0];
|
||
if (file) {
|
||
submitBtn.disabled = false;
|
||
// Show preview
|
||
const reader = new FileReader();
|
||
reader.onload = (ev) => {
|
||
previewImg.src = ev.target.result;
|
||
preview.style.display = 'block';
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
});
|
||
|
||
form.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const file = fileInput.files[0];
|
||
if (!file) return;
|
||
|
||
submitBtn.disabled = true;
|
||
submitBtn.textContent = 'Uploading...';
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('photo', file);
|
||
formData.append('ItemID', dbId);
|
||
|
||
const response = await fetch(`${this.config.apiBaseUrl}/menu/uploadItemPhoto.cfm`, {
|
||
method: 'POST',
|
||
body: formData,
|
||
credentials: 'include'
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.OK) {
|
||
// Update the item
|
||
const itemIdVal = document.getElementById('uploadItemId').value;
|
||
for (const cat of this.menu.categories) {
|
||
const item = cat.items.find(i => i.id === itemIdVal);
|
||
if (item) {
|
||
item.imageUrl = data.IMAGEURL + '?t=' + Date.now();
|
||
break;
|
||
}
|
||
}
|
||
this.closeModal();
|
||
this.render();
|
||
this.toast('Photo uploaded!', 'success');
|
||
} else {
|
||
alert('Upload failed: ' + (data.MESSAGE || data.ERROR || 'Unknown error'));
|
||
submitBtn.disabled = false;
|
||
submitBtn.textContent = 'Upload Photo';
|
||
}
|
||
} catch (err) {
|
||
console.error('Upload error:', err);
|
||
alert('Upload failed: ' + err.message);
|
||
submitBtn.disabled = false;
|
||
submitBtn.textContent = 'Upload Photo';
|
||
}
|
||
});
|
||
},
|
||
|
||
// Pending photo upload info (for mobile camera flow)
|
||
_pendingPhotoUpload: null,
|
||
|
||
// Upload photo - legacy version (kept for compatibility)
|
||
uploadPhoto(itemId) {
|
||
// Find the item and check it has a database ID
|
||
let targetItem = null;
|
||
for (const cat of this.menu.categories) {
|
||
const item = cat.items.find(i => i.id === itemId);
|
||
if (item) { targetItem = item; break; }
|
||
}
|
||
if (!targetItem || !targetItem.dbId) {
|
||
this.toast('Please save the menu first before uploading photos', 'error');
|
||
return;
|
||
}
|
||
|
||
// Store pending upload info (survives page context issues on mobile)
|
||
this._pendingPhotoUpload = {
|
||
itemId: itemId,
|
||
dbId: targetItem.dbId
|
||
};
|
||
sessionStorage.setItem('pendingPhotoUpload', JSON.stringify(this._pendingPhotoUpload));
|
||
|
||
// Get or create persistent file input (mobile browsers handle these better)
|
||
let input = document.getElementById('photoUploadInput');
|
||
if (!input) {
|
||
input = document.createElement('input');
|
||
input.type = 'file';
|
||
input.id = 'photoUploadInput';
|
||
input.accept = 'image/*'; // Simpler accept for better mobile compatibility
|
||
input.style.display = 'none';
|
||
document.body.appendChild(input);
|
||
|
||
input.addEventListener('change', async (e) => {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
|
||
// Get pending upload info
|
||
const pending = this._pendingPhotoUpload || JSON.parse(sessionStorage.getItem('pendingPhotoUpload') || 'null');
|
||
if (!pending) {
|
||
this.toast('Upload context lost - please try again', 'error');
|
||
return;
|
||
}
|
||
|
||
if (file.size > 5 * 1024 * 1024) {
|
||
this.toast('Image must be under 5MB', 'error');
|
||
return;
|
||
}
|
||
|
||
this.toast('Uploading photo...', 'info');
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('photo', file);
|
||
formData.append('ItemID', pending.dbId);
|
||
|
||
const response = await fetch(`${this.config.apiBaseUrl}/menu/uploadItemPhoto.cfm`, {
|
||
method: 'POST',
|
||
body: formData,
|
||
credentials: 'include'
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.OK) {
|
||
// Find and update the item
|
||
for (const cat of this.menu.categories) {
|
||
const item = cat.items.find(i => i.id === pending.itemId);
|
||
if (item) {
|
||
item.imageUrl = data.IMAGEURL + '?t=' + Date.now();
|
||
break;
|
||
}
|
||
}
|
||
this.render();
|
||
this.toast('Photo uploaded!', 'success');
|
||
} else {
|
||
this.toast(data.MESSAGE || 'Failed to upload photo', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('Photo upload error:', err);
|
||
this.toast('Failed to upload photo', 'error');
|
||
}
|
||
|
||
// Clean up
|
||
sessionStorage.removeItem('pendingPhotoUpload');
|
||
this._pendingPhotoUpload = null;
|
||
input.value = ''; // Reset for next upload
|
||
});
|
||
}
|
||
|
||
// Reset and trigger
|
||
input.value = '';
|
||
input.click();
|
||
},
|
||
|
||
// Show import modal
|
||
showImportModal() {
|
||
document.getElementById('modalTitle').textContent = 'Import Menu JSON';
|
||
document.getElementById('modalBody').innerHTML = `
|
||
<form id="importForm" class="form">
|
||
<div class="form-group">
|
||
<label>Paste JSON data</label>
|
||
<textarea id="importData" class="form-textarea import-export-area"
|
||
placeholder='{"categories": [{"name": "...", "items": [...]}]}'></textarea>
|
||
</div>
|
||
<div style="display: flex; gap: 8px;">
|
||
<button type="button" class="btn btn-secondary" onclick="MenuBuilder.loadSampleMenu()">
|
||
Load Sample
|
||
</button>
|
||
<button type="submit" class="btn btn-primary">Import</button>
|
||
</div>
|
||
</form>
|
||
`;
|
||
this.showModal();
|
||
|
||
document.getElementById('importForm').addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
this.importMenu();
|
||
});
|
||
},
|
||
|
||
// Import menu
|
||
importMenu() {
|
||
try {
|
||
const data = JSON.parse(document.getElementById('importData').value);
|
||
this.saveState();
|
||
|
||
// Handle different JSON formats
|
||
if (data.categories) {
|
||
this.menu = data;
|
||
} else if (Array.isArray(data)) {
|
||
// Array of categories
|
||
this.menu.categories = data.map((cat, i) => ({
|
||
id: this.generateId(),
|
||
name: cat.name || cat.category || `Category ${i + 1}`,
|
||
description: cat.description || '',
|
||
sortOrder: i,
|
||
items: (cat.items || cat.children || []).map((item, j) => ({
|
||
id: this.generateId(),
|
||
name: item.name || item.title || 'Item',
|
||
description: item.description || '',
|
||
price: parseFloat(item.price) || 0,
|
||
imageUrl: item.imageUrl || item.image || null,
|
||
photoTaskId: null,
|
||
modifiers: (item.modifiers || item.children || []).map((mod, k) => ({
|
||
id: this.generateId(),
|
||
name: mod.name || 'Modifier',
|
||
price: parseFloat(mod.price) || 0,
|
||
isDefault: mod.isDefault || mod.checked || false,
|
||
sortOrder: k
|
||
})),
|
||
sortOrder: j
|
||
}))
|
||
}));
|
||
}
|
||
|
||
// Ensure all IDs are unique
|
||
this.menu.categories.forEach(cat => {
|
||
if (!cat.id || typeof cat.id === 'number') cat.id = this.generateId();
|
||
cat.items.forEach(item => {
|
||
if (!item.id || typeof item.id === 'number') item.id = this.generateId();
|
||
if (item.modifiers) {
|
||
item.modifiers.forEach(mod => {
|
||
if (!mod.id || typeof mod.id === 'number') mod.id = this.generateId();
|
||
});
|
||
}
|
||
});
|
||
});
|
||
|
||
this.closeModal();
|
||
this.render();
|
||
this.toast(`Imported ${this.menu.categories.length} categories`, 'success');
|
||
} catch (err) {
|
||
console.error('[MenuBuilder] Import error:', err);
|
||
this.toast('Invalid JSON format', 'error');
|
||
}
|
||
},
|
||
|
||
// Load sample menu
|
||
loadSampleMenu() {
|
||
const sample = {
|
||
categories: [
|
||
{
|
||
name: "Appetizers",
|
||
description: "Start your meal right",
|
||
items: [
|
||
{ name: "Hummus", description: "Classic chickpea dip with olive oil", price: 8.99 },
|
||
{ name: "Falafel", description: "Crispy fried chickpea balls", price: 7.99 },
|
||
{ name: "Baba Ganoush", description: "Smoky eggplant dip", price: 9.99 }
|
||
]
|
||
},
|
||
{
|
||
name: "Entrees",
|
||
description: "Main dishes",
|
||
items: [
|
||
{ name: "Chicken Shawarma", description: "Slow-roasted chicken with garlic sauce", price: 14.99 },
|
||
{ name: "Lamb Kebab", description: "Grilled lamb skewers", price: 16.99 },
|
||
{ name: "Mixed Grill", description: "Assortment of grilled meats", price: 22.99 }
|
||
]
|
||
},
|
||
{
|
||
name: "Beverages",
|
||
description: "Drinks",
|
||
items: [
|
||
{ name: "Mint Lemonade", description: "Fresh squeezed with mint", price: 4.99 },
|
||
{ name: "Turkish Coffee", description: "Traditional preparation", price: 3.99 }
|
||
]
|
||
}
|
||
]
|
||
};
|
||
document.getElementById('importData').value = JSON.stringify(sample, null, 2);
|
||
},
|
||
|
||
// Show export modal
|
||
showExportModal() {
|
||
document.getElementById('modalTitle').textContent = 'Export Menu JSON';
|
||
document.getElementById('modalBody').innerHTML = `
|
||
<div class="form-group">
|
||
<label>Menu Data</label>
|
||
<textarea id="exportData" class="form-textarea import-export-area" readonly>${JSON.stringify(this.menu, null, 2)}</textarea>
|
||
</div>
|
||
<div style="display: flex; gap: 8px;">
|
||
<button class="btn btn-secondary" onclick="MenuBuilder.copyExport()">
|
||
Copy to Clipboard
|
||
</button>
|
||
<button class="btn btn-primary" onclick="MenuBuilder.downloadExport()">
|
||
Download JSON
|
||
</button>
|
||
</div>
|
||
`;
|
||
this.showModal();
|
||
},
|
||
|
||
// Copy export
|
||
copyExport() {
|
||
const textarea = document.getElementById('exportData');
|
||
textarea.select();
|
||
document.execCommand('copy');
|
||
this.toast('Copied to clipboard', 'success');
|
||
},
|
||
|
||
// Download export
|
||
downloadExport() {
|
||
const data = document.getElementById('exportData').value;
|
||
const blob = new Blob([data], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'menu-export.json';
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
},
|
||
|
||
// Show outline modal - full menu in hierarchical text format
|
||
showOutlineModal() {
|
||
document.getElementById('modalTitle').textContent = 'Menu Outline';
|
||
this._outlineShowMods = false;
|
||
|
||
const buildOutline = () => {
|
||
const categories = this.menu.categories || [];
|
||
if (categories.length === 0)
|
||
return '<div style="color: var(--gray-500); text-align: center; padding: 40px;">No menu items yet</div>';
|
||
|
||
let outline = '<div class="menu-outline">';
|
||
const topLevel = categories.filter(c => !c.parentCategoryId || c.parentCategoryId === 0);
|
||
const getSubcats = (parent) => categories.filter(c =>
|
||
c.parentCategoryId === parent.id || (parent.dbId && c.parentCategoryDbId === parent.dbId)
|
||
);
|
||
|
||
const renderOutlineItems = (items, indentLevel) => {
|
||
let html = '';
|
||
for (const item of (items || [])) {
|
||
const itemPrice = item.price ? `$${parseFloat(item.price).toFixed(2)}` : '';
|
||
html += `<div class="outline-item" style="padding-left: ${indentLevel * 20}px;">${this.escapeHtml(item.name)}${itemPrice ? ' <span class="outline-price">' + itemPrice + '</span>' : ''}</div>`;
|
||
if (this._outlineShowMods) {
|
||
html += this.renderOutlineModifierGroups(item.modifiers || [], indentLevel + 1);
|
||
}
|
||
}
|
||
return html;
|
||
};
|
||
|
||
for (const cat of topLevel) {
|
||
outline += `<div class="outline-category">${this.escapeHtml(cat.name)}</div>`;
|
||
outline += renderOutlineItems(cat.items, 1);
|
||
const subcats = getSubcats(cat);
|
||
for (const subcat of subcats) {
|
||
outline += `<div class="outline-subcategory">${this.escapeHtml(subcat.name)}</div>`;
|
||
outline += renderOutlineItems(subcat.items, 2);
|
||
}
|
||
}
|
||
outline += '</div>';
|
||
return outline;
|
||
};
|
||
|
||
const render = () => {
|
||
const btnLabel = this._outlineShowMods ? 'Hide Modifiers' : 'Show Modifiers';
|
||
document.getElementById('modalBody').innerHTML = `
|
||
<div style="margin-bottom: 12px;">
|
||
<button class="outline-mods-btn" onclick="MenuBuilder.toggleOutlineMods()">${btnLabel}</button>
|
||
</div>
|
||
${buildOutline()}
|
||
`;
|
||
};
|
||
|
||
// Inject styles once
|
||
document.getElementById('modalBody').innerHTML = '';
|
||
if (!document.getElementById('outlineStyles')) {
|
||
const style = document.createElement('style');
|
||
style.id = 'outlineStyles';
|
||
style.textContent = `
|
||
.menu-outline {
|
||
font-family: monospace;
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
max-height: 55vh;
|
||
overflow-y: auto;
|
||
padding: 16px;
|
||
background: var(--bg-secondary);
|
||
border-radius: 8px;
|
||
}
|
||
.outline-category {
|
||
font-weight: bold;
|
||
font-size: 15px;
|
||
color: var(--primary);
|
||
margin-top: 16px;
|
||
margin-bottom: 4px;
|
||
text-transform: uppercase;
|
||
}
|
||
.outline-category:first-child { margin-top: 0; }
|
||
.outline-subcategory {
|
||
font-weight: 600;
|
||
font-size: 13px;
|
||
color: var(--text-secondary);
|
||
margin-top: 8px;
|
||
margin-bottom: 2px;
|
||
padding-left: 20px;
|
||
}
|
||
.outline-item {
|
||
padding-left: 20px;
|
||
color: var(--text-primary);
|
||
}
|
||
.outline-modifier { color: var(--text-muted); }
|
||
.outline-price { color: var(--success); }
|
||
.outline-modifier .outline-price { color: var(--warning); }
|
||
.outline-mods-btn {
|
||
padding: 6px 14px;
|
||
font-size: 13px;
|
||
border: 1px solid var(--border-color, #ccc);
|
||
border-radius: 6px;
|
||
background: var(--bg-primary, #fff);
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
}
|
||
.outline-mods-btn:hover { background: var(--bg-secondary, #f5f5f5); }
|
||
.outline-mod-group {
|
||
padding-left: 20px;
|
||
color: var(--text-muted);
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
.outline-mod-group:hover { color: var(--text-secondary); }
|
||
.outline-mod-expand { display: inline-block; width: 14px; font-size: 11px; color: var(--text-muted); }
|
||
.outline-mod-opts { display: none; }
|
||
.outline-mod-opts.open { display: block; }
|
||
`;
|
||
document.head.appendChild(style);
|
||
}
|
||
|
||
this._outlineRender = render;
|
||
render();
|
||
this.showModal();
|
||
},
|
||
|
||
toggleOutlineMods() {
|
||
this._outlineShowMods = !this._outlineShowMods;
|
||
if (this._outlineRender) this._outlineRender();
|
||
},
|
||
|
||
// Render first-level modifier groups with expandable options
|
||
renderOutlineModifierGroups(modifiers, depth) {
|
||
if (!modifiers || modifiers.length === 0) return '';
|
||
let html = '';
|
||
const pad = depth * 20;
|
||
for (let i = 0; i < modifiers.length; i++) {
|
||
const mod = modifiers[i];
|
||
const modPrice = mod.price ? `+$${parseFloat(mod.price).toFixed(2)}` : '';
|
||
const opts = mod.options || [];
|
||
const uid = 'om_' + Math.random().toString(36).substring(2, 8);
|
||
const countLabel = opts.length ? ` (${opts.length})` : '';
|
||
|
||
if (opts.length > 0) {
|
||
html += `<div class="outline-mod-group" style="padding-left: ${pad}px;" onclick="var el=document.getElementById('${uid}');el.classList.toggle('open');this.querySelector('.outline-mod-expand').textContent=el.classList.contains('open')?'−':'+'">`;
|
||
html += `<span class="outline-mod-expand">+</span> ${this.escapeHtml(mod.name)}${modPrice ? ' <span class="outline-price">' + modPrice + '</span>' : ''}${countLabel}`;
|
||
html += `</div>`;
|
||
html += `<div id="${uid}" class="outline-mod-opts">`;
|
||
for (const opt of opts) {
|
||
const optPrice = opt.price ? `+$${parseFloat(opt.price).toFixed(2)}` : '';
|
||
html += `<div class="outline-modifier" style="padding-left: ${(depth + 1) * 20}px;">${this.escapeHtml(opt.name)}${optPrice ? ' <span class="outline-price">' + optPrice + '</span>' : ''}</div>`;
|
||
}
|
||
html += `</div>`;
|
||
} else {
|
||
html += `<div class="outline-modifier" style="padding-left: ${pad}px;">${this.escapeHtml(mod.name)}${modPrice ? ' <span class="outline-price">' + modPrice + '</span>' : ''}</div>`;
|
||
}
|
||
}
|
||
return html;
|
||
},
|
||
|
||
// Helper to render modifiers recursively for outline
|
||
renderOutlineModifiers(modifiers, depth) {
|
||
if (!modifiers || modifiers.length === 0) return '';
|
||
|
||
let html = '';
|
||
const indent = '\u00A0\u00A0\u00A0\u00A0'.repeat(depth); // non-breaking spaces for indent
|
||
|
||
for (const mod of modifiers) {
|
||
const modPrice = mod.price ? `+$${parseFloat(mod.price).toFixed(2)}` : '';
|
||
html += `<div class="outline-modifier" style="padding-left: ${depth * 20}px;">${indent.substring(0, 4)}${this.escapeHtml(mod.name)}${modPrice ? ' <span class="outline-price">' + modPrice + '</span>' : ''}</div>`;
|
||
|
||
// Recurse into nested options
|
||
if (mod.options && mod.options.length > 0) {
|
||
html += this.renderOutlineModifiers(mod.options, depth + 1);
|
||
}
|
||
}
|
||
|
||
return html;
|
||
},
|
||
|
||
// Upload header image (1200x400)
|
||
uploadHeader() {
|
||
const input = document.createElement('input');
|
||
input.type = 'file';
|
||
input.accept = 'image/jpeg,image/png,image/gif,image/webp,image/heic,image/heif,.heic,.heif';
|
||
input.onchange = async (e) => {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
|
||
// Validate file type (be permissive for mobile cameras)
|
||
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/heic', 'image/heif', ''];
|
||
if (file.type && !validTypes.includes(file.type) && !file.type.startsWith('image/')) {
|
||
this.toast('Please select a valid image file', 'error');
|
||
return;
|
||
}
|
||
|
||
// Validate file size (max 5MB)
|
||
if (file.size > 5 * 1024 * 1024) {
|
||
this.toast('Image must be under 5MB', 'error');
|
||
return;
|
||
}
|
||
|
||
// Show loading toast
|
||
this.toast('Uploading header...', 'info');
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('header', file);
|
||
formData.append('BusinessID', this.config.businessId);
|
||
|
||
const response = await fetch(`${this.config.apiBaseUrl}/menu/uploadHeader.cfm`, {
|
||
method: 'POST',
|
||
body: formData,
|
||
credentials: 'include'
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.OK) {
|
||
this.toast('Header uploaded successfully! Recommended size: 600x200 JPG', 'success');
|
||
} else {
|
||
this.toast(data.MESSAGE || 'Failed to upload header', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('Header upload error:', err);
|
||
this.toast('Failed to upload header', 'error');
|
||
}
|
||
};
|
||
input.click();
|
||
},
|
||
|
||
// Show brand color picker modal
|
||
showBrandColorPicker() {
|
||
const currentColor = this.brandColor || '#1B4D3E';
|
||
const currentLight = this.brandColorLight || '';
|
||
document.getElementById('modalTitle').textContent = 'Brand Colors';
|
||
document.getElementById('modalBody').innerHTML = `
|
||
<div style="margin-bottom: 20px;">
|
||
<label style="display: block; font-weight: 600; margin-bottom: 6px; font-size: 13px;">Brand Color (Dark)</label>
|
||
<p style="margin-bottom: 10px; color: var(--text-muted); font-size: 12px;">
|
||
Used for category bar gradients in the customer app.
|
||
</p>
|
||
<div style="display: flex; align-items: center; gap: 16px; margin-bottom: 12px;">
|
||
<input type="color" id="brandColorInput" value="${currentColor}"
|
||
style="width: 60px; height: 40px; border: none; cursor: pointer; border-radius: 4px;">
|
||
<input type="text" id="brandColorHex" value="${currentColor}"
|
||
style="width: 100px; padding: 8px; border: 1px solid var(--border); border-radius: 4px; font-family: monospace;"
|
||
pattern="^#[0-9A-Fa-f]{6}$" maxlength="7">
|
||
${'EyeDropper' in window ? '<button type="button" id="eyedropperDark" style="width:40px;height:40px;border:1px solid var(--border);border-radius:4px;cursor:pointer;background:var(--bg);display:flex;align-items:center;justify-content:center;font-size:18px;" title="Pick from screen">👁</button>' : ''}
|
||
<div id="brandColorPreview" style="flex: 1; height: 40px; border-radius: 4px; background: linear-gradient(to bottom, ${currentColor}44, ${currentColor}00, ${currentColor}66);"></div>
|
||
</div>
|
||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||
<button type="button" class="color-preset" data-color="#1B4D3E" style="width: 32px; height: 32px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; background: #1B4D3E;" title="Forest Green"></button>
|
||
<button type="button" class="color-preset" data-color="#2C3E50" style="width: 32px; height: 32px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; background: #2C3E50;" title="Midnight Blue"></button>
|
||
<button type="button" class="color-preset" data-color="#8B4513" style="width: 32px; height: 32px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; background: #8B4513;" title="Saddle Brown"></button>
|
||
<button type="button" class="color-preset" data-color="#800020" style="width: 32px; height: 32px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; background: #800020;" title="Burgundy"></button>
|
||
<button type="button" class="color-preset" data-color="#1A1A2E" style="width: 32px; height: 32px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; background: #1A1A2E;" title="Dark Navy"></button>
|
||
<button type="button" class="color-preset" data-color="#4A0E4E" style="width: 32px; height: 32px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; background: #4A0E4E;" title="Deep Purple"></button>
|
||
<button type="button" class="color-preset" data-color="#CC5500" style="width: 32px; height: 32px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; background: #CC5500;" title="Burnt Orange"></button>
|
||
<button type="button" class="color-preset" data-color="#355E3B" style="width: 32px; height: 32px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; background: #355E3B;" title="Hunter Green"></button>
|
||
</div>
|
||
</div>
|
||
<div style="border-top: 1px solid var(--border); padding-top: 16px; margin-bottom: 20px;">
|
||
<label style="display: block; font-weight: 600; margin-bottom: 6px; font-size: 13px;">Brand Color (Light)</label>
|
||
<p style="margin-bottom: 10px; color: var(--text-muted); font-size: 12px;">
|
||
Subtle tint for menu builder cards and backgrounds. Leave empty for white.
|
||
</p>
|
||
<div style="display: flex; align-items: center; gap: 16px;">
|
||
<input type="color" id="brandColorLightInput" value="${currentLight || '#F5F5F5'}"
|
||
style="width: 60px; height: 40px; border: none; cursor: pointer; border-radius: 4px;">
|
||
<input type="text" id="brandColorLightHex" value="${currentLight}"
|
||
style="width: 100px; padding: 8px; border: 1px solid var(--border); border-radius: 4px; font-family: monospace;"
|
||
placeholder="#F5F5F5" maxlength="7">
|
||
${'EyeDropper' in window ? '<button type="button" id="eyedropperLight" style="width:40px;height:40px;border:1px solid var(--border);border-radius:4px;cursor:pointer;background:var(--bg);display:flex;align-items:center;justify-content:center;font-size:18px;" title="Pick from screen">👁</button>' : ''}
|
||
<div id="brandColorLightPreview" style="flex: 1; height: 40px; border-radius: 4px; border: 1px solid var(--border); background: ${currentLight || '#fff'};"></div>
|
||
<button type="button" class="btn btn-secondary" style="font-size: 12px; padding: 6px 10px;" onclick="document.getElementById('brandColorLightHex').value='';document.getElementById('brandColorLightPreview').style.background='#fff';">Clear</button>
|
||
</div>
|
||
</div>
|
||
<div style="display: flex; gap: 8px; justify-content: flex-end;">
|
||
<button type="button" class="btn btn-secondary" onclick="MenuBuilder.closeModal()">Cancel</button>
|
||
<button type="button" class="btn btn-primary" onclick="MenuBuilder.saveBrandColor()">Save Colors</button>
|
||
</div>
|
||
`;
|
||
this.showModal();
|
||
|
||
// Wire up dark color picker sync
|
||
const colorInput = document.getElementById('brandColorInput');
|
||
const hexInput = document.getElementById('brandColorHex');
|
||
const preview = document.getElementById('brandColorPreview');
|
||
|
||
const updatePreview = (color) => {
|
||
preview.style.background = `linear-gradient(to bottom, ${color}44, ${color}00, ${color}66)`;
|
||
};
|
||
|
||
colorInput.addEventListener('input', (e) => {
|
||
hexInput.value = e.target.value.toUpperCase();
|
||
updatePreview(e.target.value);
|
||
});
|
||
|
||
hexInput.addEventListener('input', (e) => {
|
||
let val = e.target.value;
|
||
if (!val.startsWith('#')) val = '#' + val;
|
||
if (/^#[0-9A-Fa-f]{6}$/.test(val)) {
|
||
colorInput.value = val;
|
||
updatePreview(val);
|
||
}
|
||
});
|
||
|
||
// Preset buttons (dark only)
|
||
document.querySelectorAll('.color-preset').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const color = btn.dataset.color;
|
||
colorInput.value = color;
|
||
hexInput.value = color;
|
||
updatePreview(color);
|
||
});
|
||
});
|
||
|
||
// Wire up light color picker sync
|
||
const lightInput = document.getElementById('brandColorLightInput');
|
||
const lightHex = document.getElementById('brandColorLightHex');
|
||
const lightPreview = document.getElementById('brandColorLightPreview');
|
||
|
||
lightInput.addEventListener('input', (e) => {
|
||
lightHex.value = e.target.value.toUpperCase();
|
||
lightPreview.style.background = e.target.value;
|
||
});
|
||
|
||
lightHex.addEventListener('input', (e) => {
|
||
let val = e.target.value;
|
||
if (!val.startsWith('#')) val = '#' + val;
|
||
if (/^#[0-9A-Fa-f]{6}$/.test(val)) {
|
||
lightInput.value = val;
|
||
lightPreview.style.background = val;
|
||
}
|
||
});
|
||
|
||
// Eyedropper buttons (Chrome/Edge only)
|
||
if ('EyeDropper' in window) {
|
||
const pickColor = async (colorInput, hexInput, onPick) => {
|
||
try {
|
||
const dropper = new EyeDropper();
|
||
const result = await dropper.open();
|
||
const color = result.sRGBHex.toUpperCase();
|
||
colorInput.value = color;
|
||
hexInput.value = color;
|
||
if (onPick) onPick(color);
|
||
} catch (e) { /* user cancelled */ }
|
||
};
|
||
|
||
document.getElementById('eyedropperDark')?.addEventListener('click', () => {
|
||
pickColor(colorInput, hexInput, updatePreview);
|
||
});
|
||
|
||
document.getElementById('eyedropperLight')?.addEventListener('click', () => {
|
||
pickColor(lightInput, lightHex, (c) => { lightPreview.style.background = c; });
|
||
});
|
||
}
|
||
},
|
||
|
||
// Save brand color
|
||
async saveBrandColor() {
|
||
const color = document.getElementById('brandColorHex').value.toUpperCase();
|
||
if (!/^#[0-9A-Fa-f]{6}$/.test(color)) {
|
||
this.toast('Invalid dark color format. Use #RRGGBB', 'error');
|
||
return;
|
||
}
|
||
const lightColor = (document.getElementById('brandColorLightHex')?.value || '').toUpperCase();
|
||
if (lightColor && !/^#[0-9A-Fa-f]{6}$/.test(lightColor)) {
|
||
this.toast('Invalid light color format. Use #RRGGBB or leave empty', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/businesses/saveBrandColor.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
BusinessID: this.config.businessId,
|
||
BrandColor: color,
|
||
BrandColorLight: lightColor
|
||
})
|
||
});
|
||
const data = await response.json();
|
||
if (data.OK) {
|
||
this.brandColor = color;
|
||
this.brandColorLight = data.BRANDCOLORLIGHT ? '#' + data.BRANDCOLORLIGHT : '';
|
||
document.getElementById('brandColorSwatch').style.background = color;
|
||
this.applyBrandTint();
|
||
this.closeModal();
|
||
this.toast('Brand color saved!', 'success');
|
||
} else {
|
||
this.toast(data.MESSAGE || 'Failed to save color', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('Save brand color error:', err);
|
||
this.toast('Failed to save color', 'error');
|
||
}
|
||
},
|
||
|
||
applyBrandTint() {
|
||
const canvas = document.getElementById('menuCanvas');
|
||
if (!canvas) return;
|
||
if (this.brandColorLight && this.brandColorLight.length >= 4) {
|
||
canvas.style.setProperty('--brand-tint', this.brandColorLight + '0A');
|
||
} else {
|
||
canvas.style.setProperty('--brand-tint', 'transparent');
|
||
}
|
||
},
|
||
|
||
// Load menu from API
|
||
async loadMenu(menuId = null) {
|
||
try {
|
||
// Use provided menuId or current selected
|
||
const loadMenuId = menuId !== null ? menuId : this.selectedMenuId;
|
||
console.log('[MenuBuilder] Loading menu for BusinessID:', this.config.businessId, 'MenuID:', loadMenuId);
|
||
|
||
const payload = { BusinessID: this.config.businessId };
|
||
if (loadMenuId > 0) {
|
||
payload.MenuID = loadMenuId;
|
||
}
|
||
|
||
const response = await fetch(`${this.config.apiBaseUrl}/menu/getForBuilder.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
console.log('[MenuBuilder] Response status:', response.status);
|
||
const data = await response.json();
|
||
console.log('[MenuBuilder] Response data:', data);
|
||
console.log('[MenuBuilder] CATEGORY_COUNT:', data.CATEGORY_COUNT);
|
||
console.log('[MenuBuilder] ITEM_COUNT:', data.ITEM_COUNT);
|
||
if (data.OK && data.MENU) {
|
||
this.menu = data.MENU;
|
||
console.log('[MenuBuilder] Menu categories:', this.menu.categories?.length || 0);
|
||
|
||
// Store menus list and update selector
|
||
this.menus = data.MENUS || [];
|
||
this.selectedMenuId = data.SELECTED_MENU_ID || 0;
|
||
this.defaultMenuId = data.DEFAULT_MENU_ID || 0;
|
||
this.updateMenuSelector();
|
||
|
||
// Store templates from API (default to empty array if not provided)
|
||
this.templates = data.TEMPLATES || [];
|
||
// Load brand colors
|
||
if (data.BRANDCOLOR && data.BRANDCOLOR.length > 0) {
|
||
this.brandColor = data.BRANDCOLOR;
|
||
const swatch = document.getElementById('brandColorSwatch');
|
||
if (swatch) swatch.style.background = data.BRANDCOLOR;
|
||
}
|
||
this.brandColorLight = data.BRANDCOLORLIGHT || '';
|
||
this.applyBrandTint();
|
||
this.renderTemplateLibrary();
|
||
this.render();
|
||
} else {
|
||
console.log('[MenuBuilder] No MENU in response or OK=false');
|
||
// Still clear the loading message
|
||
this.templates = [];
|
||
this.menus = data.MENUS || [];
|
||
this.defaultMenuId = data.DEFAULT_MENU_ID || 0;
|
||
this.updateMenuSelector();
|
||
this.renderTemplateLibrary();
|
||
}
|
||
} catch (err) {
|
||
console.error('[MenuBuilder] Error loading menu:', err);
|
||
// Clear loading message on error
|
||
this.templates = [];
|
||
this.renderTemplateLibrary();
|
||
}
|
||
},
|
||
|
||
// Update menu selector dropdown
|
||
updateMenuSelector() {
|
||
const selector = document.getElementById('menuSelector');
|
||
if (!selector) return;
|
||
|
||
let options = '<option value="0">All Menus</option>';
|
||
for (const menu of this.menus) {
|
||
const selected = menu.MenuID === this.selectedMenuId ? 'selected' : '';
|
||
options += `<option value="${menu.MenuID}" ${selected}>${this.escapeHtml(menu.MenuName)}</option>`;
|
||
}
|
||
selector.innerHTML = options;
|
||
},
|
||
|
||
// Handle menu selection change
|
||
async onMenuSelect(menuId) {
|
||
this.selectedMenuId = parseInt(menuId) || 0;
|
||
await this.loadMenu(this.selectedMenuId);
|
||
},
|
||
|
||
// Show menu manager modal
|
||
showMenuManager() {
|
||
const content = `
|
||
<div style="display: flex; flex-direction: column; gap: 16px;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||
<h4 style="margin: 0;">Your Menus</h4>
|
||
<button class="btn btn-sm btn-primary" onclick="MenuBuilder.showCreateMenuForm()">+ Add Menu</button>
|
||
</div>
|
||
<div id="menuManagerList" style="display: flex; flex-direction: column; gap: 8px;">
|
||
${this.menus.length === 0 ? '<p style="color: var(--gray-500); text-align: center;">No menus created yet. Click "Add Menu" to create one.</p>' : ''}
|
||
${this.menus.map(menu => `
|
||
<div style="display: flex; align-items: center; gap: 12px; padding: 12px; background: var(--gray-50); border-radius: 8px; ${menu.MenuID === this.defaultMenuId ? 'border: 2px solid var(--primary-color);' : ''}">
|
||
<div style="flex: 1;">
|
||
<div style="font-weight: 600;">
|
||
${this.escapeHtml(menu.MenuName)}
|
||
${menu.MenuID === this.defaultMenuId ? '<span style="color: var(--primary-color); font-size: 12px; margin-left: 8px;">★ Default</span>' : ''}
|
||
</div>
|
||
<div style="font-size: 12px; color: var(--gray-500);">
|
||
${menu.MenuStartTime && menu.MenuEndTime ? `${menu.MenuStartTime} - ${menu.MenuEndTime}` : 'All day'}
|
||
· ${this.formatDaysActive(menu.MenuDaysActive)}
|
||
</div>
|
||
</div>
|
||
${menu.MenuID !== this.defaultMenuId ? `<button class="btn btn-sm" onclick="MenuBuilder.setDefaultMenu(${menu.MenuID})" title="Set as default menu">Set Default</button>` : ''}
|
||
<button class="btn btn-sm btn-secondary" onclick="MenuBuilder.editMenu(${menu.MenuID})">Edit</button>
|
||
<button class="btn btn-sm btn-danger" onclick="MenuBuilder.deleteMenu(${menu.MenuID})">Delete</button>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
<div id="menuFormContainer" style="display: none; padding: 16px; background: var(--gray-50); border-radius: 8px;">
|
||
<h4 id="menuFormTitle" style="margin: 0 0 16px 0;">Create Menu</h4>
|
||
<input type="hidden" id="editMenuId" value="0">
|
||
<div class="property-field">
|
||
<label>Menu Name</label>
|
||
<input type="text" id="menuNameInput" placeholder="e.g., Lunch Menu, Happy Hour">
|
||
</div>
|
||
<div class="property-field">
|
||
<label>Description (optional)</label>
|
||
<input type="text" id="menuDescInput" placeholder="Brief description">
|
||
</div>
|
||
<div style="display: flex; gap: 12px;">
|
||
<div class="property-field" style="flex: 1;">
|
||
<label>Start Time</label>
|
||
<input type="time" id="menuStartInput">
|
||
</div>
|
||
<div class="property-field" style="flex: 1;">
|
||
<label>End Time</label>
|
||
<input type="time" id="menuEndInput">
|
||
</div>
|
||
</div>
|
||
<div class="property-field">
|
||
<label>Active Days</label>
|
||
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
|
||
${['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((day, i) => `
|
||
<label style="display: flex; align-items: center; gap: 4px; cursor: pointer;">
|
||
<input type="checkbox" class="menuDayCheck" data-day="${1 << i}" checked>
|
||
${day}
|
||
</label>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
<div style="display: flex; gap: 8px; margin-top: 16px;">
|
||
<button class="btn btn-primary" onclick="MenuBuilder.saveMenuForm()">Save</button>
|
||
<button class="btn btn-secondary" onclick="MenuBuilder.cancelMenuForm()">Cancel</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.getElementById('modalTitle').textContent = 'Manage Menus';
|
||
document.getElementById('modalBody').innerHTML = content;
|
||
this.showModal();
|
||
},
|
||
|
||
// Format days active bitmask to readable string
|
||
formatDaysActive(bitmask) {
|
||
if (bitmask === 127) return 'Every day';
|
||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||
const active = [];
|
||
for (let i = 0; i < 7; i++) {
|
||
if (bitmask & (1 << i)) active.push(days[i]);
|
||
}
|
||
return active.join(', ') || 'No days';
|
||
},
|
||
|
||
showCreateMenuForm() {
|
||
document.getElementById('menuFormContainer').style.display = 'block';
|
||
document.getElementById('menuFormTitle').textContent = 'Create Menu';
|
||
document.getElementById('editMenuId').value = '0';
|
||
document.getElementById('menuNameInput').value = '';
|
||
document.getElementById('menuDescInput').value = '';
|
||
document.getElementById('menuStartInput').value = '';
|
||
document.getElementById('menuEndInput').value = '';
|
||
document.querySelectorAll('.menuDayCheck').forEach(cb => cb.checked = true);
|
||
},
|
||
|
||
cancelMenuForm() {
|
||
document.getElementById('menuFormContainer').style.display = 'none';
|
||
},
|
||
|
||
async editMenu(menuId) {
|
||
const menu = this.menus.find(m => m.MenuID === menuId);
|
||
if (!menu) return;
|
||
|
||
document.getElementById('menuFormContainer').style.display = 'block';
|
||
document.getElementById('menuFormTitle').textContent = 'Edit Menu';
|
||
document.getElementById('editMenuId').value = menuId;
|
||
document.getElementById('menuNameInput').value = menu.MenuName;
|
||
document.getElementById('menuDescInput').value = menu.MenuDescription || '';
|
||
document.getElementById('menuStartInput').value = menu.MenuStartTime || '';
|
||
document.getElementById('menuEndInput').value = menu.MenuEndTime || '';
|
||
|
||
document.querySelectorAll('.menuDayCheck').forEach(cb => {
|
||
const day = parseInt(cb.dataset.day);
|
||
cb.checked = (menu.MenuDaysActive & day) !== 0;
|
||
});
|
||
},
|
||
|
||
async saveMenuForm() {
|
||
const menuId = parseInt(document.getElementById('editMenuId').value) || 0;
|
||
const menuName = document.getElementById('menuNameInput').value.trim();
|
||
const menuDesc = document.getElementById('menuDescInput').value.trim();
|
||
const menuStart = document.getElementById('menuStartInput').value;
|
||
const menuEnd = document.getElementById('menuEndInput').value;
|
||
|
||
let daysActive = 0;
|
||
document.querySelectorAll('.menuDayCheck').forEach(cb => {
|
||
if (cb.checked) daysActive |= parseInt(cb.dataset.day);
|
||
});
|
||
|
||
if (!menuName) {
|
||
this.toast('Menu name is required', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/menu/menus.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
BusinessID: this.config.businessId,
|
||
action: 'save',
|
||
MenuID: menuId,
|
||
MenuName: menuName,
|
||
MenuDescription: menuDesc,
|
||
MenuStartTime: menuStart,
|
||
MenuEndTime: menuEnd,
|
||
MenuDaysActive: daysActive
|
||
})
|
||
});
|
||
const data = await response.json();
|
||
if (data.OK) {
|
||
if (data.ACTION === 'created') {
|
||
// New menu — store context and redirect to wizard to populate it
|
||
sessionStorage.setItem('payfrit_wizard_menu', JSON.stringify({
|
||
businessId: this.config.businessId,
|
||
menuId: data.MenuID,
|
||
menuName: menuName
|
||
}));
|
||
window.location.href = `${BASE_PATH}/portal/setup-wizard.html`;
|
||
return;
|
||
}
|
||
this.toast(`Menu ${data.ACTION}!`, 'success');
|
||
this.closeModal();
|
||
await this.loadMenu();
|
||
} else {
|
||
this.toast(data.MESSAGE || 'Failed to save menu', 'error');
|
||
}
|
||
} catch (err) {
|
||
this.toast('Error saving menu', 'error');
|
||
}
|
||
},
|
||
|
||
async deleteMenu(menuId) {
|
||
if (!confirm('Are you sure you want to delete this menu? Categories in this menu will become unassigned.')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/menu/menus.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
BusinessID: this.config.businessId,
|
||
action: 'delete',
|
||
MenuID: menuId
|
||
})
|
||
});
|
||
const data = await response.json();
|
||
if (data.OK) {
|
||
this.toast('Menu deleted', 'success');
|
||
this.closeModal();
|
||
await this.loadMenu();
|
||
} else {
|
||
this.toast(data.MESSAGE || 'Failed to delete menu', 'error');
|
||
}
|
||
} catch (err) {
|
||
this.toast('Error deleting menu', 'error');
|
||
}
|
||
},
|
||
|
||
async setDefaultMenu(menuId) {
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/menu/menus.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
BusinessID: this.config.businessId,
|
||
action: 'setDefault',
|
||
MenuID: menuId
|
||
})
|
||
});
|
||
const data = await response.json();
|
||
if (data.OK) {
|
||
this.defaultMenuId = menuId;
|
||
this.toast('Default menu updated', 'success');
|
||
// Refresh the menu manager display
|
||
this.showMenuManager();
|
||
} else {
|
||
this.toast(data.MESSAGE || 'Failed to set default menu', 'error');
|
||
}
|
||
} catch (err) {
|
||
this.toast('Error setting default menu', 'error');
|
||
}
|
||
},
|
||
|
||
// Render template library in sidebar
|
||
renderTemplateLibrary() {
|
||
const container = document.getElementById('templateLibrary');
|
||
if (!container || !this.templates) return;
|
||
|
||
if (this.templates.length === 0) {
|
||
container.innerHTML = '<div style="color: var(--gray-500); font-size: 13px; padding: 8px;">No templates yet</div>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = this.templates.map(tmpl => `
|
||
<div class="palette-item template-item" draggable="true" data-type="template" data-template-id="${tmpl.dbId}">
|
||
<div class="icon">📋</div>
|
||
<div>
|
||
<div class="label">${this.escapeHtml(tmpl.name)}</div>
|
||
<div class="hint">${tmpl.options ? tmpl.options.length : 0} options</div>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
|
||
// Update template count stat
|
||
const statEl = document.getElementById('statTemplates');
|
||
if (statEl) statEl.textContent = this.templates.length;
|
||
},
|
||
|
||
// Save menu to API
|
||
async saveMenu() {
|
||
try {
|
||
console.log('[MenuBuilder] Saving menu...');
|
||
console.log('[MenuBuilder] BusinessID:', this.config.businessId);
|
||
|
||
// Debug: Find and log the specific option we're testing
|
||
for (const cat of this.menu.categories) {
|
||
for (const item of cat.items) {
|
||
for (const mod of (item.modifiers || [])) {
|
||
if (mod.name === 'Select Drink') {
|
||
console.log('[MenuBuilder] Select Drink modifier at save time:', JSON.stringify(mod, null, 2));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
console.log('[MenuBuilder] Menu data:', JSON.stringify(this.menu, null, 2));
|
||
|
||
// Deep clone menu and strip base64 data URLs to avoid huge payloads
|
||
const menuClone = JSON.parse(JSON.stringify(this.menu));
|
||
for (const cat of menuClone.categories) {
|
||
for (const item of (cat.items || [])) {
|
||
if (item.imageUrl && item.imageUrl.startsWith('data:')) {
|
||
item.imageUrl = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
const payload = {
|
||
BusinessID: this.config.businessId,
|
||
Menu: menuClone
|
||
};
|
||
console.log('[MenuBuilder] Full payload:', JSON.stringify(payload, null, 2));
|
||
|
||
const response = await fetch(`${this.config.apiBaseUrl}/menu/saveFromBuilder.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
|
||
console.log('[MenuBuilder] Response status:', response.status);
|
||
const responseText = await response.text();
|
||
console.log('[MenuBuilder] Raw response:', responseText);
|
||
|
||
let data;
|
||
try {
|
||
data = JSON.parse(responseText);
|
||
} catch (parseErr) {
|
||
console.error('[MenuBuilder] Failed to parse response as JSON:', parseErr);
|
||
this.toast('Server returned invalid response', 'error');
|
||
return;
|
||
}
|
||
|
||
console.log('[MenuBuilder] Parsed response:', data);
|
||
|
||
if (data.OK) {
|
||
this.toast('Menu saved successfully!', 'success');
|
||
// Reload menu to get updated dbIds
|
||
await this.loadMenu();
|
||
} else {
|
||
console.error('[MenuBuilder] Save failed:', data.ERROR, data.DETAIL);
|
||
this.toast(data.ERROR || 'Failed to save', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('[MenuBuilder] Save error:', err);
|
||
this.toast('Error saving menu: ' + err.message, 'error');
|
||
}
|
||
},
|
||
|
||
// Recursive function to render modifiers/options at any depth
|
||
renderModifiers(modifiers, parentItemId, depth) {
|
||
if (!modifiers || modifiers.length === 0) return '';
|
||
|
||
const indent = 16 + (depth * 16); // Progressive indentation
|
||
const iconSize = Math.max(24, 32 - (depth * 4)); // Smaller icons for deeper levels
|
||
const icons = ['⚙️', '🔘', '◉', '•']; // Different icons for different levels
|
||
const icon = icons[Math.min(depth - 1, icons.length - 1)];
|
||
|
||
return modifiers.map(mod => {
|
||
const hasOptions = mod.options && mod.options.length > 0;
|
||
const modExpanded = this.expandedModifierIds.has(mod.id);
|
||
return `
|
||
<div class="item-card modifier depth-${depth} ${modExpanded ? 'expanded' : ''}"
|
||
data-modifier-id="${mod.id}" data-parent-id="${parentItemId}" data-depth="${depth}"
|
||
draggable="true"
|
||
style="margin-left: ${indent}px;"
|
||
onclick="event.stopPropagation(); MenuBuilder.selectOption('${parentItemId}', '${mod.id}', ${depth})">
|
||
${hasOptions ? `
|
||
<div class="item-toggle ${modExpanded ? 'expanded' : ''}" onclick="event.stopPropagation(); MenuBuilder.toggleModifier('${mod.id}')">
|
||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M9 18l6-6-6-6"/>
|
||
</svg>
|
||
</div>
|
||
` : ''}
|
||
<div class="drag-handle">
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||
<circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/>
|
||
<circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/>
|
||
<circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/>
|
||
</svg>
|
||
</div>
|
||
<div class="item-image" style="width: ${iconSize}px; height: ${iconSize}px; font-size: ${iconSize/2}px;">${icon}</div>
|
||
<div class="item-info" onclick="event.stopPropagation(); ${hasOptions ? `MenuBuilder.toggleModifier('${mod.id}')` : `MenuBuilder.selectOption('${parentItemId}', '${mod.id}', ${depth})`}" style="cursor: pointer;">
|
||
<div class="item-name">${this.escapeHtml(mod.name)}</div>
|
||
<div class="item-meta">
|
||
${mod.price > 0 ? `<span>+$${mod.price.toFixed(2)}</span>` : ''}
|
||
${mod.isDefault ? '<span class="item-type-badge">Default</span>' : ''}
|
||
${mod.requiresSelection ? '<span class="item-type-badge" style="background: #dc3545; color: white;">Required</span>' : ''}
|
||
${mod.maxSelections > 0 ? `<span class="item-type-badge">Max ${mod.maxSelections}</span>` : ''}
|
||
${mod.isInverted ? '<span class="item-type-badge" style="background: #6f42c1; color: white;">Inverted</span>' : ''}
|
||
${hasOptions ? `<span class="item-type-badge">${mod.options.length} options</span>` : ''}
|
||
</div>
|
||
</div>
|
||
<div class="item-actions">
|
||
<button onclick="event.stopPropagation(); MenuBuilder.deleteOption('${parentItemId}', '${mod.id}', ${depth})" title="Delete">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M18 6L6 18M6 6l12 12"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
${hasOptions && modExpanded ? this.renderModifiers(mod.options, mod.id, depth + 1) : ''}
|
||
`;
|
||
}).join('');
|
||
},
|
||
|
||
// Render a single item card (reused by categories and subcategories)
|
||
renderItemCard(item, category) {
|
||
const itemExpanded = this.expandedItemId === item.id;
|
||
const hasModifiers = item.modifiers && item.modifiers.length > 0;
|
||
return `
|
||
<div class="item-card ${itemExpanded ? 'expanded' : ''}" data-item-id="${item.id}" draggable="true"
|
||
onclick="event.stopPropagation(); MenuBuilder.selectElement(this)">
|
||
${hasModifiers ? `
|
||
<div class="item-toggle ${itemExpanded ? 'expanded' : ''}" onclick="event.stopPropagation(); MenuBuilder.toggleItem('${item.id}')">
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M9 18l6-6-6-6"/>
|
||
</svg>
|
||
</div>
|
||
` : ''}
|
||
<div class="drag-handle">
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||
<circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/>
|
||
<circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/>
|
||
<circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/>
|
||
</svg>
|
||
</div>
|
||
<div class="item-image">
|
||
${item.imageUrl ? `<img src="${this.getImageUrls(item.imageUrl).thumb}" alt="" onerror="this.onerror=null; this.src='${item.imageUrl}'">` : '🍽️'}
|
||
${item.photoTaskId ? `
|
||
<div class="photo-badge ${item.imageUrl ? '' : 'missing'}" title="${item.imageUrl ? 'Has photo' : 'Photo task pending'}">
|
||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||
<path d="M21 15l-5-5L5 21"/>
|
||
</svg>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
<div class="item-info" onclick="event.stopPropagation(); ${hasModifiers ? `MenuBuilder.toggleItem('${item.id}')` : `MenuBuilder.selectItem('${item.id}')`}" style="cursor: pointer;">
|
||
<div class="item-name">${this.escapeHtml(item.name)}</div>
|
||
<div class="item-meta">
|
||
<span class="item-price">$${(item.price || 0).toFixed(2)}</span>
|
||
${hasModifiers ? `<span>${item.modifiers.length} modifiers</span>` : ''}
|
||
</div>
|
||
</div>
|
||
<div class="item-actions">
|
||
<button onclick="event.stopPropagation(); MenuBuilder.cloneItem('${category.id}', '${item.id}')" title="Clone">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<rect x="9" y="9" width="13" height="13" rx="2"/>
|
||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
|
||
</svg>
|
||
</button>
|
||
<button class="danger" onclick="event.stopPropagation(); MenuBuilder.deleteItem('${category.id}', '${item.id}')" title="Delete">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M18 6L6 18M6 6l12 12"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
${itemExpanded ? this.renderModifiers(item.modifiers, item.id, 1) : ''}
|
||
`;
|
||
},
|
||
|
||
// Render menu structure
|
||
render() {
|
||
const container = document.getElementById('menuStructure');
|
||
|
||
if (this.menu.categories.length === 0) {
|
||
container.innerHTML = `
|
||
<div class="empty-canvas" id="emptyCanvas">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||
<path d="M3 6h18M3 12h18M3 18h18"/>
|
||
</svg>
|
||
<h3>Start Building Your Menu</h3>
|
||
<p>Drag categories and items from the left panel, or click the buttons above to add them.</p>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
// Separate top-level categories and subcategories
|
||
const topLevelCategories = this.menu.categories.filter(c => !c.parentCategoryId || c.parentCategoryId === 0);
|
||
const getSubcategories = (parentCat) => this.menu.categories.filter(c =>
|
||
c.parentCategoryId === parentCat.id || (parentCat.dbId && c.parentCategoryDbId === parentCat.dbId)
|
||
);
|
||
|
||
container.innerHTML = topLevelCategories.map(category => {
|
||
const isExpanded = this.expandedCategoryId === category.id;
|
||
const subcategories = getSubcategories(category);
|
||
const hasSubcats = subcategories.length > 0;
|
||
const totalItems = hasSubcats
|
||
? subcategories.reduce((sum, sc) => sum + sc.items.length, 0) + category.items.length
|
||
: category.items.length;
|
||
return `
|
||
<div class="category-card" data-category-id="${category.id}" draggable="true"
|
||
onclick="MenuBuilder.selectElement(this)">
|
||
<div class="category-header">
|
||
<div class="category-toggle ${isExpanded ? 'expanded' : ''}" onclick="event.stopPropagation(); MenuBuilder.toggleCategory('${category.id}')">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M9 18l6-6-6-6"/>
|
||
</svg>
|
||
</div>
|
||
<div class="drag-handle">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||
<circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/>
|
||
<circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/>
|
||
<circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/>
|
||
</svg>
|
||
</div>
|
||
<div class="category-info" onclick="event.stopPropagation(); MenuBuilder.toggleCategory('${category.id}')" style="cursor: pointer;">
|
||
<div class="category-name">${this.escapeHtml(category.name)}</div>
|
||
<div class="category-count">${totalItems} items${hasSubcats ? `, ${subcategories.length} subcategories` : ''}</div>
|
||
</div>
|
||
<div class="category-actions">
|
||
${hasSubcats || category.items.length === 0 ? `
|
||
<button onclick="event.stopPropagation(); MenuBuilder.addCategory(null, '${category.id}')" title="Add Subcategory">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
|
||
</svg>
|
||
</button>
|
||
` : ''}
|
||
<button onclick="event.stopPropagation(); MenuBuilder.addItem('${category.id}')" title="Add Item">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M12 5v14M5 12h14"/>
|
||
</svg>
|
||
</button>
|
||
<button class="danger" onclick="event.stopPropagation(); MenuBuilder.deleteCategory('${category.id}')" title="Delete">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="items-list ${isExpanded ? '' : 'collapsed'}">
|
||
${hasSubcats ? subcategories.map(subcat => {
|
||
const subExpanded = this.expandedSubCategoryId === subcat.id;
|
||
return `
|
||
<div class="category-card subcategory-card" data-category-id="${subcat.id}" draggable="true"
|
||
onclick="event.stopPropagation(); MenuBuilder.selectElement(this)" style="margin: 8px 0 8px 20px; border-color: var(--gray-300);">
|
||
<div class="category-header" style="padding: 8px 12px;">
|
||
<div class="category-toggle ${subExpanded ? 'expanded' : ''}" onclick="event.stopPropagation(); MenuBuilder.toggleSubCategory('${subcat.id}')">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M9 18l6-6-6-6"/>
|
||
</svg>
|
||
</div>
|
||
<div class="drag-handle">
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||
<circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/>
|
||
<circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/>
|
||
<circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/>
|
||
</svg>
|
||
</div>
|
||
<div class="category-info" onclick="event.stopPropagation(); MenuBuilder.toggleSubCategory('${subcat.id}')" style="cursor: pointer;">
|
||
<div class="category-name" style="font-size: 14px;">${this.escapeHtml(subcat.name)}</div>
|
||
<div class="category-count">${subcat.items.length} items</div>
|
||
</div>
|
||
<div class="category-actions">
|
||
<button onclick="event.stopPropagation(); MenuBuilder.addItem('${subcat.id}')" title="Add Item">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M12 5v14M5 12h14"/>
|
||
</svg>
|
||
</button>
|
||
<button class="danger" onclick="event.stopPropagation(); MenuBuilder.deleteCategory('${subcat.id}')" title="Delete">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M18 6L6 18M6 6l12 12"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="items-list ${subExpanded ? '' : 'collapsed'}">
|
||
${subcat.items.length === 0 ? `
|
||
<div class="item-drop-zone">Drag items here or click + to add</div>
|
||
` : subcat.items.map(item => this.renderItemCard(item, subcat)).join('')}
|
||
</div>
|
||
</div>`;
|
||
}).join('') : ''}
|
||
${!hasSubcats && category.items.length === 0 ? `
|
||
<div class="item-drop-zone">Drag items here or click + to add</div>
|
||
` : category.items.map(item => this.renderItemCard(item, category)).join('')}
|
||
</div>
|
||
</div>
|
||
`}).join('');
|
||
|
||
// Update stats
|
||
let totalItems = 0;
|
||
let missingPhotos = 0;
|
||
this.menu.categories.forEach(cat => {
|
||
totalItems += cat.items.length;
|
||
cat.items.forEach(item => {
|
||
if (!item.imageUrl) missingPhotos++;
|
||
});
|
||
});
|
||
|
||
document.getElementById('statCategories').textContent = this.menu.categories.length;
|
||
document.getElementById('statItems').textContent = totalItems;
|
||
document.getElementById('statPhotosMissing').textContent = missingPhotos;
|
||
|
||
// Re-setup drag handlers for new elements
|
||
this.setupItemDragHandlers();
|
||
},
|
||
|
||
// Setup drag handlers for items
|
||
setupItemDragHandlers() {
|
||
document.querySelectorAll('.category-card').forEach(card => {
|
||
card.addEventListener('dragstart', (e) => {
|
||
e.dataTransfer.setData('categoryId', card.dataset.categoryId);
|
||
e.dataTransfer.setData('source', 'canvas');
|
||
card.classList.add('dragging');
|
||
});
|
||
|
||
card.addEventListener('dragend', () => {
|
||
card.classList.remove('dragging');
|
||
document.querySelectorAll('.drop-before, .drop-after').forEach(el => {
|
||
el.classList.remove('drop-before', 'drop-after');
|
||
});
|
||
});
|
||
|
||
card.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
const isDraggingCategory = e.dataTransfer.types.includes('categoryid');
|
||
const isDraggingItem = e.dataTransfer.types.includes('itemid');
|
||
|
||
if (isDraggingItem) {
|
||
card.classList.add('drag-over');
|
||
} else if (isDraggingCategory && !card.classList.contains('dragging')) {
|
||
// Category reordering - show drop indicator
|
||
const rect = card.getBoundingClientRect();
|
||
const midpoint = rect.top + rect.height / 2;
|
||
card.classList.remove('drop-before', 'drop-after', 'drag-over');
|
||
if (e.clientY < midpoint) {
|
||
card.classList.add('drop-before');
|
||
} else {
|
||
card.classList.add('drop-after');
|
||
}
|
||
}
|
||
});
|
||
|
||
card.addEventListener('dragleave', () => {
|
||
card.classList.remove('drag-over', 'drop-before', 'drop-after');
|
||
});
|
||
|
||
card.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
card.classList.remove('drag-over', 'drop-before', 'drop-after');
|
||
|
||
const itemId = e.dataTransfer.getData('itemId');
|
||
const draggedCategoryId = e.dataTransfer.getData('categoryId');
|
||
const targetCategoryId = card.dataset.categoryId;
|
||
|
||
if (itemId) {
|
||
// Moving item to category
|
||
this.moveItemToCategory(itemId, targetCategoryId);
|
||
} else if (draggedCategoryId && draggedCategoryId !== targetCategoryId) {
|
||
// Reordering categories
|
||
const rect = card.getBoundingClientRect();
|
||
const midpoint = rect.top + rect.height / 2;
|
||
const position = e.clientY < midpoint ? 'before' : 'after';
|
||
this.reorderCategory(draggedCategoryId, targetCategoryId, position);
|
||
}
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('.item-card:not(.modifier)').forEach(card => {
|
||
card.addEventListener('dragstart', (e) => {
|
||
e.stopPropagation();
|
||
e.dataTransfer.setData('itemId', card.dataset.itemId);
|
||
e.dataTransfer.setData('source', 'canvas');
|
||
card.classList.add('dragging');
|
||
});
|
||
|
||
card.addEventListener('dragend', () => {
|
||
card.classList.remove('dragging');
|
||
// Remove all drop indicators
|
||
document.querySelectorAll('.drop-before, .drop-after').forEach(el => {
|
||
el.classList.remove('drop-before', 'drop-after');
|
||
});
|
||
});
|
||
|
||
card.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const draggedItemId = e.dataTransfer.types.includes('itemid');
|
||
if (draggedItemId && !card.classList.contains('dragging')) {
|
||
// Determine if dropping above or below based on mouse position
|
||
const rect = card.getBoundingClientRect();
|
||
const midpoint = rect.top + rect.height / 2;
|
||
card.classList.remove('drop-before', 'drop-after');
|
||
if (e.clientY < midpoint) {
|
||
card.classList.add('drop-before');
|
||
} else {
|
||
card.classList.add('drop-after');
|
||
}
|
||
}
|
||
});
|
||
|
||
card.addEventListener('dragleave', () => {
|
||
card.classList.remove('drop-before', 'drop-after');
|
||
});
|
||
|
||
card.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const draggedItemId = e.dataTransfer.getData('itemId');
|
||
const targetItemId = card.dataset.itemId;
|
||
if (draggedItemId && targetItemId && draggedItemId !== targetItemId) {
|
||
const position = card.classList.contains('drop-before') ? 'before' : 'after';
|
||
this.reorderItemInCategory(draggedItemId, targetItemId, position);
|
||
}
|
||
card.classList.remove('drop-before', 'drop-after');
|
||
});
|
||
});
|
||
|
||
// Modifier drag handlers
|
||
document.querySelectorAll('.item-card.modifier').forEach(card => {
|
||
card.addEventListener('dragstart', (e) => {
|
||
e.stopPropagation();
|
||
e.dataTransfer.setData('modifierId', card.dataset.modifierId);
|
||
e.dataTransfer.setData('parentId', card.dataset.parentId);
|
||
e.dataTransfer.setData('depth', card.dataset.depth);
|
||
e.dataTransfer.setData('source', 'modifier');
|
||
// Default is copy, hold Shift for move
|
||
const isMove = e.shiftKey;
|
||
e.dataTransfer.setData('isMove', isMove ? 'true' : 'false');
|
||
e.dataTransfer.effectAllowed = isMove ? 'move' : 'copy';
|
||
card.classList.add('dragging');
|
||
});
|
||
|
||
card.addEventListener('dragend', () => {
|
||
card.classList.remove('dragging');
|
||
document.querySelectorAll('.drop-before, .drop-after, .drag-over').forEach(el => {
|
||
el.classList.remove('drop-before', 'drop-after', 'drag-over');
|
||
});
|
||
});
|
||
|
||
card.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const isDraggingModifier = e.dataTransfer.types.includes('modifierid');
|
||
if (isDraggingModifier && !card.classList.contains('dragging')) {
|
||
const rect = card.getBoundingClientRect();
|
||
const midpoint = rect.top + rect.height / 2;
|
||
card.classList.remove('drop-before', 'drop-after');
|
||
if (e.clientY < midpoint) {
|
||
card.classList.add('drop-before');
|
||
} else {
|
||
card.classList.add('drop-after');
|
||
}
|
||
}
|
||
});
|
||
|
||
card.addEventListener('dragleave', (e) => {
|
||
card.classList.remove('drop-before', 'drop-after');
|
||
});
|
||
|
||
card.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const draggedModId = e.dataTransfer.getData('modifierId');
|
||
const draggedParentId = e.dataTransfer.getData('parentId');
|
||
const targetModId = card.dataset.modifierId;
|
||
const targetParentId = card.dataset.parentId;
|
||
const isMove = e.dataTransfer.getData('isMove') === 'true';
|
||
|
||
if (draggedModId && targetModId && draggedModId !== targetModId) {
|
||
const position = card.classList.contains('drop-before') ? 'before' : 'after';
|
||
|
||
if (isMove && draggedParentId === targetParentId) {
|
||
// Same parent + move = reorder
|
||
this.reorderModifier(draggedModId, targetModId, targetParentId, position);
|
||
} else if (isMove) {
|
||
// Different parent + move = move modifier
|
||
this.moveModifier(draggedModId, draggedParentId, targetModId, targetParentId, position);
|
||
} else {
|
||
// Copy (default)
|
||
this.copyModifier(draggedModId, draggedParentId, targetModId, targetParentId, position);
|
||
}
|
||
}
|
||
card.classList.remove('drop-before', 'drop-after');
|
||
});
|
||
});
|
||
|
||
// Allow dropping modifiers onto items (to add as top-level modifier)
|
||
document.querySelectorAll('.item-card:not(.modifier)').forEach(card => {
|
||
card.addEventListener('dragover', (e) => {
|
||
const isDraggingModifier = e.dataTransfer.types.includes('modifierid');
|
||
if (isDraggingModifier) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
card.classList.add('drag-over');
|
||
}
|
||
});
|
||
|
||
card.addEventListener('dragleave', (e) => {
|
||
if (e.dataTransfer.types.includes('modifierid')) {
|
||
card.classList.remove('drag-over');
|
||
}
|
||
});
|
||
|
||
card.addEventListener('drop', (e) => {
|
||
const isDraggingModifier = e.dataTransfer.types.includes('modifierid');
|
||
if (isDraggingModifier) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const draggedModId = e.dataTransfer.getData('modifierId');
|
||
const draggedParentId = e.dataTransfer.getData('parentId');
|
||
const targetItemId = card.dataset.itemId;
|
||
const isMove = e.dataTransfer.getData('isMove') === 'true';
|
||
|
||
if (draggedModId && targetItemId) {
|
||
if (isMove) {
|
||
this.moveModifierToItem(draggedModId, draggedParentId, targetItemId);
|
||
} else {
|
||
this.copyModifierToItem(draggedModId, draggedParentId, targetItemId);
|
||
}
|
||
}
|
||
card.classList.remove('drag-over');
|
||
}
|
||
});
|
||
});
|
||
},
|
||
|
||
// Toggle category expanded/collapsed (accordion behavior)
|
||
toggleCategory(categoryId) {
|
||
if (this.expandedCategoryId === categoryId) {
|
||
this.expandedCategoryId = null;
|
||
} else {
|
||
this.expandedCategoryId = categoryId;
|
||
this.expandedSubCategoryId = null;
|
||
this.expandedItemId = null;
|
||
this.expandedModifierIds.clear();
|
||
}
|
||
this.render();
|
||
},
|
||
|
||
// Toggle subcategory expanded/collapsed (independent of parent)
|
||
toggleSubCategory(subcatId) {
|
||
if (this.expandedSubCategoryId === subcatId) {
|
||
this.expandedSubCategoryId = null;
|
||
} else {
|
||
this.expandedSubCategoryId = subcatId;
|
||
this.expandedItemId = null;
|
||
this.expandedModifierIds.clear();
|
||
}
|
||
this.render();
|
||
},
|
||
|
||
// Toggle item expanded/collapsed
|
||
toggleItem(itemId) {
|
||
if (this.expandedItemId === itemId) {
|
||
this.expandedItemId = null;
|
||
} else {
|
||
this.expandedItemId = itemId;
|
||
this.expandedModifierIds.clear();
|
||
}
|
||
this.render();
|
||
},
|
||
|
||
// Toggle modifier expanded/collapsed
|
||
toggleModifier(modifierId) {
|
||
if (this.expandedModifierIds.has(modifierId)) {
|
||
this.expandedModifierIds.delete(modifierId);
|
||
} else {
|
||
this.expandedModifierIds.add(modifierId);
|
||
}
|
||
this.render();
|
||
},
|
||
|
||
// Clone item
|
||
cloneItem(categoryId, itemId) {
|
||
this.saveState();
|
||
const category = this.menu.categories.find(c => c.id === categoryId);
|
||
if (category) {
|
||
const item = category.items.find(i => i.id === itemId);
|
||
if (item) {
|
||
const clone = JSON.parse(JSON.stringify(item));
|
||
clone.id = this.generateId();
|
||
clone.name = item.name + ' (Copy)';
|
||
clone.modifiers.forEach(mod => mod.id = this.generateId());
|
||
category.items.push(clone);
|
||
this.render();
|
||
this.toast('Item cloned', 'success');
|
||
}
|
||
}
|
||
},
|
||
|
||
// Show modal
|
||
showModal() {
|
||
document.getElementById('modalOverlay').classList.add('visible');
|
||
},
|
||
|
||
// Close modal
|
||
closeModal() {
|
||
document.getElementById('modalOverlay').classList.remove('visible');
|
||
},
|
||
|
||
// Toast
|
||
toast(message, type = 'info') {
|
||
const container = document.getElementById('toastContainer');
|
||
const toast = document.createElement('div');
|
||
toast.className = `toast ${type}`;
|
||
toast.textContent = message;
|
||
container.appendChild(toast);
|
||
setTimeout(() => toast.remove(), 3000);
|
||
},
|
||
|
||
// Escape HTML
|
||
escapeHtml(str) {
|
||
if (!str) return '';
|
||
return str.replace(/[&<>"']/g, (m) => ({
|
||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||
})[m]);
|
||
}
|
||
};
|
||
|
||
// Initialize
|
||
document.addEventListener('DOMContentLoaded', () => MenuBuilder.init());
|
||
</script>
|
||
</body>
|
||
</html>
|