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