Previously each extraction step cleared the conversation and only showed the latest result. Now completed menus accumulate as checkmarked lines while the spinner updates in place for the current extraction. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
3980 lines
155 KiB
HTML
3980 lines
155 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 Setup Wizard - Payfrit</title>
|
||
<link rel="stylesheet" href="portal.css">
|
||
<style>
|
||
/* Setup Wizard Specific Styles */
|
||
.wizard-container {
|
||
max-width: 900px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.wizard-header {
|
||
text-align: center;
|
||
margin-bottom: 32px;
|
||
}
|
||
|
||
.wizard-header h1 {
|
||
font-size: 28px;
|
||
color: var(--gray-900);
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.wizard-header p {
|
||
color: var(--gray-500);
|
||
font-size: 16px;
|
||
}
|
||
|
||
/* Upload Zone */
|
||
.upload-zone {
|
||
border: 2px dashed var(--gray-300);
|
||
border-radius: var(--radius);
|
||
padding: 48px 24px;
|
||
text-align: center;
|
||
background: var(--gray-50);
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.upload-zone:hover, .upload-zone.dragover {
|
||
border-color: var(--primary);
|
||
background: rgba(99, 102, 241, 0.05);
|
||
}
|
||
|
||
.upload-zone.has-files {
|
||
border-style: solid;
|
||
border-color: var(--success);
|
||
background: rgba(34, 197, 94, 0.05);
|
||
}
|
||
|
||
.upload-icon {
|
||
width: 64px;
|
||
height: 64px;
|
||
margin: 0 auto 16px;
|
||
color: var(--gray-400);
|
||
}
|
||
|
||
.upload-zone:hover .upload-icon {
|
||
color: var(--primary);
|
||
}
|
||
|
||
.upload-zone h3 {
|
||
font-size: 18px;
|
||
color: var(--gray-700);
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.upload-zone p {
|
||
color: var(--gray-500);
|
||
font-size: 14px;
|
||
}
|
||
|
||
.upload-zone input[type="file"] {
|
||
display: none;
|
||
}
|
||
|
||
/* File Preview Grid */
|
||
.file-preview-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||
gap: 12px;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.file-preview {
|
||
position: relative;
|
||
aspect-ratio: 1;
|
||
border-radius: var(--radius);
|
||
overflow: hidden;
|
||
background: var(--gray-200);
|
||
}
|
||
|
||
.file-preview img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.file-preview .remove-file {
|
||
position: absolute;
|
||
top: 4px;
|
||
right: 4px;
|
||
width: 24px;
|
||
height: 24px;
|
||
background: rgba(0,0,0,0.6);
|
||
color: #fff;
|
||
border: none;
|
||
border-radius: 50%;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.file-preview .remove-file:hover {
|
||
background: var(--danger);
|
||
}
|
||
|
||
.file-name {
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
background: rgba(0,0,0,0.6);
|
||
color: #fff;
|
||
font-size: 11px;
|
||
padding: 4px 6px;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
/* Conversation Flow */
|
||
.conversation {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20px;
|
||
}
|
||
|
||
.message {
|
||
display: flex;
|
||
gap: 12px;
|
||
animation: fadeIn 0.3s ease;
|
||
}
|
||
|
||
@keyframes fadeIn {
|
||
from { opacity: 0; transform: translateY(10px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
|
||
.message-avatar {
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 50%;
|
||
background: var(--primary);
|
||
color: #fff;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
font-size: 18px;
|
||
}
|
||
|
||
.message-avatar.ai {
|
||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||
}
|
||
|
||
.message-content {
|
||
flex: 1;
|
||
background: #fff;
|
||
border-radius: var(--radius);
|
||
padding: 16px 20px;
|
||
box-shadow: var(--shadow);
|
||
}
|
||
|
||
.message-content p {
|
||
margin-bottom: 12px;
|
||
color: var(--gray-700);
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.message-content p:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
/* Extracted Value Display */
|
||
.extracted-value {
|
||
background: var(--gray-50);
|
||
border: 1px solid var(--gray-200);
|
||
border-radius: var(--radius);
|
||
padding: 12px 16px;
|
||
margin: 12px 0;
|
||
font-weight: 500;
|
||
color: var(--gray-900);
|
||
}
|
||
|
||
.extracted-value.editable {
|
||
cursor: text;
|
||
}
|
||
|
||
.extracted-value input {
|
||
border: none;
|
||
background: transparent;
|
||
font-size: inherit;
|
||
font-weight: inherit;
|
||
color: inherit;
|
||
width: 100%;
|
||
outline: none;
|
||
}
|
||
|
||
/* Extracted List */
|
||
.extracted-list {
|
||
background: var(--gray-50);
|
||
border: 1px solid var(--gray-200);
|
||
border-radius: var(--radius);
|
||
margin: 12px 0;
|
||
max-height: 300px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.extracted-list-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 10px 16px;
|
||
border-bottom: 1px solid var(--gray-200);
|
||
}
|
||
|
||
.extracted-list-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.extracted-list-item input[type="checkbox"] {
|
||
width: 18px;
|
||
height: 18px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.extracted-list-item .item-text {
|
||
flex: 1;
|
||
font-weight: 500;
|
||
color: var(--gray-800);
|
||
}
|
||
|
||
.extracted-list-item .item-text input {
|
||
border: none;
|
||
background: transparent;
|
||
font-size: inherit;
|
||
font-weight: inherit;
|
||
color: inherit;
|
||
width: 100%;
|
||
outline: none;
|
||
}
|
||
|
||
.extracted-list-item .item-count {
|
||
font-size: 12px;
|
||
color: var(--gray-500);
|
||
background: var(--gray-200);
|
||
padding: 2px 8px;
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.extracted-list-item .remove-item {
|
||
color: var(--gray-400);
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
padding: 4px;
|
||
}
|
||
|
||
.extracted-list-item .remove-item:hover {
|
||
color: var(--danger);
|
||
}
|
||
|
||
/* Modifier Template Display */
|
||
.modifier-template {
|
||
background: var(--gray-50);
|
||
border: 1px solid var(--gray-200);
|
||
border-radius: var(--radius);
|
||
margin: 8px 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.modifier-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 12px 16px;
|
||
background: #fff;
|
||
cursor: pointer;
|
||
transition: background 0.2s;
|
||
}
|
||
|
||
.modifier-header:hover {
|
||
background: var(--gray-50);
|
||
}
|
||
|
||
.modifier-header input[type="checkbox"] {
|
||
width: 18px;
|
||
height: 18px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.modifier-info {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.modifier-name-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.modifier-name {
|
||
font-weight: 600;
|
||
color: var(--gray-800);
|
||
font-size: 15px;
|
||
}
|
||
|
||
.modifier-type {
|
||
font-size: 11px;
|
||
color: var(--gray-500);
|
||
background: var(--gray-100);
|
||
padding: 2px 8px;
|
||
border-radius: 10px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.modifier-meta {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
}
|
||
|
||
.source-badge, .applies-to-badge, .options-count {
|
||
font-size: 12px;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
background: var(--gray-100);
|
||
color: var(--gray-600);
|
||
}
|
||
|
||
.source-badge {
|
||
background: rgba(99, 102, 241, 0.1);
|
||
color: var(--primary);
|
||
}
|
||
|
||
.applies-to-badge {
|
||
background: rgba(34, 197, 94, 0.1);
|
||
color: var(--success);
|
||
}
|
||
|
||
.applies-to-badge.uncertain {
|
||
background: rgba(245, 158, 11, 0.1);
|
||
color: #d97706;
|
||
}
|
||
|
||
.expand-icon {
|
||
font-size: 12px;
|
||
color: var(--gray-400);
|
||
transition: transform 0.2s;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.modifier-details {
|
||
border-top: 1px solid var(--gray-200);
|
||
background: var(--gray-50);
|
||
}
|
||
|
||
.modifier-options-list {
|
||
padding: 12px 16px 12px 46px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.modifier-option-detail {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 8px 12px;
|
||
background: #fff;
|
||
border: 1px solid var(--gray-200);
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.option-name {
|
||
font-size: 14px;
|
||
color: var(--gray-700);
|
||
}
|
||
|
||
.option-price {
|
||
font-size: 13px;
|
||
color: var(--success);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.modifier-options {
|
||
padding: 8px 16px 12px 46px;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
|
||
.modifier-option {
|
||
background: #fff;
|
||
border: 1px solid var(--gray-200);
|
||
border-radius: 4px;
|
||
padding: 4px 10px;
|
||
font-size: 13px;
|
||
color: var(--gray-600);
|
||
}
|
||
|
||
.modifier-option .price {
|
||
color: var(--success);
|
||
margin-left: 4px;
|
||
}
|
||
|
||
/* Category Selection */
|
||
.category-option {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 12px;
|
||
background: #fff;
|
||
border: 1px solid var(--gray-200);
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.category-option:hover {
|
||
border-color: var(--primary);
|
||
background: rgba(99, 102, 241, 0.05);
|
||
}
|
||
|
||
.category-option input[type="checkbox"] {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.category-option span {
|
||
font-size: 14px;
|
||
color: var(--gray-700);
|
||
}
|
||
|
||
/* Action Buttons */
|
||
.action-buttons {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.action-buttons .btn {
|
||
flex: 1;
|
||
}
|
||
|
||
.btn-success {
|
||
background: var(--success);
|
||
color: #fff;
|
||
}
|
||
|
||
.btn-success:hover {
|
||
background: #16a34a;
|
||
}
|
||
|
||
.btn-outline {
|
||
background: #fff;
|
||
border: 1px solid var(--gray-300);
|
||
color: var(--gray-700);
|
||
}
|
||
|
||
.btn-outline:hover {
|
||
background: var(--gray-50);
|
||
border-color: var(--gray-400);
|
||
}
|
||
|
||
/* Progress Indicator */
|
||
.progress-steps {
|
||
display: flex;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
margin-bottom: 32px;
|
||
}
|
||
|
||
.progress-step {
|
||
width: 12px;
|
||
height: 12px;
|
||
border-radius: 50%;
|
||
background: var(--gray-200);
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.progress-step.active {
|
||
background: var(--primary);
|
||
transform: scale(1.2);
|
||
}
|
||
|
||
.progress-step.completed {
|
||
background: var(--success);
|
||
}
|
||
|
||
/* Loading State */
|
||
.loading-indicator {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 20px;
|
||
color: var(--gray-600);
|
||
}
|
||
|
||
.loading-spinner {
|
||
width: 24px;
|
||
height: 24px;
|
||
border: 3px solid var(--gray-200);
|
||
border-top-color: var(--primary);
|
||
border-radius: 50%;
|
||
animation: spin 1s linear infinite;
|
||
}
|
||
|
||
@keyframes spin {
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
/* Items Table */
|
||
.items-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
margin: 12px 0;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.items-table th {
|
||
text-align: left;
|
||
padding: 10px 12px;
|
||
background: var(--gray-100);
|
||
font-weight: 600;
|
||
color: var(--gray-600);
|
||
font-size: 12px;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.items-table td {
|
||
padding: 10px 12px;
|
||
border-bottom: 1px solid var(--gray-100);
|
||
vertical-align: top;
|
||
}
|
||
|
||
.items-table tr:hover {
|
||
background: var(--gray-50);
|
||
}
|
||
|
||
.item-modifiers {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 4px;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.item-modifier-tag {
|
||
font-size: 11px;
|
||
background: var(--primary-light);
|
||
color: #fff;
|
||
padding: 2px 6px;
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.item-mod-groups { display: none; margin-top: 6px; }
|
||
.item-mod-groups.visible { display: block; }
|
||
.item-mod-group {
|
||
font-size: 12px;
|
||
margin-bottom: 4px;
|
||
}
|
||
.item-mod-group-header {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
cursor: pointer;
|
||
color: var(--gray-600);
|
||
font-weight: 500;
|
||
}
|
||
.item-mod-group-header:hover { color: var(--gray-800); }
|
||
.mod-expand-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 16px;
|
||
height: 16px;
|
||
border-radius: 50%;
|
||
background: var(--gray-200);
|
||
color: var(--gray-600);
|
||
font-size: 12px;
|
||
line-height: 1;
|
||
flex-shrink: 0;
|
||
transition: transform 0.15s;
|
||
}
|
||
.mod-expand-btn.open { transform: rotate(45deg); }
|
||
.item-mod-options {
|
||
display: none;
|
||
margin: 2px 0 4px 20px;
|
||
padding-left: 8px;
|
||
border-left: 2px solid var(--gray-200);
|
||
}
|
||
.item-mod-options.visible { display: block; }
|
||
.item-mod-option {
|
||
font-size: 11px;
|
||
color: var(--gray-500);
|
||
padding: 1px 0;
|
||
}
|
||
.item-mod-option .opt-price { color: var(--gray-400); margin-left: 4px; }
|
||
|
||
/* Add Item Row */
|
||
.add-row {
|
||
display: flex;
|
||
gap: 8px;
|
||
padding: 12px;
|
||
background: var(--gray-50);
|
||
border-radius: var(--radius);
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.add-row input {
|
||
flex: 1;
|
||
padding: 8px 12px;
|
||
border: 1px solid var(--gray-300);
|
||
border-radius: var(--radius);
|
||
font-size: 14px;
|
||
}
|
||
|
||
/* Summary Card */
|
||
.summary-card {
|
||
background: #fff;
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow);
|
||
overflow: hidden;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.summary-card-header {
|
||
padding: 16px 20px;
|
||
background: var(--gray-50);
|
||
border-bottom: 1px solid var(--gray-200);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.summary-card-header h3 {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: var(--gray-800);
|
||
}
|
||
|
||
.summary-card-body {
|
||
padding: 16px 20px;
|
||
}
|
||
|
||
.summary-stat {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
padding: 8px 0;
|
||
border-bottom: 1px solid var(--gray-100);
|
||
}
|
||
|
||
.summary-stat:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.summary-stat-label {
|
||
color: var(--gray-600);
|
||
}
|
||
|
||
.summary-stat-value {
|
||
font-weight: 600;
|
||
color: var(--gray-900);
|
||
}
|
||
|
||
/* Back Link */
|
||
.back-link {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
color: var(--gray-500);
|
||
text-decoration: none;
|
||
font-size: 14px;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.back-link:hover {
|
||
color: var(--gray-700);
|
||
}
|
||
|
||
/* Hidden */
|
||
.hidden {
|
||
display: none !important;
|
||
}
|
||
|
||
/* Scrollable Items Section */
|
||
.items-section {
|
||
max-height: 400px;
|
||
overflow-y: auto;
|
||
border: 1px solid var(--gray-200);
|
||
border-radius: var(--radius);
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="app">
|
||
<!-- Sidebar -->
|
||
<aside class="sidebar" id="sidebar">
|
||
<div class="sidebar-header">
|
||
<div class="logo">
|
||
<div class="logo-icon">P</div>
|
||
<span class="logo-text">Payfrit</span>
|
||
</div>
|
||
<button class="sidebar-toggle" onclick="toggleSidebar()">
|
||
<svg class="nav-icon" 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" 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#menu" class="nav-item">
|
||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M4 19h16M4 15h16M4 11h16M4 7h16"/>
|
||
</svg>
|
||
<span>Menu</span>
|
||
</a>
|
||
<a href="setup-wizard.html" class="nav-item active">
|
||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||
<path d="M2 17l10 5 10-5"/>
|
||
<path d="M2 12l10 5 10-5"/>
|
||
</svg>
|
||
<span>Setup Wizard</span>
|
||
</a>
|
||
</nav>
|
||
|
||
<div class="sidebar-footer">
|
||
<div class="business-info">
|
||
<div class="business-avatar" id="businessAvatar">?</div>
|
||
<div class="business-details">
|
||
<div class="business-name" id="businessName">Loading...</div>
|
||
<div class="business-status online">Online</div>
|
||
</div>
|
||
</div>
|
||
<a href="signup.html" class="nav-item logout" onclick="logout()">
|
||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||
<polyline points="16,17 21,12 16,7"/>
|
||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||
</svg>
|
||
<span>Logout</span>
|
||
</a>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- Main Content -->
|
||
<main class="main-content">
|
||
<div class="top-bar">
|
||
<div class="page-title">
|
||
<h1>Menu Setup Wizard</h1>
|
||
</div>
|
||
<div class="top-bar-actions">
|
||
<button class="btn btn-secondary" onclick="openPreview()" id="previewBtn" disabled>
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||
<circle cx="12" cy="12" r="3"/>
|
||
</svg>
|
||
Preview Menu
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="page-container">
|
||
<div class="wizard-container">
|
||
<!-- Back Link -->
|
||
<a href="index.html#menu" class="back-link">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||
</svg>
|
||
Back to Menu
|
||
</a>
|
||
|
||
<!-- Progress Steps -->
|
||
<div class="progress-steps">
|
||
<div class="progress-step active" data-step="1"></div>
|
||
<div class="progress-step" data-step="2"></div>
|
||
<div class="progress-step" data-step="3"></div>
|
||
<div class="progress-step" data-step="4"></div>
|
||
<div class="progress-step" data-step="5"></div>
|
||
<div class="progress-step" data-step="6"></div>
|
||
</div>
|
||
|
||
<!-- Wizard Header -->
|
||
<div class="wizard-header">
|
||
<h1>Let's Setup Your Menu</h1>
|
||
<p>Import from an existing source, or start from scratch</p>
|
||
</div>
|
||
|
||
<!-- Upload Section -->
|
||
<div id="uploadSection">
|
||
<!-- Import Method Tabs -->
|
||
<div class="import-tabs" style="display:flex;gap:0;margin-bottom:20px;border-radius:8px;overflow:hidden;border:1px solid var(--gray-300);">
|
||
<button class="import-tab active" id="tabUrl" onclick="switchImportTab('url')" style="flex:1;padding:12px 16px;border:none;background:var(--primary);color:white;font-weight:500;cursor:pointer;transition:all 0.2s;">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:middle;margin-right:6px;">
|
||
<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||
</svg>
|
||
Import from URL
|
||
</button>
|
||
<button class="import-tab" id="tabUpload" onclick="switchImportTab('upload')" style="flex:1;padding:12px 16px;border:none;background:var(--gray-100);color:var(--gray-700);font-weight:500;cursor:pointer;transition:all 0.2s;">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:middle;margin-right:6px;">
|
||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
|
||
</svg>
|
||
Upload Files
|
||
</button>
|
||
<button class="import-tab" id="tabScratch" onclick="switchImportTab('scratch')" style="flex:1;padding:12px 16px;border:none;background:var(--gray-100);color:var(--gray-700);font-weight:500;cursor:pointer;transition:all 0.2s;">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:middle;margin-right:6px;">
|
||
<path d="M12 5v14M5 12h14"/>
|
||
</svg>
|
||
Start from Scratch
|
||
</button>
|
||
</div>
|
||
|
||
<!-- URL Import Panel -->
|
||
<div id="urlImportPanel">
|
||
<div style="background:var(--gray-50);border:2px dashed var(--gray-300);border-radius:12px;padding:32px;text-align:center;">
|
||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--gray-400)" stroke-width="1.5" style="margin-bottom:16px;">
|
||
<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||
</svg>
|
||
<h3 style="margin:0 0 8px;color:var(--gray-700);">Enter Restaurant Website URL</h3>
|
||
<p style="margin:0 0 16px;color:var(--gray-500);font-size:14px;">We'll crawl the site to extract menu items, prices, images, and business info</p>
|
||
<input type="url" id="menuUrlInput" placeholder="https://restaurant-website.com" style="width:100%;max-width:400px;padding:12px 16px;border:1px solid var(--gray-300);border-radius:8px;font-size:16px;margin-bottom:16px;">
|
||
<div>
|
||
<button class="btn btn-primary" onclick="startUrlAnalysis()" style="min-width:160px;">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:6px;">
|
||
<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||
</svg>
|
||
Import Menu
|
||
</button>
|
||
</div>
|
||
<div style="margin:16px auto 0;max-width:440px;text-align:left;font-size:12px;color:var(--gray-400);">
|
||
<p style="margin:0 0 6px;font-weight:600;color:var(--gray-500);">Supported platforms:</p>
|
||
<p style="margin:0 0 2px;"><strong style="color:var(--gray-500);">Toast</strong> — full menu with modifiers & images (URL or saved page)</p>
|
||
<p style="margin:0 0 2px;"><strong style="color:var(--gray-500);">Grubhub</strong> — full menu with modifiers (URL only)</p>
|
||
<p style="margin:0 0 2px;"><strong style="color:var(--gray-500);">DoorDash / order.online</strong> — items, categories, prices (URL or saved page)</p>
|
||
<p style="margin:0 0 2px;"><strong style="color:var(--gray-500);">Uber Eats</strong> — full menu with modifiers (saved page)</p>
|
||
<p style="margin:0;"><strong style="color:var(--gray-500);">Other sites</strong> — upload saved page, images, or PDFs for AI extraction</p>
|
||
</div>
|
||
|
||
<!-- Divider -->
|
||
<div style="display:flex;align-items:center;margin:24px 0;gap:16px;">
|
||
<div style="flex:1;height:1px;background:var(--gray-300);"></div>
|
||
<span style="color:var(--gray-400);font-size:12px;text-transform:uppercase;">or upload saved page</span>
|
||
<div style="flex:1;height:1px;background:var(--gray-300);"></div>
|
||
</div>
|
||
|
||
<!-- Saved Page Upload -->
|
||
<p style="margin:0 0 8px;color:var(--gray-500);font-size:14px;">If the website blocks our crawler, save the menu page from your browser and upload it here</p>
|
||
<p style="margin:0 0 12px;color:#b45309;font-size:13px;font-weight:500;">Please disable ad blockers (uBlock Origin, AdBlock, etc.) before saving the page, or menu data may be missing.</p>
|
||
<input type="file" id="savedPageInput" accept=".html,.htm,.mhtml,.txt,.zip" style="display:none;" onchange="handleSavedPageUpload(event)">
|
||
<button class="btn btn-secondary" onclick="document.getElementById('savedPageInput').click()" style="min-width:180px;">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:6px;">
|
||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>
|
||
</svg>
|
||
Upload Saved Page
|
||
</button>
|
||
<p style="margin:12px 0 0;color:var(--gray-400);font-size:12px;">HTML file or ZIP (Save Page As > Webpage, Complete)</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- File Upload Panel (hidden by default) -->
|
||
<div id="fileUploadPanel" style="display:none;">
|
||
<div class="upload-zone" id="uploadZone">
|
||
<svg class="upload-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||
<polyline points="17 8 12 3 7 8"/>
|
||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||
</svg>
|
||
<h3>Drop your menu images here</h3>
|
||
<p>or click to browse (JPG, PNG, PDF supported)</p>
|
||
<input type="file" id="fileInput" multiple accept="image/*,.pdf">
|
||
</div>
|
||
|
||
<div class="file-preview-grid" id="filePreviewGrid"></div>
|
||
|
||
<div class="action-buttons" id="uploadActions" style="display: none;">
|
||
<button class="btn btn-primary" onclick="startAnalysis()">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<polygon points="5 3 19 12 5 21 5 3"/>
|
||
</svg>
|
||
Analyze Menu
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Start from Scratch Panel (hidden by default) -->
|
||
<div id="scratchPanel" style="display:none;">
|
||
<div style="background:var(--gray-50);border:2px dashed var(--gray-300);border-radius:12px;padding:40px;text-align:center;">
|
||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--gray-400)" stroke-width="1.5" style="margin-bottom:16px;">
|
||
<path d="M12 5v14M5 12h14"/>
|
||
</svg>
|
||
<h3 style="margin:0 0 8px;color:var(--gray-700);">Build Your Menu from Scratch</h3>
|
||
<p style="margin:0 0 24px;color:var(--gray-500);font-size:14px;">Enter your business information and then use the Visual Menu Builder to add categories, items, and modifiers.</p>
|
||
<button class="btn btn-primary" onclick="startFromScratch()" style="min-width:200px;">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:6px;">
|
||
<path d="M12 5v14M5 12h14"/>
|
||
</svg>
|
||
Get Started
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Conversation Section -->
|
||
<div class="conversation" id="conversation"></div>
|
||
|
||
<!-- Final Actions -->
|
||
<div id="finalActions" class="hidden" style="margin-top: 24px;">
|
||
<div class="summary-card">
|
||
<div class="summary-card-header">
|
||
<h3>Menu Summary</h3>
|
||
</div>
|
||
<div class="summary-card-body">
|
||
<div id="singleMenuSection">
|
||
<div class="summary-stat" style="flex-direction: column; align-items: stretch; gap: 8px;">
|
||
<span class="summary-stat-label">Menu Name</span>
|
||
<input type="text" id="menuNameInput" value="Main Menu" placeholder="e.g., Main Menu, Lunch, Dinner"
|
||
style="padding: 8px 12px; border: 1px solid var(--gray-300); border-radius: 6px; font-size: 14px;">
|
||
</div>
|
||
<div class="summary-stat" style="flex-direction: column; align-items: stretch; gap: 8px;">
|
||
<span class="summary-stat-label">Menu Hours</span>
|
||
<div style="display: flex; gap: 12px; align-items: center;">
|
||
<input type="time" id="menuStartTime" style="padding: 8px 12px; border: 1px solid var(--gray-300); border-radius: 6px; font-size: 14px; flex: 1;">
|
||
<span style="color: var(--gray-500);">to</span>
|
||
<input type="time" id="menuEndTime" style="padding: 8px 12px; border: 1px solid var(--gray-300); border-radius: 6px; font-size: 14px; flex: 1;">
|
||
</div>
|
||
<small style="color: var(--gray-500);">Leave empty for all-day availability. You can create additional menus later in the Menu Builder.</small>
|
||
</div>
|
||
</div>
|
||
<div id="multiMenuSection" style="display: none;">
|
||
<div class="summary-stat" style="flex-direction: column; align-items: stretch; gap: 8px;">
|
||
<span class="summary-stat-label">Detected Menus</span>
|
||
<div id="multiMenuList"></div>
|
||
<small style="color: var(--gray-500);">Each menu will be created separately with its own items and categories.</small>
|
||
</div>
|
||
</div>
|
||
<div class="summary-stat">
|
||
<span class="summary-stat-label">Categories</span>
|
||
<span class="summary-stat-value" id="summaryCategories">0</span>
|
||
</div>
|
||
<div class="summary-stat" id="summarySubcategoriesRow" style="display:none;">
|
||
<span class="summary-stat-label" style="padding-left:12px;color:var(--gray-500);">Subcategories</span>
|
||
<span class="summary-stat-value" id="summarySubcategories">0</span>
|
||
</div>
|
||
<div class="summary-stat">
|
||
<span class="summary-stat-label">Modifier Templates</span>
|
||
<span class="summary-stat-value" id="summaryModifiers">0</span>
|
||
</div>
|
||
<div class="summary-stat">
|
||
<span class="summary-stat-label">Menu Items</span>
|
||
<span class="summary-stat-value" id="summaryItems">0</span>
|
||
</div>
|
||
<div class="summary-stat" id="summaryImagesRow">
|
||
<span class="summary-stat-label">Images</span>
|
||
<span class="summary-stat-value" id="summaryImages" style="font-size: 13px; text-align: right;"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Community Meal Participation -->
|
||
<div class="summary-card" id="communityMealCard" style="margin-top: 16px;">
|
||
<div class="summary-card-header">
|
||
<h3>Community Meal Participation</h3>
|
||
<p style="margin: 4px 0 0; color: var(--gray-500); font-size: 14px;">Choose how this location participates.</p>
|
||
</div>
|
||
<div class="summary-card-body" style="flex-direction: column; gap: 12px;">
|
||
<label style="display: flex; align-items: flex-start; gap: 12px; cursor: pointer; padding: 12px; border: 2px solid var(--primary); border-radius: 8px; background: rgba(99, 102, 241, 0.05);" id="communityMealOption1">
|
||
<input type="radio" name="communityMealType" value="1" checked
|
||
onchange="updateCommunityMealSelection()"
|
||
style="margin-top: 3px; width: 18px; height: 18px; accent-color: var(--primary); flex-shrink: 0;">
|
||
<div>
|
||
<strong style="display: block; color: var(--gray-900);">Provide Community Meals</strong>
|
||
<span style="color: var(--gray-500); font-size: 13px;">Offer one Community Meal per service window and receive a reduced Payfrit fee.</span>
|
||
</div>
|
||
</label>
|
||
<label style="display: flex; align-items: flex-start; gap: 12px; cursor: pointer; padding: 12px; border: 2px solid var(--gray-200); border-radius: 8px;" id="communityMealOption2">
|
||
<input type="radio" name="communityMealType" value="2"
|
||
onchange="updateCommunityMealSelection()"
|
||
style="margin-top: 3px; width: 18px; height: 18px; accent-color: var(--primary); flex-shrink: 0;">
|
||
<div>
|
||
<strong style="display: block; color: var(--gray-900);">Support a local food bank instead</strong>
|
||
<span style="color: var(--gray-500); font-size: 13px;">A combined 1.0% contribution (business + guest) is donated locally.</span>
|
||
</div>
|
||
</label>
|
||
<a href="/uploads/docs/Payfrit_Community_Meal_Participation_One_Pager.pdf" target="_blank"
|
||
style="display: inline-flex; align-items: center; gap: 6px; color: var(--primary); font-size: 13px; text-decoration: none; margin-top: 4px;">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||
<polyline points="14 2 14 8 20 8"/>
|
||
</svg>
|
||
Learn more about Community Meal Participation
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="action-buttons">
|
||
<button class="btn btn-outline" onclick="startOver()">Start Over</button>
|
||
<button class="btn btn-success" onclick="saveMenu()">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
|
||
<polyline points="17 21 17 13 7 13 7 21"/>
|
||
<polyline points="7 3 7 8 15 8"/>
|
||
</svg>
|
||
Save Menu
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
|
||
<!-- Toast Container -->
|
||
<div class="toast-container" id="toastContainer"></div>
|
||
|
||
<!-- Image Preview Modal -->
|
||
<div id="imagePreviewModal" class="image-modal" onclick="closeImagePreview(event)">
|
||
<div class="image-modal-content">
|
||
<span class="image-modal-close" onclick="closeImagePreview()">×</span>
|
||
<img id="imagePreviewImg" src="" alt="Menu Image">
|
||
<div class="image-modal-caption" id="imagePreviewCaption"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<style>
|
||
.image-modal {
|
||
display: none;
|
||
position: fixed;
|
||
z-index: 9999;
|
||
left: 0;
|
||
top: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background-color: rgba(0,0,0,0.9);
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
.image-modal.active {
|
||
display: flex;
|
||
}
|
||
.image-modal-content {
|
||
position: relative;
|
||
max-width: 90%;
|
||
max-height: 90%;
|
||
}
|
||
.image-modal-content img {
|
||
max-width: 100%;
|
||
max-height: 85vh;
|
||
object-fit: contain;
|
||
border-radius: 8px;
|
||
}
|
||
.image-modal-close {
|
||
position: absolute;
|
||
top: -40px;
|
||
right: 0;
|
||
color: white;
|
||
font-size: 32px;
|
||
cursor: pointer;
|
||
padding: 5px 10px;
|
||
}
|
||
.image-modal-close:hover {
|
||
color: #ccc;
|
||
}
|
||
.image-modal-caption {
|
||
color: white;
|
||
text-align: center;
|
||
padding: 10px;
|
||
font-size: 14px;
|
||
}
|
||
.source-badge.clickable {
|
||
cursor: pointer;
|
||
text-decoration: underline;
|
||
color: var(--primary);
|
||
}
|
||
.source-badge.clickable:hover {
|
||
color: var(--primary-hover);
|
||
}
|
||
</style>
|
||
|
||
<script>
|
||
// US States (from tt_States)
|
||
const US_STATES = [
|
||
{abbr:'AL',name:'Alabama'},{abbr:'AK',name:'Alaska'},{abbr:'AZ',name:'Arizona'},{abbr:'AR',name:'Arkansas'},
|
||
{abbr:'CA',name:'California'},{abbr:'CO',name:'Colorado'},{abbr:'CT',name:'Connecticut'},{abbr:'DE',name:'Delaware'},
|
||
{abbr:'DC',name:'Dist. of Columbia'},{abbr:'FL',name:'Florida'},{abbr:'GA',name:'Georgia'},{abbr:'HI',name:'Hawaii'},
|
||
{abbr:'ID',name:'Idaho'},{abbr:'IL',name:'Illinois'},{abbr:'IN',name:'Indiana'},{abbr:'IA',name:'Iowa'},
|
||
{abbr:'KS',name:'Kansas'},{abbr:'KY',name:'Kentucky'},{abbr:'LA',name:'Louisiana'},{abbr:'ME',name:'Maine'},
|
||
{abbr:'MD',name:'Maryland'},{abbr:'MA',name:'Massachusetts'},{abbr:'MI',name:'Michigan'},{abbr:'MN',name:'Minnesota'},
|
||
{abbr:'MS',name:'Mississippi'},{abbr:'MO',name:'Missouri'},{abbr:'MT',name:'Montana'},{abbr:'NE',name:'Nebraska'},
|
||
{abbr:'NV',name:'Nevada'},{abbr:'NH',name:'New Hampshire'},{abbr:'NJ',name:'New Jersey'},{abbr:'NM',name:'New Mexico'},
|
||
{abbr:'NY',name:'New York'},{abbr:'NC',name:'North Carolina'},{abbr:'ND',name:'North Dakota'},{abbr:'OH',name:'Ohio'},
|
||
{abbr:'OK',name:'Oklahoma'},{abbr:'OR',name:'Oregon'},{abbr:'PA',name:'Pennsylvania'},{abbr:'RI',name:'Rhode Island'},
|
||
{abbr:'SC',name:'South Carolina'},{abbr:'SD',name:'South Dakota'},{abbr:'TN',name:'Tennessee'},{abbr:'TX',name:'Texas'},
|
||
{abbr:'UT',name:'Utah'},{abbr:'VT',name:'Vermont'},{abbr:'VA',name:'Virginia'},{abbr:'WA',name:'Washington'},
|
||
{abbr:'WV',name:'West Virginia'},{abbr:'WI',name:'Wisconsin'},{abbr:'WY',name:'Wyoming'}
|
||
];
|
||
|
||
function buildStateOptions(selectedAbbr) {
|
||
const upper = (selectedAbbr || '').toUpperCase();
|
||
return '<option value="">Select...</option>' +
|
||
US_STATES.map(s => `<option value="${s.abbr}"${s.abbr === upper ? ' selected' : ''}>${s.abbr} - ${s.name}</option>`).join('');
|
||
}
|
||
|
||
// Configuration
|
||
const config = {
|
||
businessId: null,
|
||
apiBaseUrl: '',
|
||
uploadedFiles: [],
|
||
extractedData: {
|
||
business: {},
|
||
categories: [],
|
||
modifiers: [],
|
||
items: []
|
||
},
|
||
currentStep: 1,
|
||
imageObjectUrls: [], // Store object URLs for uploaded images
|
||
imageMappings: [], // For matching uploaded images to items (from HTML import)
|
||
itemImages: {}, // item ID -> File object for matched images
|
||
tempFolder: null // Temp folder ID from ZIP upload (for cleanup after save)
|
||
};
|
||
|
||
// Image preview functions
|
||
function showImagePreview(imageIndex) {
|
||
// imageIndex is 1-based from the API
|
||
const fileIndex = imageIndex - 1;
|
||
if (fileIndex < 0 || fileIndex >= config.uploadedFiles.length) {
|
||
console.error('Invalid image index:', imageIndex);
|
||
return;
|
||
}
|
||
|
||
const file = config.uploadedFiles[fileIndex];
|
||
|
||
// Create object URL if not cached
|
||
if (!config.imageObjectUrls[fileIndex]) {
|
||
config.imageObjectUrls[fileIndex] = URL.createObjectURL(file);
|
||
}
|
||
|
||
const modal = document.getElementById('imagePreviewModal');
|
||
const img = document.getElementById('imagePreviewImg');
|
||
const caption = document.getElementById('imagePreviewCaption');
|
||
|
||
img.src = config.imageObjectUrls[fileIndex];
|
||
caption.textContent = `Image ${imageIndex}: ${file.name}`;
|
||
modal.classList.add('active');
|
||
|
||
// Prevent body scroll
|
||
document.body.style.overflow = 'hidden';
|
||
}
|
||
|
||
function closeImagePreview(event) {
|
||
// If event is passed, only close if clicking the background (not the image)
|
||
if (event && event.target.id !== 'imagePreviewModal') {
|
||
return;
|
||
}
|
||
const modal = document.getElementById('imagePreviewModal');
|
||
modal.classList.remove('active');
|
||
document.body.style.overflow = '';
|
||
}
|
||
|
||
// Close on escape key
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') {
|
||
closeImagePreview();
|
||
}
|
||
});
|
||
|
||
// Initialize
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
initializeConfig();
|
||
setupUploadZone();
|
||
loadBusinessInfo();
|
||
|
||
// In add-menu mode, update the header to show the menu name
|
||
if (config.menuId && config.menuName) {
|
||
document.querySelector('.wizard-header h1').textContent = `Setup: ${config.menuName}`;
|
||
document.querySelector('.wizard-header p').textContent = 'Upload menu images or PDFs to extract categories, items, and modifiers for this menu.';
|
||
}
|
||
});
|
||
|
||
function initializeConfig() {
|
||
// Check for add-menu context from sessionStorage
|
||
const wizardMenu = sessionStorage.getItem('payfrit_wizard_menu');
|
||
if (wizardMenu) {
|
||
try {
|
||
const ctx = JSON.parse(wizardMenu);
|
||
config.businessId = ctx.businessId || null;
|
||
config.menuId = ctx.menuId || null;
|
||
config.menuName = ctx.menuName || null;
|
||
sessionStorage.removeItem('payfrit_wizard_menu');
|
||
} catch (e) {
|
||
sessionStorage.removeItem('payfrit_wizard_menu');
|
||
}
|
||
}
|
||
|
||
// Determine API base URL
|
||
const basePath = window.location.pathname.includes('/biz.payfrit.com/')
|
||
? '/biz.payfrit.com'
|
||
: '';
|
||
config.apiBaseUrl = basePath + '/api';
|
||
|
||
// Check if user is logged in
|
||
const userId = localStorage.getItem('payfrit_portal_userid');
|
||
if (!userId) {
|
||
window.location.href = 'signup.html';
|
||
return;
|
||
}
|
||
config.userId = userId;
|
||
|
||
console.log('Wizard initialized. BusinessId:', config.businessId, 'MenuId:', config.menuId, 'UserId:', config.userId);
|
||
}
|
||
|
||
function setupUploadZone() {
|
||
const uploadZone = document.getElementById('uploadZone');
|
||
const fileInput = document.getElementById('fileInput');
|
||
|
||
uploadZone.addEventListener('click', () => fileInput.click());
|
||
|
||
uploadZone.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
uploadZone.classList.add('dragover');
|
||
});
|
||
|
||
uploadZone.addEventListener('dragleave', () => {
|
||
uploadZone.classList.remove('dragover');
|
||
});
|
||
|
||
uploadZone.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
uploadZone.classList.remove('dragover');
|
||
handleFiles(e.dataTransfer.files);
|
||
});
|
||
|
||
fileInput.addEventListener('change', (e) => {
|
||
handleFiles(e.target.files);
|
||
});
|
||
}
|
||
|
||
function handleFiles(files) {
|
||
const uploadZone = document.getElementById('uploadZone');
|
||
const previewGrid = document.getElementById('filePreviewGrid');
|
||
const uploadActions = document.getElementById('uploadActions');
|
||
|
||
Array.from(files).forEach(file => {
|
||
if (!file.type.match('image.*') && file.type !== 'application/pdf') {
|
||
showToast('Only images and PDFs are supported', 'error');
|
||
return;
|
||
}
|
||
|
||
config.uploadedFiles.push(file);
|
||
|
||
// Create preview
|
||
const preview = document.createElement('div');
|
||
preview.className = 'file-preview';
|
||
preview.dataset.index = config.uploadedFiles.length - 1;
|
||
|
||
if (file.type.match('image.*')) {
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
preview.innerHTML = `
|
||
<img src="${e.target.result}" alt="${file.name}">
|
||
<button class="remove-file" onclick="removeFile(${config.uploadedFiles.length - 1})">×</button>
|
||
<div class="file-name">${file.name}</div>
|
||
`;
|
||
};
|
||
reader.readAsDataURL(file);
|
||
} else {
|
||
preview.innerHTML = `
|
||
<div style="display:flex;align-items:center;justify-content:center;height:100%;background:var(--gray-100);">
|
||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||
<polyline points="14,2 14,8 20,8"/>
|
||
</svg>
|
||
</div>
|
||
<button class="remove-file" onclick="removeFile(${config.uploadedFiles.length - 1})">×</button>
|
||
<div class="file-name">${file.name}</div>
|
||
`;
|
||
}
|
||
|
||
previewGrid.appendChild(preview);
|
||
});
|
||
|
||
if (config.uploadedFiles.length > 0) {
|
||
uploadZone.classList.add('has-files');
|
||
uploadActions.style.display = 'flex';
|
||
}
|
||
}
|
||
|
||
function removeFile(index) {
|
||
config.uploadedFiles.splice(index, 1);
|
||
rebuildPreviewGrid();
|
||
}
|
||
|
||
function rebuildPreviewGrid() {
|
||
const previewGrid = document.getElementById('filePreviewGrid');
|
||
const uploadZone = document.getElementById('uploadZone');
|
||
const uploadActions = document.getElementById('uploadActions');
|
||
|
||
previewGrid.innerHTML = '';
|
||
|
||
config.uploadedFiles.forEach((file, index) => {
|
||
const preview = document.createElement('div');
|
||
preview.className = 'file-preview';
|
||
preview.dataset.index = index;
|
||
|
||
if (file.type.match('image.*')) {
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
preview.innerHTML = `
|
||
<img src="${e.target.result}" alt="${file.name}">
|
||
<button class="remove-file" onclick="removeFile(${index})">×</button>
|
||
<div class="file-name">${file.name}</div>
|
||
`;
|
||
};
|
||
reader.readAsDataURL(file);
|
||
} else {
|
||
preview.innerHTML = `
|
||
<div style="display:flex;align-items:center;justify-content:center;height:100%;background:var(--gray-100);">
|
||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||
<polyline points="14,2 14,8 20,8"/>
|
||
</svg>
|
||
</div>
|
||
<button class="remove-file" onclick="removeFile(${index})">×</button>
|
||
<div class="file-name">${file.name}</div>
|
||
`;
|
||
}
|
||
previewGrid.appendChild(preview);
|
||
});
|
||
|
||
if (config.uploadedFiles.length === 0) {
|
||
uploadZone.classList.remove('has-files');
|
||
uploadActions.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// Switch between URL and file upload tabs
|
||
function switchImportTab(tab) {
|
||
const tabs = {
|
||
url: document.getElementById('tabUrl'),
|
||
upload: document.getElementById('tabUpload'),
|
||
scratch: document.getElementById('tabScratch')
|
||
};
|
||
const panels = {
|
||
url: document.getElementById('urlImportPanel'),
|
||
upload: document.getElementById('fileUploadPanel'),
|
||
scratch: document.getElementById('scratchPanel')
|
||
};
|
||
|
||
Object.keys(tabs).forEach(key => {
|
||
if (key === tab) {
|
||
tabs[key].style.background = 'var(--primary)';
|
||
tabs[key].style.color = 'white';
|
||
panels[key].style.display = 'block';
|
||
} else {
|
||
tabs[key].style.background = 'var(--gray-100)';
|
||
tabs[key].style.color = 'var(--gray-700)';
|
||
panels[key].style.display = 'none';
|
||
}
|
||
});
|
||
}
|
||
|
||
// Start from scratch — skip import, go straight to business info
|
||
function startFromScratch() {
|
||
config.scratchMode = true;
|
||
config.extractedData = {
|
||
business: {},
|
||
categories: [],
|
||
modifiers: [],
|
||
items: []
|
||
};
|
||
|
||
// Hide upload section, show conversation
|
||
document.getElementById('uploadSection').style.display = 'none';
|
||
|
||
showBusinessInfoStep();
|
||
}
|
||
|
||
// Save business only (scratch mode) then redirect to Menu Builder
|
||
async function saveScratchBusiness() {
|
||
// Add a default menu name and community meal type
|
||
config.extractedData.menuName = 'Main Menu';
|
||
config.extractedData.communityMealType = 1;
|
||
|
||
addMessage('ai', `
|
||
<p>Saving your business information...</p>
|
||
<div class="loading-spinner" style="width:24px;height:24px;border-width:3px;margin:16px auto;"></div>
|
||
`);
|
||
|
||
try {
|
||
const response = await fetch(`${config.apiBaseUrl}/setup/saveWizard.php`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
businessId: config.businessId || 0,
|
||
menuId: 0,
|
||
userId: config.userId,
|
||
data: config.extractedData,
|
||
tempFolder: null
|
||
})
|
||
});
|
||
|
||
const responseText = await response.text();
|
||
let result;
|
||
try {
|
||
result = JSON.parse(responseText);
|
||
} catch (e) {
|
||
throw new Error('Invalid response from server');
|
||
}
|
||
|
||
if (!result.OK) {
|
||
const errorMsg = result.errors && result.errors.length > 0
|
||
? result.errors.join('; ')
|
||
: (result.MESSAGE || 'Save failed');
|
||
throw new Error(errorMsg);
|
||
}
|
||
|
||
const summary = result.summary || result.SUMMARY || {};
|
||
const finalBusinessId = summary.businessId || summary.BUSINESSID || summary.businessid || config.businessId;
|
||
|
||
localStorage.setItem('payfrit_portal_business', finalBusinessId);
|
||
|
||
// Upload header image if one was selected
|
||
if (config.headerImageFile && finalBusinessId) {
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('BusinessID', finalBusinessId);
|
||
formData.append('header', config.headerImageFile);
|
||
|
||
const headerResp = await fetch(`${config.apiBaseUrl}/menu/uploadHeader.php`, {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
const headerResult = await headerResp.json();
|
||
if (!headerResult.OK) {
|
||
console.error('Header upload failed:', headerResult.MESSAGE);
|
||
}
|
||
} catch (headerErr) {
|
||
console.error('Header upload error:', headerErr);
|
||
}
|
||
}
|
||
|
||
showToast('Business created! Redirecting to Menu Builder...', 'success');
|
||
|
||
setTimeout(() => {
|
||
window.location.href = '/portal/menu-builder.html';
|
||
}, 1500);
|
||
|
||
} catch (err) {
|
||
console.error('Save error:', err);
|
||
showToast('Error: ' + err.message, 'error');
|
||
// Remove the loading message and show retry
|
||
addMessage('ai', `
|
||
<p style="color:var(--error);">Something went wrong: ${err.message}</p>
|
||
<div class="action-buttons">
|
||
<button class="btn btn-primary" onclick="saveScratchBusiness()">Try Again</button>
|
||
</div>
|
||
`);
|
||
}
|
||
}
|
||
|
||
// URL-based menu import
|
||
async function startUrlAnalysis() {
|
||
const urlInput = document.getElementById('menuUrlInput');
|
||
const url = urlInput.value.trim();
|
||
|
||
if (!url) {
|
||
showToast('Please enter a website URL', 'error');
|
||
return;
|
||
}
|
||
|
||
// Uber Eats URLs can't be scraped server-side — prompt user to save page
|
||
if (url.includes('ubereats.com/store') || url.includes('ubereats.com/store/')) {
|
||
document.getElementById('uploadSection').style.display = 'none';
|
||
addMessage('ai', `
|
||
<p><strong>Uber Eats pages can't be crawled directly</strong> (bot protection), but we can import from a saved page.</p>
|
||
<ol style="margin:12px 0;padding-left:20px;line-height:1.8;">
|
||
<li>Open the Uber Eats link in Chrome: <a href="${url}" target="_blank" style="word-break:break-all;">${url}</a></li>
|
||
<li>Wait for the menu to fully load (scroll down to load all items)</li>
|
||
<li>Press <strong>Ctrl+S</strong> to save the page as HTML</li>
|
||
<li>Upload the saved <code>.html</code> file below</li>
|
||
</ol>
|
||
<div style="margin-top:16px;">
|
||
<input type="file" id="uberEatsSavedPage" accept=".html,.htm" style="display:none;" onchange="handleSavedPageUpload(event)">
|
||
<button class="btn btn-primary" onclick="document.getElementById('uberEatsSavedPage').click()">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:6px;">
|
||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
|
||
</svg>
|
||
Upload Saved Page
|
||
</button>
|
||
<button class="btn btn-secondary" onclick="retryUrlAnalysis()" style="margin-left:8px;">Back</button>
|
||
</div>
|
||
`);
|
||
return;
|
||
}
|
||
|
||
// Hide upload section, show conversation
|
||
document.getElementById('uploadSection').style.display = 'none';
|
||
|
||
addMessage('ai', `
|
||
<div style="display:flex;align-items:center;gap:12px;">
|
||
<div class="loading-spinner"></div>
|
||
<span>Scanning website for menu pages...</span>
|
||
</div>
|
||
<p style="font-size:13px;color:var(--gray-500);margin-top:8px;">This takes about 30 seconds while I crawl the site and look for menu sub-pages.</p>
|
||
`);
|
||
|
||
try {
|
||
// Phase 1: Discovery — quick Playwright crawl to find menu pages
|
||
const discoverResp = await fetch(`${config.apiBaseUrl}/setup/analyzeMenuUrl.php`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ mode: 'discover', url })
|
||
});
|
||
const discoverResult = await discoverResp.json();
|
||
|
||
if (!discoverResult.OK) {
|
||
throw new Error(discoverResult.MESSAGE || 'Failed to scan website');
|
||
}
|
||
|
||
console.log('=== DISCOVERY RESULT ===', discoverResult);
|
||
document.getElementById('conversation').innerHTML = '';
|
||
|
||
const menuPages = discoverResult.menuPages || [];
|
||
const siteName = discoverResult.siteName || '';
|
||
|
||
// Store business info and platform URLs from discovery
|
||
config.discoveredBusinessInfo = discoverResult.businessInfo || {};
|
||
config.platformPages = discoverResult.platformPages || [];
|
||
|
||
if (menuPages.length > 1) {
|
||
// Multiple menus found — show confirmation step
|
||
const pageListHtml = menuPages.map((p, i) => `
|
||
<label style="display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--gray-50);border-radius:8px;margin-bottom:6px;cursor:pointer;">
|
||
<input type="checkbox" checked data-menu-url="${p.url}" data-menu-name="${p.name}" style="width:18px;height:18px;">
|
||
<span style="font-weight:500;">${p.name}</span>
|
||
<span style="font-size:12px;color:var(--gray-400);margin-left:auto;">${p.url}</span>
|
||
</label>
|
||
`).join('');
|
||
|
||
addMessage('ai', `
|
||
<p>I found <strong>${menuPages.length} menu pages</strong>${siteName ? ' for <strong>' + siteName + '</strong>' : ''}:</p>
|
||
<div id="discoveredMenuPages" style="margin:12px 0;">
|
||
${pageListHtml}
|
||
</div>
|
||
<p style="font-size:13px;color:var(--gray-500);">Uncheck any that aren't actual menus. Each checked page will be analyzed separately.</p>
|
||
<div style="margin-top:8px;">
|
||
<label style="display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--gray-50);border-radius:8px;margin-bottom:6px;">
|
||
<span style="font-size:13px;">Additional menu URL (optional):</span>
|
||
<input type="text" id="extraMenuUrl" placeholder="https://..." style="flex:1;padding:4px 8px;border:1px solid var(--gray-200);border-radius:4px;font-size:13px;">
|
||
</label>
|
||
</div>
|
||
<div class="action-buttons" style="margin-top:12px;">
|
||
<button class="btn btn-primary" onclick="extractConfirmedMenuPages('${url}')">Extract Menus</button>
|
||
<button class="btn btn-secondary" onclick="skipDiscoveryExtractAll('${url}')">Extract as Single Page</button>
|
||
</div>
|
||
`);
|
||
} else {
|
||
// No sub-pages or just one — fall through to single-page extract
|
||
await extractSingleUrl(url);
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('URL analysis error:', error);
|
||
document.getElementById('conversation').innerHTML = '';
|
||
addMessage('ai', `
|
||
<p>Sorry, I encountered an error importing from that URL:</p>
|
||
<p style="color: var(--danger);">${error.message}</p>
|
||
<div class="action-buttons">
|
||
<button class="btn btn-primary" onclick="retryUrlAnalysis()">Try Again</button>
|
||
<button class="btn btn-secondary" onclick="switchToFileUpload()">Upload Files Instead</button>
|
||
</div>
|
||
`);
|
||
}
|
||
}
|
||
|
||
// Phase 2: Extract each confirmed menu page individually
|
||
async function extractConfirmedMenuPages(mainUrl) {
|
||
const checkboxes = document.querySelectorAll('#discoveredMenuPages input[type="checkbox"]:checked');
|
||
const pages = Array.from(checkboxes).map(cb => ({
|
||
url: cb.dataset.menuUrl,
|
||
name: cb.dataset.menuName
|
||
}));
|
||
|
||
// Add extra URL if provided
|
||
const extraUrl = (document.getElementById('extraMenuUrl')?.value || '').trim();
|
||
if (extraUrl) {
|
||
const slug = extraUrl.replace(/.*\//, '').replace(/[-_]/g, ' ');
|
||
pages.push({ url: extraUrl, name: slug ? slug.charAt(0).toUpperCase() + slug.slice(1) : 'Extra' });
|
||
}
|
||
|
||
if (pages.length === 0) {
|
||
alert('Please select at least one menu page.');
|
||
return;
|
||
}
|
||
|
||
document.getElementById('conversation').innerHTML = '';
|
||
|
||
// Progress container: completed results stay, spinner updates in place
|
||
const progressDiv = document.createElement('div');
|
||
progressDiv.id = 'extractionProgress';
|
||
const spinnerDiv = document.createElement('div');
|
||
spinnerDiv.id = 'extractionSpinner';
|
||
document.getElementById('conversation').appendChild(progressDiv);
|
||
document.getElementById('conversation').appendChild(spinnerDiv);
|
||
|
||
// Combined results — start with business info from discovery phase
|
||
const allItems = [];
|
||
const allCategories = [];
|
||
const allMenus = [];
|
||
let businessInfo = { ...(config.discoveredBusinessInfo || {}) };
|
||
let totalProcessed = 0;
|
||
|
||
for (const page of pages) {
|
||
spinnerDiv.innerHTML = `
|
||
<div class="message ai-message" style="margin-top:8px;">
|
||
<div style="display:flex;align-items:center;gap:12px;">
|
||
<div class="loading-spinner"></div>
|
||
<span>Extracting <strong>${page.name}</strong> menu... (${totalProcessed + 1} of ${pages.length})</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
try {
|
||
const resp = await fetch(`${config.apiBaseUrl}/setup/analyzeMenuUrl.php`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ mode: 'extract_page', url: page.url, menuName: page.name })
|
||
});
|
||
const result = await resp.json();
|
||
|
||
if (result.OK && result.DATA) {
|
||
const data = result.DATA;
|
||
// Merge business info — fill gaps from sub-page extractions
|
||
if (data.business) {
|
||
for (const [k, v] of Object.entries(data.business)) {
|
||
if (v && !businessInfo[k]) businessInfo[k] = v;
|
||
}
|
||
}
|
||
// Add categories with menu tag
|
||
(data.categories || []).forEach(cat => {
|
||
const catName = typeof cat === 'string' ? cat : cat.name;
|
||
if (catName && !allCategories.find(c => c.name === catName && c.menuName === page.name)) {
|
||
allCategories.push({ name: catName, menuName: page.name });
|
||
}
|
||
});
|
||
// Add items (already tagged with menu name by backend)
|
||
(data.items || []).forEach(item => allItems.push(item));
|
||
// Store menu with its schedule
|
||
const menuEntry = { name: page.name };
|
||
if (result.menuSchedule) menuEntry.schedule = result.menuSchedule;
|
||
allMenus.push(menuEntry);
|
||
|
||
totalProcessed++;
|
||
const scheduleNote = result.menuSchedule ? ` (${result.menuSchedule})` : '';
|
||
const line = document.createElement('div');
|
||
line.className = 'message ai-message';
|
||
line.innerHTML = `<p style="margin:4px 0;">✓ Extracted <strong>${page.name}</strong>${scheduleNote}: ${data.items?.length || 0} items in ${data.categories?.length || 0} categories</p>`;
|
||
progressDiv.appendChild(line);
|
||
} else {
|
||
const line = document.createElement('div');
|
||
line.className = 'message ai-message';
|
||
line.innerHTML = `<p style="margin:4px 0;color:var(--warning);">✗ Could not extract items from ${page.name} page.</p>`;
|
||
progressDiv.appendChild(line);
|
||
totalProcessed++;
|
||
}
|
||
} catch (err) {
|
||
console.error(`Error extracting ${page.name}:`, err);
|
||
const line = document.createElement('div');
|
||
line.className = 'message ai-message';
|
||
line.innerHTML = `<p style="margin:4px 0;color:var(--danger);">✗ Error extracting ${page.name}: ${err.message}</p>`;
|
||
progressDiv.appendChild(line);
|
||
totalProcessed++;
|
||
}
|
||
}
|
||
spinnerDiv.remove();
|
||
|
||
// Compute max business hours from all menu schedules
|
||
if (allMenus.some(m => m.schedule) && !businessInfo.hours) {
|
||
businessInfo.hours = allMenus
|
||
.filter(m => m.schedule)
|
||
.map(m => `${m.name}: ${m.schedule}`)
|
||
.join('; ');
|
||
}
|
||
|
||
// Fetch images from ordering platform (stealth Playwright)
|
||
const platformUrl = (config.platformPages || [])[0] || '';
|
||
let platformImageMap = {};
|
||
if (platformUrl && allItems.length > 0) {
|
||
document.getElementById('conversation').innerHTML = '';
|
||
addMessage('ai', `
|
||
<div style="display:flex;align-items:center;gap:12px;">
|
||
<div class="loading-spinner"></div>
|
||
<span>Grabbing food photos from ordering platform...</span>
|
||
</div>
|
||
`);
|
||
|
||
try {
|
||
const imgResp = await fetch(`${config.apiBaseUrl}/setup/analyzeMenuUrl.php`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ mode: 'platform_images', url: platformUrl, items: allItems.map(i => i.name) })
|
||
});
|
||
const imgResult = await imgResp.json();
|
||
if (imgResult.OK && imgResult.imageMap) {
|
||
platformImageMap = imgResult.imageMap;
|
||
const matchCount = Object.keys(platformImageMap).length;
|
||
// Match images to items by name
|
||
let matched = 0;
|
||
allItems.forEach(item => {
|
||
if (platformImageMap[item.name]) {
|
||
item.imageUrl = platformImageMap[item.name];
|
||
matched++;
|
||
}
|
||
});
|
||
console.log(`Platform images: ${matchCount} found, ${matched} matched to items`);
|
||
}
|
||
} catch (err) {
|
||
console.error('Platform image fetch error:', err);
|
||
}
|
||
}
|
||
|
||
// Build final combined data
|
||
document.getElementById('conversation').innerHTML = '';
|
||
config.extractedData = {
|
||
business: businessInfo,
|
||
categories: allCategories,
|
||
items: allItems,
|
||
modifiers: [],
|
||
menus: allMenus.length > 1 ? allMenus : [],
|
||
imageMappings: [],
|
||
imageUrls: [],
|
||
headerCandidateIndices: [],
|
||
};
|
||
config.sourceUrl = mainUrl;
|
||
config.imageMappings = [];
|
||
|
||
const totalItems = allItems.length;
|
||
const totalCats = allCategories.length;
|
||
const imgCount = allItems.filter(i => i.imageUrl).length;
|
||
const schedSummary = allMenus.filter(m => m.schedule).map(m => `${m.name}: ${m.schedule}`).join(', ');
|
||
addMessage('ai', `
|
||
<p>Done! Found <strong>${totalItems} items</strong> across <strong>${totalCats} categories</strong> in <strong>${allMenus.length} menus</strong>.</p>
|
||
${imgCount ? `<p style="font-size:13px;color:var(--gray-500);">${imgCount} item images found</p>` : ''}
|
||
${schedSummary ? `<p style="font-size:13px;color:var(--gray-500);">${schedSummary}</p>` : ''}
|
||
`);
|
||
|
||
console.log('=== MULTI-PAGE EXTRACT RESULT ===', config.extractedData);
|
||
|
||
// Brief pause then continue to business info
|
||
await new Promise(r => setTimeout(r, 1500));
|
||
document.getElementById('conversation').innerHTML = '';
|
||
if (config.businessId && config.menuId) {
|
||
showCategoriesStep();
|
||
} else {
|
||
showBusinessInfoStep();
|
||
}
|
||
}
|
||
|
||
// Fallback: extract entire site as single page (original behavior)
|
||
async function skipDiscoveryExtractAll(url) {
|
||
document.getElementById('conversation').innerHTML = '';
|
||
await extractSingleUrl(url);
|
||
}
|
||
|
||
async function extractSingleUrl(url) {
|
||
addMessage('ai', `
|
||
<div style="display:flex;align-items:center;gap:12px;">
|
||
<div class="loading-spinner"></div>
|
||
<span>Crawling website and extracting menu data...</span>
|
||
</div>
|
||
<p style="font-size:13px;color:var(--gray-500);margin-top:8px;">This may take 1-3 minutes while I crawl menu pages, grab photos from ordering platforms, and analyze everything.</p>
|
||
`);
|
||
|
||
try {
|
||
const response = await fetch(`${config.apiBaseUrl}/setup/analyzeMenuUrl.php`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ url })
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (!result.OK) {
|
||
throw new Error(result.MESSAGE || 'Failed to analyze URL');
|
||
}
|
||
|
||
config.extractedData = result.DATA;
|
||
config.sourceUrl = result.sourceUrl;
|
||
config.imageMappings = result.DATA.imageMappings || [];
|
||
|
||
console.log('=== URL IMPORT RESPONSE ===');
|
||
console.log('Source URL:', result.sourceUrl);
|
||
console.log('Extracted data:', result.DATA);
|
||
if (result.steps) console.log('Steps:', result.steps);
|
||
console.log('===========================');
|
||
|
||
document.getElementById('conversation').innerHTML = '';
|
||
|
||
if (config.businessId && config.menuId) {
|
||
showCategoriesStep();
|
||
} else {
|
||
showBusinessInfoStep();
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('URL analysis error:', error);
|
||
document.getElementById('conversation').innerHTML = '';
|
||
addMessage('ai', `
|
||
<p>Sorry, I encountered an error importing from that URL:</p>
|
||
<p style="color: var(--danger);">${error.message}</p>
|
||
<div class="action-buttons">
|
||
<button class="btn btn-primary" onclick="retryUrlAnalysis()">Try Again</button>
|
||
<button class="btn btn-secondary" onclick="switchToFileUpload()">Upload Files Instead</button>
|
||
</div>
|
||
`);
|
||
}
|
||
}
|
||
|
||
function retryUrlAnalysis() {
|
||
document.getElementById('conversation').innerHTML = '';
|
||
document.getElementById('uploadSection').style.display = 'block';
|
||
switchImportTab('url');
|
||
}
|
||
|
||
function switchToFileUpload() {
|
||
document.getElementById('conversation').innerHTML = '';
|
||
document.getElementById('uploadSection').style.display = 'block';
|
||
switchImportTab('upload');
|
||
}
|
||
|
||
// Handle uploaded saved page (HTML or ZIP)
|
||
async function handleSavedPageUpload(event) {
|
||
const file = event.target.files[0];
|
||
if (!file) return;
|
||
|
||
const isZip = file.name.toLowerCase().endsWith('.zip');
|
||
|
||
// Hide upload section, show conversation
|
||
document.getElementById('uploadSection').style.display = 'none';
|
||
|
||
addMessage('ai', `
|
||
<div style="display:flex;align-items:center;gap:12px;">
|
||
<div class="loading-spinner"></div>
|
||
<span>Analyzing saved page: ${file.name}...</span>
|
||
</div>
|
||
<p style="font-size:13px;color:var(--gray-500);margin-top:8px;">${isZip ? 'Uploading and extracting ZIP file...' : 'Extracting menu data from HTML content.'}</p>
|
||
`);
|
||
|
||
let result = null;
|
||
try {
|
||
if (isZip) {
|
||
// Upload ZIP file to server for extraction
|
||
const formData = new FormData();
|
||
formData.append('zipFile', file);
|
||
|
||
console.log('Uploading ZIP to:', `${config.apiBaseUrl}/setup/uploadSavedPage.php`);
|
||
const uploadResponse = await fetch(`${config.apiBaseUrl}/setup/uploadSavedPage.php`, {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
|
||
console.log('Upload response status:', uploadResponse.status, uploadResponse.statusText);
|
||
const uploadText = await uploadResponse.text();
|
||
console.log('Upload response text (first 500):', uploadText.substring(0, 500));
|
||
|
||
let uploadResult;
|
||
try {
|
||
uploadResult = JSON.parse(uploadText);
|
||
} catch (parseErr) {
|
||
console.error('Failed to parse upload response as JSON:', parseErr);
|
||
throw new Error('Server returned invalid response: ' + uploadText.substring(0, 200));
|
||
}
|
||
|
||
if (!uploadResult.OK) {
|
||
throw new Error(uploadResult.MESSAGE || 'Failed to upload ZIP file');
|
||
}
|
||
|
||
console.log('ZIP uploaded, extracted URL:', uploadResult.URL);
|
||
|
||
// Store temp folder ID for cleanup after wizard completes
|
||
if (uploadResult.FOLDER) {
|
||
config.tempFolder = uploadResult.FOLDER;
|
||
}
|
||
|
||
// Update loading message
|
||
document.getElementById('conversation').innerHTML = '';
|
||
addMessage('ai', `
|
||
<div style="display:flex;align-items:center;gap:12px;">
|
||
<div class="loading-spinner"></div>
|
||
<span>Scanning extracted page with browser...</span>
|
||
</div>
|
||
<p style="font-size:13px;color:var(--gray-500);margin-top:8px;">Using Playwright to render the saved page and extract menu data.</p>
|
||
`);
|
||
|
||
// Now analyze the extracted URL with Playwright
|
||
const response = await fetch(`${config.apiBaseUrl}/setup/analyzeMenuUrl.php`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ url: uploadResult.URL })
|
||
});
|
||
|
||
const responseText = await response.text();
|
||
if (!responseText || responseText.trim().length === 0) {
|
||
throw new Error('Server returned empty response');
|
||
}
|
||
result = JSON.parse(responseText);
|
||
} else {
|
||
// Read HTML file content and send directly
|
||
const htmlContent = await file.text();
|
||
console.log('Sending HTML content, length:', htmlContent.length);
|
||
|
||
const response = await fetch(`${config.apiBaseUrl}/setup/analyzeMenuUrl.php`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ html: htmlContent })
|
||
});
|
||
|
||
const responseText = await response.text();
|
||
console.log('Raw response (first 500 chars):', responseText.substring(0, 500));
|
||
|
||
if (!responseText || responseText.trim().length === 0) {
|
||
throw new Error('Server returned empty response');
|
||
}
|
||
|
||
try {
|
||
result = JSON.parse(responseText);
|
||
} catch (parseErr) {
|
||
console.error('JSON parse error. Full response:', responseText);
|
||
throw new Error('Invalid JSON response from server. Check console for details.');
|
||
}
|
||
}
|
||
|
||
console.log('=== SAVED PAGE IMPORT RESPONSE ===');
|
||
console.log('File:', file.name);
|
||
console.log('Full response:', result);
|
||
console.log('==================================');
|
||
|
||
if (!result.OK) {
|
||
throw new Error(result.MESSAGE || 'Failed to analyze saved page');
|
||
}
|
||
|
||
// Store extracted data
|
||
config.extractedData = result.DATA;
|
||
|
||
// Store image mappings for matching uploaded images to items
|
||
config.imageMappings = result.DATA.imageMappings || [];
|
||
console.log('Image mappings from saved page:', config.imageMappings.length);
|
||
|
||
// Remove loading message and start conversation flow
|
||
document.getElementById('conversation').innerHTML = '';
|
||
|
||
// Check if items have imageUrl - skip upload step if they're remote URLs (will be downloaded by saveWizard)
|
||
const itemsWithImages = (config.extractedData.items || []).filter(item => item.imageUrl).length;
|
||
const itemsWithRemoteImages = (config.extractedData.items || []).filter(item => {
|
||
if (!item.imageUrl) return false;
|
||
const url = item.imageUrl;
|
||
// Remote: http://, https://, or protocol-relative //
|
||
return url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//');
|
||
}).length;
|
||
|
||
// Debug: log sample imageUrls
|
||
const sampleItems = (config.extractedData.items || []).filter(item => item.imageUrl).slice(0, 3);
|
||
console.log('Items with images:', itemsWithImages, 'Remote:', itemsWithRemoteImages);
|
||
console.log('Sample imageUrls:', sampleItems.map(i => i.imageUrl));
|
||
|
||
if (itemsWithRemoteImages > 0) {
|
||
// Items have remote image URLs - saveWizard will download them, skip upload step
|
||
console.log(`${itemsWithRemoteImages} items have remote image URLs - skipping upload step`);
|
||
if (config.businessId && config.menuId) {
|
||
showCategoriesStep();
|
||
} else {
|
||
showBusinessInfoStep();
|
||
}
|
||
} else if (itemsWithImages > 0) {
|
||
// Items have local image refs (Menu_files/) - show upload step
|
||
showImageMatchingStep();
|
||
} else if (config.businessId && config.menuId) {
|
||
// In add-menu mode, skip business info and header
|
||
showCategoriesStep();
|
||
} else {
|
||
showBusinessInfoStep();
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Saved page analysis error:', error);
|
||
console.error('Full result object:', result);
|
||
document.getElementById('conversation').innerHTML = '';
|
||
let debugInfo = '';
|
||
if (result && result.debug) {
|
||
debugInfo = `<p style="font-size:12px;color:var(--gray-500);margin-top:8px;">Debug: hasHtml=${result.debug.hasHtmlKey}, htmlLen=${result.debug.htmlLength}, url="${result.debug.urlValue}"</p>`;
|
||
}
|
||
addMessage('ai', `
|
||
<p>Sorry, I encountered an error analyzing that file:</p>
|
||
<p style="color: var(--danger);">${error.message}</p>
|
||
${debugInfo}
|
||
<div class="action-buttons">
|
||
<button class="btn btn-primary" onclick="retryUrlAnalysis()">Try Again</button>
|
||
<button class="btn btn-secondary" onclick="switchToFileUpload()">Upload Images Instead</button>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
// Reset the input so the same file can be selected again
|
||
event.target.value = '';
|
||
}
|
||
|
||
// Legacy alias for backwards compatibility
|
||
function handleHtmlFileUpload(event) {
|
||
handleSavedPageUpload(event);
|
||
}
|
||
|
||
// Show step to upload images from saved webpage subfolder and match to items
|
||
function showImageMatchingStep() {
|
||
const itemCount = config.extractedData.items ? config.extractedData.items.length : 0;
|
||
const itemsWithImages = (config.extractedData.items || []).filter(item => item.imageUrl).length;
|
||
|
||
addMessage('ai', `
|
||
<p>I found <strong>${itemCount} menu items</strong> with <strong>${itemsWithImages} image references</strong>.</p>
|
||
<p>Upload the images from your saved webpage folder to add them to menu items.</p>
|
||
<p style="font-size:13px;color:var(--gray-500);">I'll match by Toast item ID in the filename.</p>
|
||
<div style="margin-top:16px;">
|
||
<input type="file" id="matchImagesInput" accept="image/*" multiple style="display:none;" onchange="handleImageMatching(event)">
|
||
<button class="btn btn-primary" onclick="document.getElementById('matchImagesInput').click()">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:6px;">
|
||
<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 Images from Saved Folder
|
||
</button>
|
||
<button class="btn btn-secondary" onclick="skipImageMatching()" style="margin-left:8px;">
|
||
Skip - No Images
|
||
</button>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
// Handle image matching from uploaded files
|
||
function handleImageMatching(event) {
|
||
const files = event.target.files;
|
||
if (!files.length) return;
|
||
|
||
let matchedCount = 0;
|
||
const matchResults = [];
|
||
const items = config.extractedData.items || [];
|
||
|
||
// Extract Toast-style item ID from filename (e.g., "item-600000000167924464_1636677556.jpg")
|
||
function extractToastId(str) {
|
||
// Match the long numeric ID pattern from Toast filenames
|
||
const match = str.match(/item-(\d{15,})/i);
|
||
return match ? match[1] : null;
|
||
}
|
||
|
||
// Check if this is a base (largest) image vs smaller variant
|
||
function isBaseSizeImage(filename) {
|
||
return !/_00[234]\.[^.]+$/i.test(filename);
|
||
}
|
||
|
||
// Group files by Toast ID, then pick best one per ID
|
||
const filesByToastId = {};
|
||
const unmatchedFiles = [];
|
||
|
||
Array.from(files).forEach(file => {
|
||
const toastId = extractToastId(file.name);
|
||
if (toastId) {
|
||
if (!filesByToastId[toastId]) {
|
||
filesByToastId[toastId] = [];
|
||
}
|
||
filesByToastId[toastId].push(file);
|
||
} else {
|
||
unmatchedFiles.push(file);
|
||
}
|
||
});
|
||
|
||
// For each Toast ID, pick the base image (no suffix) or largest file
|
||
const bestFiles = {};
|
||
for (const [toastId, fileGroup] of Object.entries(filesByToastId)) {
|
||
// Prefer base image (no _002/_003/_004 suffix)
|
||
const baseFile = fileGroup.find(f => isBaseSizeImage(f.name));
|
||
if (baseFile) {
|
||
bestFiles[toastId] = baseFile;
|
||
} else {
|
||
// Fallback: pick largest file
|
||
bestFiles[toastId] = fileGroup.reduce((a, b) => a.size > b.size ? a : b);
|
||
}
|
||
}
|
||
|
||
// Now match best files to items
|
||
for (const [toastId, file] of Object.entries(bestFiles)) {
|
||
const matchedItem = items.find(item => {
|
||
if (!item.imageUrl) return false;
|
||
const itemToastId = extractToastId(item.imageUrl);
|
||
return itemToastId === toastId;
|
||
});
|
||
|
||
if (matchedItem && !config.itemImages[matchedItem.id]) {
|
||
config.itemImages[matchedItem.id] = file;
|
||
matchedCount++;
|
||
matchResults.push({ item: matchedItem.name, file: file.name });
|
||
}
|
||
}
|
||
|
||
// Try to match unmatched files by filename in imageUrl
|
||
unmatchedFiles.forEach(file => {
|
||
const filenameBase = file.name.replace(/\.[^.]+$/, '').toLowerCase();
|
||
const matchedItem = items.find(item => {
|
||
if (!item.imageUrl) return false;
|
||
return item.imageUrl.toLowerCase().includes(filenameBase);
|
||
});
|
||
|
||
if (matchedItem && !config.itemImages[matchedItem.id]) {
|
||
config.itemImages[matchedItem.id] = file;
|
||
matchedCount++;
|
||
matchResults.push({ item: matchedItem.name, file: file.name });
|
||
}
|
||
});
|
||
|
||
// Show results
|
||
const totalFiles = files.length;
|
||
const resultHtml = matchResults.length > 0
|
||
? `<ul style="font-size:13px;margin:8px 0;max-height:200px;overflow-y:auto;">${matchResults.map(r => `<li>${r.item} ← ${r.file}</li>`).join('')}</ul>`
|
||
: '';
|
||
|
||
addMessage('ai', `
|
||
<p>Matched <strong>${matchedCount}</strong> of ${totalFiles} uploaded images to menu items.</p>
|
||
${resultHtml}
|
||
<div class="action-buttons">
|
||
<button class="btn btn-primary" onclick="proceedAfterImageMatching()">Continue</button>
|
||
<button class="btn btn-secondary" onclick="document.getElementById('matchImagesInput').click()">Upload More Images</button>
|
||
</div>
|
||
`);
|
||
|
||
console.log('Image matching results:', matchResults);
|
||
console.log('Item images map:', config.itemImages);
|
||
|
||
// Reset input
|
||
event.target.value = '';
|
||
}
|
||
|
||
function skipImageMatching() {
|
||
proceedAfterImageMatching();
|
||
}
|
||
|
||
function proceedAfterImageMatching() {
|
||
// In add-menu mode, skip business info and header
|
||
if (config.businessId && config.menuId) {
|
||
showCategoriesStep();
|
||
} else {
|
||
showBusinessInfoStep();
|
||
}
|
||
}
|
||
|
||
async function startAnalysis() {
|
||
if (config.uploadedFiles.length === 0) {
|
||
showToast('Please upload at least one menu image', 'error');
|
||
return;
|
||
}
|
||
|
||
// Hide upload section, show conversation
|
||
document.getElementById('uploadSection').style.display = 'none';
|
||
|
||
// Add loading message
|
||
addMessage('ai', `
|
||
<div class="loading-indicator">
|
||
<div class="loading-spinner"></div>
|
||
<span>Analyzing ${config.uploadedFiles.length} menu image${config.uploadedFiles.length > 1 ? 's' : ''}... This may take several minutes, please be patient.</span>
|
||
</div>
|
||
`);
|
||
|
||
try {
|
||
// Send images to API
|
||
const formData = new FormData();
|
||
config.uploadedFiles.forEach((file, i) => {
|
||
formData.append('file' + i, file);
|
||
});
|
||
formData.append('businessId', config.businessId);
|
||
|
||
const response = await fetch(`${config.apiBaseUrl}/setup/analyzeMenuImages.php`, {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (!result.OK) {
|
||
throw new Error(result.MESSAGE || 'Analysis failed');
|
||
}
|
||
|
||
// Store extracted data
|
||
config.extractedData = result.DATA;
|
||
|
||
// Debug: Log raw API response
|
||
console.log('=== RAW API RESPONSE ===');
|
||
console.log('Full result:', result);
|
||
if (result.DEBUG_RAW_RESULTS) {
|
||
console.log('DEBUG_RAW_RESULTS:', result.DEBUG_RAW_RESULTS);
|
||
result.DEBUG_RAW_RESULTS.forEach((imgResult, i) => {
|
||
console.log(`Image ${i} raw result:`, imgResult);
|
||
if (imgResult.modifiers) {
|
||
console.log(` Raw modifiers from image ${i}:`, imgResult.modifiers);
|
||
}
|
||
});
|
||
}
|
||
console.log('Merged DATA:', result.DATA);
|
||
console.log('========================');
|
||
|
||
// Auto-select header image from candidate indices if available
|
||
const headerCandidates = result.DATA.headerCandidateIndices || [];
|
||
if (headerCandidates.length > 0 && config.uploadedFiles.length > 0) {
|
||
const candidateIndex = headerCandidates[0];
|
||
if (candidateIndex >= 0 && candidateIndex < config.uploadedFiles.length) {
|
||
const candidateFile = config.uploadedFiles[candidateIndex];
|
||
// Only use image files as headers (not PDFs)
|
||
if (candidateFile.type.match('image.*')) {
|
||
config.headerImageFile = candidateFile;
|
||
console.log('Auto-selected header image:', candidateFile.name, 'from index', candidateIndex);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Remove loading message and start conversation flow
|
||
document.getElementById('conversation').innerHTML = '';
|
||
|
||
// In add-menu mode, skip business info and header — go straight to categories
|
||
if (config.businessId && config.menuId) {
|
||
showCategoriesStep();
|
||
} else {
|
||
showBusinessInfoStep();
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Analysis error:', error);
|
||
document.getElementById('conversation').innerHTML = '';
|
||
addMessage('ai', `
|
||
<p>Sorry, I encountered an error analyzing your menu:</p>
|
||
<p style="color: var(--danger);">${error.message}</p>
|
||
<div class="action-buttons">
|
||
<button class="btn btn-primary" onclick="retryAnalysis()">Try Again</button>
|
||
</div>
|
||
`);
|
||
}
|
||
}
|
||
|
||
function retryAnalysis() {
|
||
document.getElementById('conversation').innerHTML = '';
|
||
document.getElementById('uploadSection').style.display = 'block';
|
||
}
|
||
|
||
// Helper function to parse hours string into 7-day schedule
|
||
function parseHoursString(hoursText) {
|
||
const schedule = [];
|
||
const dayMap = {
|
||
'monday': 0, 'mon': 0,
|
||
'tuesday': 1, 'tue': 1, 'tues': 1,
|
||
'wednesday': 2, 'wed': 2,
|
||
'thursday': 3, 'thu': 3, 'thur': 3, 'thurs': 3,
|
||
'friday': 4, 'fri': 4,
|
||
'saturday': 5, 'sat': 5,
|
||
'sunday': 6, 'sun': 6
|
||
};
|
||
const dayNames = Object.keys(dayMap);
|
||
|
||
// Initialize all days as open with defaults
|
||
for (let i = 0; i < 7; i++) {
|
||
schedule.push({ open: '09:00', close: '17:00', closed: false });
|
||
}
|
||
|
||
if (!hoursText || !hoursText.trim()) {
|
||
return schedule;
|
||
}
|
||
|
||
const convertTo24Hour = (hour, minute, ampm) => {
|
||
let h = parseInt(hour);
|
||
const m = minute ? parseInt(minute) : 0;
|
||
if (ampm) {
|
||
ampm = ampm.replace(/\./g, '').toLowerCase();
|
||
if (ampm === 'pm' && h < 12) h += 12;
|
||
if (ampm === 'am' && h === 12) h = 0;
|
||
}
|
||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||
};
|
||
|
||
const timePattern = /(\d{1,2}):?(\d{2})?\s*(am|pm|a\.m\.|p\.m\.)?\s*(?:to|[-–])\s*(\d{1,2}):?(\d{2})?\s*(am|pm|a\.m\.|p\.m\.)?/i;
|
||
// Day name pattern (matches day abbreviations/names)
|
||
const dayPattern = new RegExp('(' + dayNames.join('|') + ')', 'gi');
|
||
|
||
// Split by comma, semicolon, or newline to get individual segments
|
||
const segments = hoursText.split(/[,;\n]+/).map(s => s.trim()).filter(s => s.length);
|
||
|
||
let appliedAny = false;
|
||
|
||
for (const segment of segments) {
|
||
// Find time range in this segment
|
||
const timeMatch = segment.match(timePattern);
|
||
if (!timeMatch) continue;
|
||
|
||
const openTime = convertTo24Hour(timeMatch[1], timeMatch[2], timeMatch[3]);
|
||
const closeTime = convertTo24Hour(timeMatch[4], timeMatch[5], timeMatch[6]);
|
||
|
||
// Find all day names in this segment
|
||
const foundDays = [];
|
||
let dayMatch;
|
||
dayPattern.lastIndex = 0;
|
||
while ((dayMatch = dayPattern.exec(segment)) !== null) {
|
||
foundDays.push(dayMap[dayMatch[1].toLowerCase()]);
|
||
}
|
||
|
||
if (foundDays.length === 0) {
|
||
// No days specified - apply to all days
|
||
for (let i = 0; i < 7; i++) {
|
||
schedule[i] = { open: openTime, close: closeTime, closed: false };
|
||
}
|
||
appliedAny = true;
|
||
} else if (foundDays.length >= 2) {
|
||
// Check for range (e.g. "Mon-Thu" or "Mon, Tue, Wed, Thur")
|
||
// Look for dash/en-dash between two day names indicating a range
|
||
const rangeMatch = segment.match(new RegExp('(' + dayNames.join('|') + ')\\s*[-–]\\s*(' + dayNames.join('|') + ')', 'i'));
|
||
if (rangeMatch) {
|
||
// Day range like "Mon-Thu"
|
||
const startDay = dayMap[rangeMatch[1].toLowerCase()];
|
||
const endDay = dayMap[rangeMatch[2].toLowerCase()];
|
||
let d = startDay;
|
||
while (true) {
|
||
schedule[d] = { open: openTime, close: closeTime, closed: false };
|
||
if (d === endDay) break;
|
||
d = (d + 1) % 7;
|
||
}
|
||
} else {
|
||
// Comma-separated days like "Mon, Tue, Wed, Thur"
|
||
for (const d of foundDays) {
|
||
schedule[d] = { open: openTime, close: closeTime, closed: false };
|
||
}
|
||
}
|
||
appliedAny = true;
|
||
} else {
|
||
// Single day
|
||
schedule[foundDays[0]] = { open: openTime, close: closeTime, closed: false };
|
||
appliedAny = true;
|
||
}
|
||
}
|
||
|
||
// Fallback: if no segments matched, try the whole string as one time
|
||
if (!appliedAny) {
|
||
const timeMatch = hoursText.match(timePattern);
|
||
if (timeMatch) {
|
||
const openTime = convertTo24Hour(timeMatch[1], timeMatch[2], timeMatch[3]);
|
||
const closeTime = convertTo24Hour(timeMatch[4], timeMatch[5], timeMatch[6]);
|
||
for (let i = 0; i < 7; i++) {
|
||
schedule[i] = { open: openTime, close: closeTime, closed: false };
|
||
}
|
||
}
|
||
}
|
||
|
||
return schedule;
|
||
}
|
||
|
||
// Toggle day closed/open - time inputs always stay editable
|
||
function toggleDayClosed(dayIdx) {
|
||
// No-op: time inputs stay editable so user can set hours before unchecking "Closed"
|
||
}
|
||
|
||
// Check for duplicate businesses before creating a new one
|
||
async function checkForDuplicateBusiness(biz) {
|
||
try {
|
||
const response = await fetch(`${config.apiBaseUrl}/setup/checkDuplicate.php`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
name: biz.name || '',
|
||
addressLine1: biz.addressLine1 || '',
|
||
city: biz.city || '',
|
||
state: biz.state || '',
|
||
zip: biz.zip || ''
|
||
})
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.OK && result.duplicates && result.duplicates.length > 0) {
|
||
// Show duplicate warning
|
||
const dupList = result.duplicates.map(d =>
|
||
`<li><strong>${d.BusinessName}</strong> (ID: ${d.BusinessID})<br><small>${d.Address || 'No address'}</small></li>`
|
||
).join('');
|
||
|
||
addMessage('ai', `
|
||
<div style="background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 16px; margin-bottom: 16px;">
|
||
<p style="margin: 0 0 12px 0; font-weight: 600; color: #92400e;">
|
||
Possible duplicate found!
|
||
</p>
|
||
<p style="margin: 0 0 12px 0; color: #78350f;">
|
||
A business with a similar name or address already exists:
|
||
</p>
|
||
<ul style="margin: 0 0 16px 0; padding-left: 20px; color: #78350f;">
|
||
${dupList}
|
||
</ul>
|
||
<p style="margin: 0; color: #78350f; font-size: 14px;">
|
||
You can continue to create a new business, or go back and select the existing one from the portal.
|
||
</p>
|
||
</div>
|
||
`);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error checking for duplicates:', error);
|
||
// Don't block the wizard if duplicate check fails
|
||
}
|
||
}
|
||
|
||
// Helper to sync brand color inputs
|
||
function syncBrandColor(hexInput) {
|
||
let hex = hexInput.value.replace(/[^0-9A-Fa-f]/g, '').toUpperCase();
|
||
if (hex.length === 6) {
|
||
document.getElementById('bizBrandColor').value = '#' + hex;
|
||
}
|
||
}
|
||
|
||
// Sync color picker to hex input
|
||
function onBrandColorPick() {
|
||
const colorVal = document.getElementById('bizBrandColor').value;
|
||
document.getElementById('bizBrandColorHex').value = colorVal.replace('#', '').toUpperCase();
|
||
}
|
||
|
||
function syncBrandColorLight(hexInput) {
|
||
let hex = hexInput.value.replace(/[^0-9A-Fa-f]/g, '').toUpperCase();
|
||
if (hex.length === 6) {
|
||
document.getElementById('bizBrandColorLight').value = '#' + hex;
|
||
}
|
||
}
|
||
|
||
function onBrandColorLightPick() {
|
||
const colorVal = document.getElementById('bizBrandColorLight').value;
|
||
document.getElementById('bizBrandColorLightHex').value = colorVal.replace('#', '').toUpperCase();
|
||
}
|
||
|
||
// Step 1: Business Info
|
||
async function showBusinessInfoStep() {
|
||
updateProgress(2);
|
||
const biz = config.extractedData.business || {};
|
||
|
||
// Check for duplicate businesses before showing the form
|
||
await checkForDuplicateBusiness(biz);
|
||
|
||
console.log('Business data:', biz);
|
||
|
||
// Clean business name: strip ordering-site suffixes and embedded address
|
||
let bizDisplayName = (biz.name || '').trim();
|
||
bizDisplayName = bizDisplayName.replace(/\s*[-–—|]\s*(Order\s+(pickup|online|delivery|food)|Online\s+Order|Delivery\s*[&and]+\s*Takeout|Takeout\s*[&and]+\s*Delivery|Menu\s*[&and]+\s*Order).*$/i, '');
|
||
if (biz.addressLine1 && bizDisplayName.toLowerCase().includes(biz.addressLine1.toLowerCase())) {
|
||
bizDisplayName = bizDisplayName.replace(new RegExp(biz.addressLine1.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'), '').trim();
|
||
}
|
||
bizDisplayName = bizDisplayName.replace(/[-–—|]+$/, '').replace(/^[-–—|]+/, '').trim();
|
||
biz.name = bizDisplayName || biz.name;
|
||
|
||
// Parse address into components if it's a single string
|
||
let addressLine1 = biz.addressLine1 || '';
|
||
let city = biz.city || '';
|
||
let state = biz.state || '';
|
||
let zip = biz.zip || '';
|
||
|
||
// Clean city: if it contains commas (e.g. "Santa Monica, CA 90405, USA"), take just the city part
|
||
if (city.includes(',')) {
|
||
city = city.split(',')[0].trim();
|
||
}
|
||
|
||
if (biz.address && !addressLine1) {
|
||
console.log('Parsing address:', biz.address);
|
||
|
||
// Parse from the end forward: ZIP (5 digits), then State (2 letters), then City + AddressLine1
|
||
let remaining = biz.address.trim()
|
||
.replace(/,?\s*(United States|USA|US|U\.S\.A?\.)?\s*$/i, '').trim();
|
||
|
||
// Extract ZIP (last 5 digits, optionally with -4)
|
||
const zipMatch = remaining.match(/\b(\d{5})(?:-\d{4})?\s*$/);
|
||
if (zipMatch) {
|
||
zip = zipMatch[1];
|
||
remaining = remaining.substring(0, zipMatch.index).trim();
|
||
console.log('Found ZIP:', zip, 'Remaining:', remaining);
|
||
}
|
||
|
||
// Extract State (2 letters before ZIP)
|
||
const stateMatch = remaining.match(/\b([A-Z]{2})\s*$/i);
|
||
if (stateMatch) {
|
||
state = stateMatch[1].toUpperCase();
|
||
remaining = remaining.substring(0, stateMatch.index).trim();
|
||
console.log('Found State:', state, 'Remaining:', remaining);
|
||
}
|
||
|
||
// What's left is AddressLine1 + City
|
||
// Try to split by comma first
|
||
if (remaining.includes(',')) {
|
||
const parts = remaining.split(',').map(p => p.trim()).filter(p => p.length > 0);
|
||
addressLine1 = parts[0] || '';
|
||
city = parts.slice(1).join(', ');
|
||
} else {
|
||
// No comma - try to find last sequence of words as city
|
||
// Cities are usually 1-3 words at the end
|
||
const words = remaining.split(/\s+/);
|
||
if (words.length > 3) {
|
||
// Assume last 2 words are city, rest is address
|
||
city = words.slice(-2).join(' ');
|
||
addressLine1 = words.slice(0, -2).join(' ');
|
||
} else if (words.length > 1) {
|
||
// Assume last word is city
|
||
city = words[words.length - 1];
|
||
addressLine1 = words.slice(0, -1).join(' ');
|
||
} else {
|
||
// All in address line 1
|
||
addressLine1 = remaining;
|
||
}
|
||
}
|
||
|
||
// Clean up any trailing commas, periods, or whitespace from city
|
||
city = city.replace(/[,.\s]+$/, '').trim();
|
||
}
|
||
|
||
console.log('Parsed address:', { addressLine1, city, state, zip });
|
||
|
||
// Parse hours into a 7-day schedule
|
||
// Claude may return hours as an object or string — normalize to string
|
||
let hoursRaw = biz.hours || '';
|
||
if (typeof hoursRaw === 'object') {
|
||
// Try to flatten object to readable string (e.g. { monday: "9am-5pm", ... })
|
||
try { hoursRaw = Object.entries(hoursRaw).map(([k, v]) => `${k} ${v}`).join(', '); } catch (e) { hoursRaw = ''; }
|
||
}
|
||
const hoursSchedule = parseHoursString(String(hoursRaw));
|
||
|
||
addMessage('ai', `
|
||
<p>I found your restaurant information:</p>
|
||
<div class="extracted-value editable">
|
||
<label style="font-size:12px;color:var(--gray-500);display:block;margin-bottom:4px;">Restaurant Name</label>
|
||
<input type="text" id="bizName" value="${biz.name || ''}" placeholder="Restaurant name" required>
|
||
</div>
|
||
<div class="extracted-value editable">
|
||
<label style="font-size:12px;color:var(--gray-500);display:block;margin-bottom:4px;">Address Line 1</label>
|
||
<input type="text" id="bizAddressLine1" value="${addressLine1}" placeholder="123 Main St">
|
||
</div>
|
||
<div class="extracted-value editable">
|
||
<label style="font-size:12px;color:var(--gray-500);display:block;margin-bottom:4px;">City</label>
|
||
<input type="text" id="bizCity" value="${city}" placeholder="Los Angeles">
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||
<div class="extracted-value editable">
|
||
<label style="font-size:12px;color:var(--gray-500);display:block;margin-bottom:4px;">State</label>
|
||
<select id="bizState" style="padding:8px 12px;border:1px solid var(--gray-300);border-radius:6px;font-size:14px;width:100%;background:white;">
|
||
${buildStateOptions(state)}
|
||
</select>
|
||
</div>
|
||
<div class="extracted-value editable">
|
||
<label style="font-size:12px;color:var(--gray-500);display:block;margin-bottom:4px;">ZIP Code</label>
|
||
<input type="text" id="bizZip" value="${zip}" placeholder="90001">
|
||
</div>
|
||
</div>
|
||
<div class="extracted-value editable">
|
||
<label style="font-size:12px;color:var(--gray-500);display:block;margin-bottom:4px;">Phone</label>
|
||
<input type="text" id="bizPhone" value="${biz.phone || ''}" placeholder="Phone number">
|
||
</div>
|
||
<div class="extracted-value editable">
|
||
<label style="font-size:12px;color:var(--gray-500);display:block;margin-bottom:4px;">Sales Tax Rate (%)</label>
|
||
<input type="number" id="bizTaxRate" value="" placeholder="8.25" step="0.01" min="0" max="25" style="width:120px;">
|
||
<span style="font-size:11px;color:var(--gray-400);margin-left:8px;">e.g. 8.25 for 8.25%</span>
|
||
</div>
|
||
<div class="extracted-value editable">
|
||
<label style="font-size:12px;color:var(--gray-500);display:block;margin-bottom:4px;">Brand Color</label>
|
||
<div style="display:flex;align-items:center;gap:12px;">
|
||
<input type="color" id="bizBrandColor" value="#${biz.brandColor || 'E74C3C'}" style="width:50px;height:36px;padding:2px;border:1px solid var(--gray-300);border-radius:6px;cursor:pointer;" onchange="onBrandColorPick()">
|
||
<input type="text" id="bizBrandColorHex" value="${biz.brandColor || 'E74C3C'}" placeholder="E74C3C" maxlength="6" style="width:80px;font-family:monospace;" oninput="syncBrandColor(this)">
|
||
<span style="font-size:11px;color:var(--gray-400);">Used for menu accents</span>
|
||
</div>
|
||
</div>
|
||
<div class="extracted-value editable">
|
||
<label style="font-size:12px;color:var(--gray-500);display:block;margin-bottom:4px;">Brand Color Light</label>
|
||
<div style="display:flex;align-items:center;gap:12px;">
|
||
<input type="color" id="bizBrandColorLight" value="#${biz.brandColorLight || 'F5F5F5'}" style="width:50px;height:36px;padding:2px;border:1px solid var(--gray-300);border-radius:6px;cursor:pointer;" onchange="onBrandColorLightPick()">
|
||
<input type="text" id="bizBrandColorLightHex" value="${biz.brandColorLight || ''}" placeholder="F5F5F5" maxlength="6" style="width:80px;font-family:monospace;" oninput="syncBrandColorLight(this)">
|
||
<span style="font-size:11px;color:var(--gray-400);">Light tint for card backgrounds (optional)</span>
|
||
</div>
|
||
</div>
|
||
<div class="extracted-value editable">
|
||
<label style="font-size:12px;color:var(--gray-500);display:block;margin-bottom:4px;">Business Hours</label>
|
||
<div style="font-size:11px;color:var(--gray-400);margin-bottom:4px;">12:00 PM = Noon • 12:00 AM = Midnight</div>
|
||
<table style="width:100%;border-collapse:collapse;margin-top:8px;">
|
||
<thead>
|
||
<tr style="border-bottom:2px solid var(--gray-300);">
|
||
<th style="text-align:left;padding:8px;font-size:12px;color:var(--gray-600);">Day</th>
|
||
<th style="text-align:left;padding:8px;font-size:12px;color:var(--gray-600);">Open</th>
|
||
<th style="text-align:left;padding:8px;font-size:12px;color:var(--gray-600);">Close</th>
|
||
<th style="text-align:center;padding:8px;font-size:12px;color:var(--gray-600);">Closed</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'].map((day, idx) => {
|
||
const dayData = hoursSchedule[idx];
|
||
return `
|
||
<tr style="border-bottom:1px solid var(--gray-200);">
|
||
<td style="padding:8px;font-weight:500;">${day}</td>
|
||
<td style="padding:8px;">
|
||
<input type="time" id="open_${idx}" value="${dayData.open}"
|
||
style="padding:4px 8px;border:1px solid var(--gray-300);border-radius:4px;font-size:14px;">
|
||
</td>
|
||
<td style="padding:8px;">
|
||
<input type="time" id="close_${idx}" value="${dayData.close}"
|
||
style="padding:4px 8px;border:1px solid var(--gray-300);border-radius:4px;font-size:14px;">
|
||
</td>
|
||
<td style="padding:8px;text-align:center;">
|
||
<input type="checkbox" id="closed_${idx}" ${dayData.closed ? 'checked' : ''}
|
||
onchange="toggleDayClosed(${idx})"
|
||
style="width:18px;height:18px;cursor:pointer;">
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<p>Is this information correct?</p>
|
||
<div class="action-buttons">
|
||
<button class="btn btn-success" onclick="confirmBusinessInfo()">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<polyline points="20 6 9 17 4 12"/>
|
||
</svg>
|
||
Looks Good
|
||
</button>
|
||
</div>
|
||
`);
|
||
|
||
// Auto-lookup tax rate based on ZIP code
|
||
if (zip && zip.length >= 5) {
|
||
lookupTaxRate(zip);
|
||
}
|
||
}
|
||
|
||
// Lookup tax rate from ZIP code and populate the field
|
||
async function lookupTaxRate(zipCode) {
|
||
const taxInput = document.getElementById('bizTaxRate');
|
||
if (!taxInput) return;
|
||
|
||
try {
|
||
const resp = await fetch(`/api/setup/lookupTaxRate.php?zip=${encodeURIComponent(zipCode)}`);
|
||
const data = await resp.json();
|
||
|
||
if (data.OK && data.taxRate > 0) {
|
||
taxInput.value = data.taxRate;
|
||
taxInput.style.backgroundColor = '#f0fff4'; // Light green to indicate auto-filled
|
||
console.log('Tax rate auto-populated:', data.taxRate + '%');
|
||
}
|
||
} catch (err) {
|
||
console.log('Tax rate lookup failed (user can enter manually):', err);
|
||
}
|
||
}
|
||
|
||
function confirmBusinessInfo() {
|
||
// Collect hours from the table - only include non-closed days
|
||
const hoursSchedule = [];
|
||
const dayNames = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||
|
||
for (let i = 0; i < 7; i++) {
|
||
const openTime = document.getElementById(`open_${i}`).value;
|
||
const closeTime = document.getElementById(`close_${i}`).value;
|
||
const isClosed = document.getElementById(`closed_${i}`).checked;
|
||
|
||
// Only add days that are not closed
|
||
if (!isClosed) {
|
||
hoursSchedule.push({
|
||
day: dayNames[i],
|
||
dayId: i + 1, // 1=Monday, 7=Sunday
|
||
open: openTime,
|
||
close: closeTime
|
||
});
|
||
}
|
||
}
|
||
|
||
// Update stored data with any edits
|
||
// Get brand colors from hex inputs (without #)
|
||
let brandColor = document.getElementById('bizBrandColorHex').value.replace(/^#/, '').toUpperCase();
|
||
if (!/^[0-9A-F]{6}$/.test(brandColor)) brandColor = 'E74C3C'; // Default if invalid
|
||
let brandColorLight = (document.getElementById('bizBrandColorLightHex').value || '').replace(/^#/, '').toUpperCase();
|
||
if (brandColorLight && !/^[0-9A-F]{6}$/.test(brandColorLight)) brandColorLight = '';
|
||
|
||
config.extractedData.business = {
|
||
name: document.getElementById('bizName').value,
|
||
addressLine1: document.getElementById('bizAddressLine1').value,
|
||
city: document.getElementById('bizCity').value,
|
||
state: document.getElementById('bizState').value,
|
||
zip: document.getElementById('bizZip').value,
|
||
phone: document.getElementById('bizPhone').value,
|
||
taxRatePercent: parseFloat(document.getElementById('bizTaxRate').value) || 0,
|
||
brandColor: brandColor,
|
||
brandColorLight: brandColorLight,
|
||
hoursSchedule: hoursSchedule // Send the structured schedule instead of the raw hours string
|
||
};
|
||
|
||
// Always show header image step so user can upload one
|
||
showHeaderImageStep();
|
||
}
|
||
|
||
// Header Image step - between business info and categories
|
||
function showHeaderImageStep() {
|
||
const hasAutoHeader = config.headerImageFile != null;
|
||
const headerText = hasAutoHeader
|
||
? `<p>I found an image that would work great as your menu header!</p>`
|
||
: `<p>This image appears at the top of your menu in the Payfrit app. It's the first thing customers see!</p>`;
|
||
|
||
addMessage('ai', `
|
||
<p><strong>Header Image</strong></p>
|
||
${headerText}
|
||
<div style="background:var(--gray-100);border-radius:8px;padding:16px;margin:12px 0;">
|
||
<p style="font-weight:500;margin-bottom:8px;">Recommended specs:</p>
|
||
<ul style="margin:0;padding-left:20px;color:var(--gray-600);font-size:13px;">
|
||
<li><strong>Size:</strong> 1200 x 400 pixels</li>
|
||
<li><strong>Format:</strong> JPG or PNG</li>
|
||
<li><strong>Content:</strong> Your restaurant, food, or branding</li>
|
||
</ul>
|
||
</div>
|
||
<div id="headerUploadPreview" style="width:100%;aspect-ratio:3/1;background:#333;border-radius:8px;margin:12px 0;background-size:cover;background-position:center;display:${hasAutoHeader ? 'block' : 'none'};overflow:hidden;"></div>
|
||
<div style="display:flex;gap:12px;margin-top:12px;">
|
||
<label class="btn btn-secondary" style="cursor:pointer;flex:1;text-align:center;">
|
||
<input type="file" id="wizardHeaderFile" accept="image/png,image/jpeg,image/jpg" style="display:none;" onchange="previewWizardHeader(this)">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:6px;vertical-align:middle;">
|
||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
|
||
</svg>
|
||
${hasAutoHeader ? 'Choose Different Image' : 'Choose Image'}
|
||
</label>
|
||
</div>
|
||
<div class="action-buttons" style="margin-top:16px;">
|
||
<button class="btn btn-success" onclick="confirmHeaderImage()">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<polyline points="20 6 9 17 4 12"/>
|
||
</svg>
|
||
${hasAutoHeader ? 'Use This Image' : 'Continue'}
|
||
</button>
|
||
<button class="btn btn-secondary" onclick="skipHeaderImage()">Skip for Now</button>
|
||
</div>
|
||
`);
|
||
|
||
// If we have an auto-detected header, show its preview
|
||
if (hasAutoHeader) {
|
||
const reader = new FileReader();
|
||
reader.onload = function(e) {
|
||
const preview = document.getElementById('headerUploadPreview');
|
||
if (preview) {
|
||
preview.style.backgroundImage = `url(${e.target.result})`;
|
||
preview.style.display = 'block';
|
||
}
|
||
};
|
||
reader.readAsDataURL(config.headerImageFile);
|
||
}
|
||
}
|
||
|
||
function previewWizardHeader(input) {
|
||
if (!input.files || !input.files[0]) return;
|
||
const file = input.files[0];
|
||
|
||
if (file.size > 5 * 1024 * 1024) {
|
||
showToast('Image must be under 5MB', 'error');
|
||
input.value = '';
|
||
return;
|
||
}
|
||
|
||
const reader = new FileReader();
|
||
reader.onload = function(e) {
|
||
const preview = document.getElementById('headerUploadPreview');
|
||
preview.style.backgroundImage = `url(${e.target.result})`;
|
||
preview.style.display = 'block';
|
||
};
|
||
reader.readAsDataURL(file);
|
||
|
||
// Store the file for upload after save
|
||
config.headerImageFile = file;
|
||
}
|
||
|
||
function confirmHeaderImage() {
|
||
if (config.scratchMode) {
|
||
saveScratchBusiness();
|
||
} else {
|
||
showCategoriesStep();
|
||
}
|
||
}
|
||
|
||
function skipHeaderImage() {
|
||
config.headerImageFile = null;
|
||
if (config.scratchMode) {
|
||
saveScratchBusiness();
|
||
} else {
|
||
showCategoriesStep();
|
||
}
|
||
}
|
||
|
||
// Step 2: Categories
|
||
function renderCategoryListHtml(categories) {
|
||
return categories.map((cat, i) => {
|
||
const isSubcat = cat.parentCategoryName ? true : false;
|
||
return `
|
||
<div class="extracted-list-item" style="${isSubcat ? 'padding-left: 28px;' : ''}" data-parent="${cat.parentCategoryName || ''}">
|
||
<input type="checkbox" checked data-index="${i}">
|
||
${isSubcat ? '<span style="color: var(--gray-400); margin-right: 4px; font-size: 12px;">└</span>' : ''}
|
||
<span class="item-text">
|
||
<input type="text" value="${cat.name}" data-index="${i}">
|
||
</span>
|
||
<span class="item-count">${cat.itemCount || 0} items</span>
|
||
<button class="remove-item" onclick="removeCategory(${i})">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||
</svg>
|
||
</button>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function showCategoriesStep() {
|
||
updateProgress(3);
|
||
const categories = config.extractedData.categories || [];
|
||
const topLevel = categories.filter(c => !c.parentCategoryName);
|
||
const subcats = categories.filter(c => c.parentCategoryName);
|
||
|
||
let label = `${topLevel.length} menu categories`;
|
||
if (subcats.length > 0) label += ` (${subcats.length} subcategories)`;
|
||
|
||
addMessage('ai', `
|
||
<p>I found <strong>${label}</strong>:</p>
|
||
<div class="extracted-list" id="categoriesList">
|
||
${renderCategoryListHtml(categories)}
|
||
</div>
|
||
<div class="add-row">
|
||
<input type="text" id="newCategoryName" placeholder="Add new category...">
|
||
<button class="btn btn-secondary" onclick="addCategory()">Add</button>
|
||
</div>
|
||
<p>Uncheck any categories you don't want to include.</p>
|
||
<div class="action-buttons">
|
||
<button class="btn btn-success" onclick="confirmCategories()">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<polyline points="20 6 9 17 4 12"/>
|
||
</svg>
|
||
Continue
|
||
</button>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
function removeCategory(index) {
|
||
const removed = config.extractedData.categories[index];
|
||
config.extractedData.categories.splice(index, 1);
|
||
// If removing a parent, also remove its subcategories
|
||
if (removed && !removed.parentCategoryName) {
|
||
config.extractedData.categories = config.extractedData.categories.filter(
|
||
c => c.parentCategoryName !== removed.name
|
||
);
|
||
}
|
||
document.getElementById('categoriesList').innerHTML =
|
||
renderCategoryListHtml(config.extractedData.categories);
|
||
}
|
||
|
||
function addCategory() {
|
||
const input = document.getElementById('newCategoryName');
|
||
const name = input.value.trim();
|
||
if (!name) return;
|
||
|
||
config.extractedData.categories.push({ name, itemCount: 0 });
|
||
input.value = '';
|
||
|
||
document.getElementById('categoriesList').innerHTML =
|
||
renderCategoryListHtml(config.extractedData.categories);
|
||
}
|
||
|
||
function confirmCategories() {
|
||
// Update categories with any edits and filter unchecked
|
||
const list = document.getElementById('categoriesList');
|
||
const items = list.querySelectorAll('.extracted-list-item');
|
||
const updatedCategories = [];
|
||
|
||
items.forEach(item => {
|
||
const checkbox = item.querySelector('input[type="checkbox"]');
|
||
const nameInput = item.querySelector('.item-text input');
|
||
if (checkbox.checked) {
|
||
const origCat = config.extractedData.categories[checkbox.dataset.index];
|
||
const catObj = {
|
||
name: nameInput.value,
|
||
itemCount: origCat?.itemCount || 0
|
||
};
|
||
// Preserve parentCategoryName for subcategories
|
||
if (origCat?.parentCategoryName) {
|
||
catObj.parentCategoryName = origCat.parentCategoryName;
|
||
}
|
||
// Preserve menuName for multi-menu mapping
|
||
if (origCat?.menuName) {
|
||
catObj.menuName = origCat.menuName;
|
||
}
|
||
updatedCategories.push(catObj);
|
||
}
|
||
});
|
||
|
||
config.extractedData.categories = updatedCategories;
|
||
showModifiersStep();
|
||
}
|
||
|
||
// Step 3: Modifiers
|
||
function showModifiersStep() {
|
||
updateProgress(4);
|
||
const modifiers = config.extractedData.modifiers || [];
|
||
|
||
// Debug: Log the raw modifier data
|
||
console.log('=== MODIFIERS DEBUG ===');
|
||
console.log('Total modifiers:', modifiers.length);
|
||
modifiers.forEach((mod, i) => {
|
||
console.log(`Modifier ${i}:`, mod);
|
||
console.log(` - name:`, mod.name);
|
||
console.log(` - options type:`, typeof mod.options);
|
||
console.log(` - options:`, mod.options);
|
||
if (mod.options && mod.options.length > 0) {
|
||
mod.options.forEach((opt, j) => {
|
||
console.log(` Option ${j}:`, opt, `(type: ${typeof opt})`);
|
||
});
|
||
}
|
||
});
|
||
console.log('======================');
|
||
|
||
if (modifiers.length === 0) {
|
||
addMessage('ai', `
|
||
<p>I didn't detect any modifier templates (like size options, add-ons, etc.).</p>
|
||
<p>Would you like to add some manually, or continue without modifiers?</p>
|
||
<div class="action-buttons">
|
||
<button class="btn btn-outline" onclick="showItemsStep()">Skip Modifiers</button>
|
||
<button class="btn btn-primary" onclick="showAddModifierForm()">Add Modifiers</button>
|
||
</div>
|
||
`);
|
||
return;
|
||
}
|
||
|
||
let modifiersHtml = modifiers.map((mod, i) => {
|
||
const sourceImgIndex = mod.sourceImageIndex;
|
||
const sourceImgBadge = sourceImgIndex
|
||
? `<span class="source-badge clickable" onclick="event.stopPropagation(); showImagePreview(${sourceImgIndex})">Image ${sourceImgIndex}</span>`
|
||
: '<span class="source-badge">Unknown source</span>';
|
||
const appliesToInfo = mod.appliesTo === 'category' && mod.categoryName
|
||
? `<span class="applies-to-badge">Applies to: ${mod.categoryName}</span>`
|
||
: mod.appliesTo === 'uncertain'
|
||
? '<span class="applies-to-badge uncertain">Uncertain application</span>'
|
||
: '';
|
||
|
||
const optionsCount = (mod.options || []).length;
|
||
const optionsList = (mod.options || []).filter(opt => opt && opt.name).map(opt => `
|
||
<div class="modifier-option-detail">
|
||
<span class="option-name">${opt.name}</span>
|
||
<span class="option-price">${opt.price ? `+$${opt.price.toFixed(2)}` : '$0.00'}</span>
|
||
</div>
|
||
`).join('');
|
||
|
||
return `
|
||
<div class="modifier-template" data-index="${i}">
|
||
<div class="modifier-header" onclick="toggleModifierDetails(${i})">
|
||
<input type="checkbox" checked data-index="${i}" onclick="event.stopPropagation()">
|
||
<div class="modifier-info">
|
||
<div class="modifier-name-row">
|
||
<span class="modifier-name">${mod.name || 'Unnamed'}</span>
|
||
<span class="modifier-type">${mod.required ? 'Required' : 'Optional'}</span>
|
||
</div>
|
||
<div class="modifier-meta">
|
||
${sourceImgBadge}
|
||
${appliesToInfo}
|
||
<span class="options-count">${optionsCount} option${optionsCount !== 1 ? 's' : ''}</span>
|
||
</div>
|
||
</div>
|
||
<span class="expand-icon">▼</span>
|
||
</div>
|
||
<div class="modifier-details" id="mod-details-${i}" style="display: none;">
|
||
<div class="modifier-options-list">
|
||
${optionsList}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
addMessage('ai', `
|
||
<p>I found <strong>${modifiers.length} modifier templates</strong> that can be applied to menu items:</p>
|
||
<div id="modifiersList">
|
||
${modifiersHtml}
|
||
</div>
|
||
<p>Uncheck any you don't want to use.</p>
|
||
<div class="action-buttons">
|
||
<button class="btn btn-success" onclick="confirmModifiers()">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<polyline points="20 6 9 17 4 12"/>
|
||
</svg>
|
||
Continue
|
||
</button>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
function toggleModifierDetails(index) {
|
||
const details = document.getElementById(`mod-details-${index}`);
|
||
const icon = details.previousElementSibling.querySelector('.expand-icon');
|
||
|
||
if (details.style.display === 'none') {
|
||
details.style.display = 'block';
|
||
icon.textContent = '▲';
|
||
} else {
|
||
details.style.display = 'none';
|
||
icon.textContent = '▼';
|
||
}
|
||
}
|
||
|
||
function showAddModifierForm() {
|
||
// Initialize modifiers array if needed
|
||
if (!config.extractedData.modifiers) {
|
||
config.extractedData.modifiers = [];
|
||
}
|
||
|
||
addMessage('ai', `
|
||
<div class="add-modifier-form">
|
||
<h4>Add Modifier Template</h4>
|
||
<div class="form-group">
|
||
<label>Modifier Name</label>
|
||
<input type="text" id="newModName" class="form-control" placeholder="e.g., Size, Add-ons, Spice Level">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>
|
||
<input type="checkbox" id="newModRequired"> Required (customer must choose)
|
||
</label>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Options</label>
|
||
<div id="newModOptions">
|
||
<div class="modifier-option-row">
|
||
<input type="text" class="form-control option-name" placeholder="Option name">
|
||
<input type="number" class="form-control option-price" placeholder="Price" step="0.01" min="0">
|
||
<button class="btn btn-sm btn-outline" onclick="removeModifierOption(this)">×</button>
|
||
</div>
|
||
</div>
|
||
<button class="btn btn-sm btn-outline" onclick="addModifierOption()">+ Add Option</button>
|
||
</div>
|
||
<div class="action-buttons">
|
||
<button class="btn btn-outline" onclick="showItemsStep()">Cancel</button>
|
||
<button class="btn btn-outline" onclick="saveModifierAndAddAnother()">Save & Add Another</button>
|
||
<button class="btn btn-primary" onclick="saveModifierAndContinue()">Save & Continue</button>
|
||
</div>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
function addModifierOption() {
|
||
const container = document.getElementById('newModOptions');
|
||
const row = document.createElement('div');
|
||
row.className = 'modifier-option-row';
|
||
row.innerHTML = `
|
||
<input type="text" class="form-control option-name" placeholder="Option name">
|
||
<input type="number" class="form-control option-price" placeholder="Price" step="0.01" min="0">
|
||
<button class="btn btn-sm btn-outline" onclick="removeModifierOption(this)">×</button>
|
||
`;
|
||
container.appendChild(row);
|
||
}
|
||
|
||
function removeModifierOption(btn) {
|
||
const row = btn.closest('.modifier-option-row');
|
||
const container = document.getElementById('newModOptions');
|
||
if (container.children.length > 1) {
|
||
row.remove();
|
||
}
|
||
}
|
||
|
||
function saveCurrentModifier() {
|
||
const name = document.getElementById('newModName').value.trim();
|
||
if (!name) {
|
||
alert('Please enter a modifier name');
|
||
return false;
|
||
}
|
||
|
||
const required = document.getElementById('newModRequired').checked;
|
||
const optionRows = document.querySelectorAll('#newModOptions .modifier-option-row');
|
||
const options = [];
|
||
|
||
optionRows.forEach(row => {
|
||
const optName = row.querySelector('.option-name').value.trim();
|
||
const optPrice = parseFloat(row.querySelector('.option-price').value) || 0;
|
||
if (optName) {
|
||
options.push({ name: optName, price: optPrice });
|
||
}
|
||
});
|
||
|
||
if (options.length === 0) {
|
||
alert('Please add at least one option');
|
||
return false;
|
||
}
|
||
|
||
config.extractedData.modifiers.push({
|
||
name: name,
|
||
required: required,
|
||
options: options,
|
||
appliesTo: 'uncertain'
|
||
});
|
||
|
||
return true;
|
||
}
|
||
|
||
function saveModifierAndAddAnother() {
|
||
if (saveCurrentModifier()) {
|
||
showAddModifierForm();
|
||
}
|
||
}
|
||
|
||
function saveModifierAndContinue() {
|
||
if (saveCurrentModifier()) {
|
||
showItemsStep();
|
||
}
|
||
}
|
||
|
||
function confirmModifiers() {
|
||
const list = document.getElementById('modifiersList');
|
||
const templates = list.querySelectorAll('.modifier-template');
|
||
const updatedModifiers = [];
|
||
|
||
templates.forEach(template => {
|
||
const checkbox = template.querySelector('input[type="checkbox"]');
|
||
if (checkbox.checked) {
|
||
const index = parseInt(checkbox.dataset.index);
|
||
updatedModifiers.push(config.extractedData.modifiers[index]);
|
||
}
|
||
});
|
||
|
||
config.extractedData.modifiers = updatedModifiers;
|
||
|
||
// Go to items step (uncertain modifiers will be handled after items)
|
||
showItemsStep();
|
||
}
|
||
|
||
// New step: Handle uncertain modifier assignments
|
||
function showUncertainModifiersStep() {
|
||
const modifiers = config.extractedData.modifiers || [];
|
||
const categories = config.extractedData.categories || [];
|
||
|
||
// Find modifiers marked as "uncertain"
|
||
const uncertainModifiers = modifiers.filter(mod =>
|
||
mod.appliesTo === 'uncertain'
|
||
);
|
||
|
||
if (uncertainModifiers.length === 0 || categories.length === 0) {
|
||
// No uncertain modifiers or no categories, skip to final review
|
||
showFinalStep();
|
||
return;
|
||
}
|
||
|
||
// Initialize uncertain modifier assignment tracking
|
||
if (!config.uncertainModifierAssignments) {
|
||
config.uncertainModifierAssignments = {};
|
||
config.currentUncertainModIndex = 0;
|
||
}
|
||
|
||
const currentIndex = config.currentUncertainModIndex;
|
||
|
||
if (currentIndex >= uncertainModifiers.length) {
|
||
// All uncertain modifiers have been processed, apply assignments and continue
|
||
applyUncertainModifierAssignments();
|
||
showFinalStep();
|
||
return;
|
||
}
|
||
|
||
const modifier = uncertainModifiers[currentIndex];
|
||
|
||
// Build detailed modifier view
|
||
const sourceImgIndex = modifier.sourceImageIndex;
|
||
const sourceImgBadge = sourceImgIndex
|
||
? `<span class="source-badge clickable" onclick="showImagePreview(${sourceImgIndex})">Image ${sourceImgIndex}</span>`
|
||
: '<span class="source-badge">Unknown source</span>';
|
||
const optionsCount = (modifier.options || []).length;
|
||
const optionsList = (modifier.options || []).filter(opt => opt && opt.name).map(opt => `
|
||
<div class="modifier-option-detail">
|
||
<span class="option-name">${opt.name}</span>
|
||
<span class="option-price">${opt.price ? `+$${opt.price.toFixed(2)}` : '$0.00'}</span>
|
||
</div>
|
||
`).join('');
|
||
|
||
// Ask user about this modifier
|
||
const categoryOptions = categories.map((cat, i) => `
|
||
<label class="category-option">
|
||
<input type="checkbox" name="category-assign" value="${cat.name}">
|
||
<span>${cat.name}</span>
|
||
</label>
|
||
`).join('');
|
||
|
||
addMessage('ai', `
|
||
<p>I found the modifier template <strong>"${modifier.name}"</strong> but I'm not sure which items it applies to.</p>
|
||
<div class="modifier-details-view" style="background: var(--gray-50); border: 1px solid var(--gray-200); border-radius: 8px; padding: 16px; margin: 16px 0;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
||
<span style="font-weight: 500;">${modifier.name}</span>
|
||
${sourceImgBadge}
|
||
</div>
|
||
<div style="color: var(--gray-600); font-size: 14px; margin-bottom: 8px;">
|
||
<strong>${optionsCount} option${optionsCount !== 1 ? 's' : ''}</strong> • ${modifier.required ? 'Required' : 'Optional'}
|
||
</div>
|
||
<div class="modifier-options-list">
|
||
${optionsList}
|
||
</div>
|
||
</div>
|
||
<p>Select the categories where this modifier should be applied, or skip if it doesn't apply automatically:</p>
|
||
<div class="category-selection" style="display: flex; flex-direction: column; gap: 8px; margin: 16px 0;">
|
||
${categoryOptions}
|
||
</div>
|
||
<div class="action-buttons">
|
||
<button class="btn btn-outline" onclick="skipUncertainModifier()">Skip This Modifier</button>
|
||
<button class="btn btn-primary" onclick="assignUncertainModifier('${modifier.name}')">Apply to Selected Categories</button>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
function skipUncertainModifier() {
|
||
config.currentUncertainModIndex++;
|
||
showUncertainModifiersStep();
|
||
}
|
||
|
||
function assignUncertainModifier(modifierName) {
|
||
const checkboxes = document.querySelectorAll('input[name="category-assign"]:checked');
|
||
const selectedCategories = Array.from(checkboxes).map(cb => cb.value);
|
||
|
||
if (selectedCategories.length > 0) {
|
||
config.uncertainModifierAssignments[modifierName] = selectedCategories;
|
||
}
|
||
|
||
config.currentUncertainModIndex++;
|
||
showUncertainModifiersStep();
|
||
}
|
||
|
||
function applyUncertainModifierAssignments() {
|
||
// Apply the user's category selections to items
|
||
const items = config.extractedData.items || [];
|
||
const modifiers = config.extractedData.modifiers || [];
|
||
|
||
for (const [modifierName, categories] of Object.entries(config.uncertainModifierAssignments)) {
|
||
items.forEach(item => {
|
||
if (!item.modifiers) {
|
||
item.modifiers = [];
|
||
}
|
||
|
||
// If this item is in one of the selected categories, add the modifier
|
||
if (categories.includes(item.category)) {
|
||
if (!item.modifiers.includes(modifierName)) {
|
||
item.modifiers.push(modifierName);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Update the modifier's appliesTo field to reflect the assignment
|
||
const modifier = modifiers.find(m => m.name === modifierName);
|
||
if (modifier) {
|
||
modifier.appliesTo = 'category';
|
||
modifier.categoryNames = categories; // Store all assigned categories
|
||
}
|
||
}
|
||
}
|
||
|
||
// Step 4: Items
|
||
function showItemsStep() {
|
||
updateProgress(5);
|
||
const items = config.extractedData.items || [];
|
||
const categories = config.extractedData.categories || [];
|
||
|
||
// Assign unique IDs to all items (fixes per-category index mismatch in confirmItems)
|
||
items.forEach((item, idx) => {
|
||
item.id = 'item_' + idx;
|
||
});
|
||
|
||
if (items.length === 0) {
|
||
addMessage('ai', `
|
||
<p>I couldn't extract any menu items. This might happen with complex or handwritten menus.</p>
|
||
<p>You can add items manually in the Menu Builder after completing this wizard.</p>
|
||
<div class="action-buttons">
|
||
<button class="btn btn-success" onclick="showFinalStep()">Continue to Review</button>
|
||
</div>
|
||
`);
|
||
return;
|
||
}
|
||
|
||
// Group items by category (subcategories grouped under parents)
|
||
let itemsByCategory = {};
|
||
let assignedItemIds = new Set();
|
||
|
||
// Build parent-to-subcategory map
|
||
const subcatMap = {};
|
||
categories.forEach(cat => {
|
||
if (cat.parentCategoryName) {
|
||
if (!subcatMap[cat.parentCategoryName]) subcatMap[cat.parentCategoryName] = [];
|
||
subcatMap[cat.parentCategoryName].push(cat.name);
|
||
}
|
||
});
|
||
|
||
categories.forEach(cat => {
|
||
const catItems = items.filter(item => item.category === cat.name);
|
||
if (catItems.length > 0) {
|
||
itemsByCategory[cat.name] = catItems;
|
||
catItems.forEach(item => assignedItemIds.add(item.id));
|
||
}
|
||
});
|
||
|
||
// Collect any items not assigned to a known category
|
||
const unassignedItems = items.filter(item => !assignedItemIds.has(item.id));
|
||
if (unassignedItems.length > 0) {
|
||
// Group by their category name, or "Menu" if none
|
||
unassignedItems.forEach(item => {
|
||
const catName = item.category || 'Menu';
|
||
if (!itemsByCategory[catName]) {
|
||
itemsByCategory[catName] = [];
|
||
}
|
||
itemsByCategory[catName].push(item);
|
||
});
|
||
}
|
||
|
||
let itemsHtml = '';
|
||
// Render top-level categories first, then their subcategories
|
||
const topLevelCats = categories.filter(c => !c.parentCategoryName);
|
||
const renderedCats = new Set();
|
||
|
||
function renderCategoryItems(catName, isSubcat) {
|
||
const catItems = itemsByCategory[catName];
|
||
if (!catItems || catItems.length === 0) return '';
|
||
renderedCats.add(catName);
|
||
const indent = isSubcat ? 'margin-left: 20px;' : '';
|
||
const prefix = isSubcat ? '<span style="color: var(--gray-400); margin-right: 4px;">└</span>' : '';
|
||
return `
|
||
<div style="margin-bottom: 16px; ${indent}">
|
||
<h4 style="margin-bottom: 8px; color: var(--gray-700);">${prefix}${catName} (${catItems.length})</h4>
|
||
<table class="items-table">
|
||
<thead>
|
||
<tr>
|
||
<th style="width:40px;"></th>
|
||
<th>Item</th>
|
||
<th style="width:80px;">Price</th>
|
||
<th>Modifiers</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${catItems.map((item, i) => {
|
||
const itemMods = item.modifiers || [];
|
||
const allMods = config.extractedData.modifiers || [];
|
||
const modGroupsHtml = itemMods.map(mName => {
|
||
const mg = allMods.find(m => m.name === mName);
|
||
const opts = mg && mg.options ? mg.options.filter(o => o && o.name) : [];
|
||
const optsHtml = opts.map(o =>
|
||
`<div class="item-mod-option">${o.name}${o.price ? `<span class="opt-price">+$${o.price.toFixed(2)}</span>` : ''}</div>`
|
||
).join('');
|
||
return `<div class="item-mod-group">
|
||
<span class="item-mod-group-header" onclick="this.querySelector('.mod-expand-btn').classList.toggle('open');this.parentElement.querySelector('.item-mod-options').classList.toggle('visible')">
|
||
<span class="mod-expand-btn">+</span> ${mName}${opts.length ? ` (${opts.length})` : ''}
|
||
</span>
|
||
<div class="item-mod-options">${optsHtml}</div>
|
||
</div>`;
|
||
}).join('');
|
||
return `
|
||
<tr>
|
||
<td><input type="checkbox" checked data-item-id="${item.id || i}"></td>
|
||
<td>
|
||
<strong>${item.name}</strong>
|
||
${item.description ? `<br><small style="color:var(--gray-500);">${item.description}</small>` : ''}
|
||
${modGroupsHtml ? `<div class="item-mod-groups">${modGroupsHtml}</div>` : ''}
|
||
</td>
|
||
<td>$${parseFloat(item.price || 0).toFixed(2)}</td>
|
||
<td>
|
||
<div class="item-modifiers">
|
||
${itemMods.map(m => `<span class="item-modifier-tag">${m}</span>`).join('')}
|
||
</div>
|
||
</td>
|
||
</tr>`;
|
||
}).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>`;
|
||
}
|
||
|
||
// Render each top-level category and its subcategories
|
||
topLevelCats.forEach(cat => {
|
||
itemsHtml += renderCategoryItems(cat.name, false);
|
||
const subs = subcatMap[cat.name] || [];
|
||
subs.forEach(subName => {
|
||
itemsHtml += renderCategoryItems(subName, true);
|
||
});
|
||
});
|
||
|
||
// Render any remaining categories not yet rendered
|
||
for (const [catName, catItems] of Object.entries(itemsByCategory)) {
|
||
if (renderedCats.has(catName) || catItems.length === 0) continue;
|
||
itemsHtml += renderCategoryItems(catName, false);
|
||
}
|
||
|
||
const hasAnyMods = items.some(it => (it.modifiers || []).length > 0);
|
||
const modsBtn = hasAnyMods ? '<button class="btn btn-outline" id="toggleModsBtn" onclick="toggleItemModifiers()">Show Modifiers</button>' : '';
|
||
|
||
addMessage('ai', `
|
||
<p>I found <strong>${items.length} menu items</strong> across your categories:</p>
|
||
<div class="items-section" id="itemsList">
|
||
${itemsHtml}
|
||
</div>
|
||
<p>Uncheck any items you don't want to include.</p>
|
||
<div class="action-buttons">
|
||
${modsBtn}
|
||
<button class="btn btn-success" onclick="confirmItems()">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<polyline points="20 6 9 17 4 12"/>
|
||
</svg>
|
||
Continue to Review
|
||
</button>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
function toggleItemModifiers() {
|
||
const groups = document.querySelectorAll('#itemsList .item-mod-groups');
|
||
const btn = document.getElementById('toggleModsBtn');
|
||
const showing = btn.textContent === 'Hide Modifiers';
|
||
groups.forEach(g => g.classList.toggle('visible', !showing));
|
||
btn.textContent = showing ? 'Show Modifiers' : 'Hide Modifiers';
|
||
}
|
||
|
||
function confirmItems() {
|
||
// Filter out unchecked items
|
||
const checkboxes = document.querySelectorAll('#itemsList input[type="checkbox"]');
|
||
const checkedIds = new Set();
|
||
checkboxes.forEach(cb => {
|
||
if (cb.checked) {
|
||
checkedIds.add(cb.dataset.itemId);
|
||
}
|
||
});
|
||
|
||
// DEBUG
|
||
console.log('=== confirmItems DEBUG ===');
|
||
console.log('checkboxes found:', checkboxes.length);
|
||
console.log('checkedIds:', [...checkedIds].slice(0, 10));
|
||
console.log('items before filter:', config.extractedData.items.length);
|
||
|
||
config.extractedData.items = config.extractedData.items.filter((item, i) =>
|
||
checkedIds.has(item.id || i.toString())
|
||
);
|
||
|
||
console.log('items after filter:', config.extractedData.items.length);
|
||
|
||
// Now that user has seen items, ask about uncertain modifiers
|
||
showUncertainModifiersStep();
|
||
}
|
||
|
||
// Step 5: Final Review
|
||
function showFinalStep() {
|
||
updateProgress(6);
|
||
|
||
console.log('=== showFinalStep DEBUG ===');
|
||
console.log('config.extractedData.menus:', config.extractedData.menus);
|
||
console.log('config.extractedData keys:', Object.keys(config.extractedData));
|
||
console.log('menus length:', (config.extractedData.menus || []).length);
|
||
|
||
const { business, categories, modifiers, items } = config.extractedData;
|
||
|
||
const topCats = categories.filter(c => !c.parentCategoryName);
|
||
const subCats = categories.filter(c => c.parentCategoryName);
|
||
document.getElementById('summaryCategories').textContent = topCats.length > 0 ? topCats.length : categories.length;
|
||
const subRow = document.getElementById('summarySubcategoriesRow');
|
||
if (subCats.length > 0) {
|
||
document.getElementById('summarySubcategories').textContent = subCats.length;
|
||
subRow.style.display = '';
|
||
} else {
|
||
subRow.style.display = 'none';
|
||
}
|
||
document.getElementById('summaryModifiers').textContent = modifiers.length;
|
||
document.getElementById('summaryItems').textContent = items.length;
|
||
|
||
// Count images by type
|
||
const itemsWithImageUrl = items.filter(item => item.imageUrl).length;
|
||
const manuallyMatchedImages = Object.keys(config.itemImages || {}).length;
|
||
const hasHeaderImage = config.headerImageFile != null;
|
||
|
||
const imageParts = [];
|
||
if (itemsWithImageUrl > 0) imageParts.push(`${itemsWithImageUrl} item`);
|
||
if (manuallyMatchedImages > 0) imageParts.push(`${manuallyMatchedImages} matched`);
|
||
if (hasHeaderImage) imageParts.push('1 header');
|
||
|
||
const imagesRow = document.getElementById('summaryImagesRow');
|
||
if (imageParts.length > 0) {
|
||
document.getElementById('summaryImages').textContent = imageParts.join(', ');
|
||
imagesRow.style.display = '';
|
||
} else {
|
||
imagesRow.style.display = 'none';
|
||
}
|
||
|
||
// In add-menu mode, hide menu name/hours/community meal (already set during menu creation)
|
||
const detectedMenus = config.extractedData.menus || [];
|
||
if (config.menuId) {
|
||
document.getElementById('singleMenuSection').style.display = 'none';
|
||
document.getElementById('multiMenuSection').style.display = 'none';
|
||
document.getElementById('communityMealCard').style.display = 'none';
|
||
} else if (detectedMenus.length > 1) {
|
||
// Multiple menus detected — show multi-menu UI
|
||
document.getElementById('singleMenuSection').style.display = 'none';
|
||
document.getElementById('multiMenuSection').style.display = '';
|
||
const menuListHtml = detectedMenus.map((m, i) => {
|
||
const menuItems = (config.extractedData.items || []).filter(item => item.menu === m.name);
|
||
return `<div style="display:flex;align-items:center;gap:12px;padding:8px 12px;background:var(--gray-50);border:1px solid var(--gray-200);border-radius:6px;margin-bottom:6px;">
|
||
<input type="checkbox" id="menu_${i}" checked style="width:18px;height:18px;">
|
||
<label for="menu_${i}" style="flex:1;font-weight:500;">${m.name}</label>
|
||
<span style="color:var(--gray-500);font-size:13px;">${menuItems.length} items</span>
|
||
</div>`;
|
||
}).join('');
|
||
document.getElementById('multiMenuList').innerHTML = menuListHtml;
|
||
} else {
|
||
// Single menu — show normal UI
|
||
document.getElementById('singleMenuSection').style.display = '';
|
||
document.getElementById('multiMenuSection').style.display = 'none';
|
||
// Set default menu hours based on business hours (earliest open, latest close)
|
||
const hoursSchedule = business.hoursSchedule || [];
|
||
if (hoursSchedule.length > 0) {
|
||
let earliestOpen = '23:59';
|
||
let latestClose = '00:00';
|
||
hoursSchedule.forEach(day => {
|
||
if (day.open && day.open < earliestOpen) earliestOpen = day.open;
|
||
if (day.close && day.close > latestClose) latestClose = day.close;
|
||
});
|
||
document.getElementById('menuStartTime').value = earliestOpen;
|
||
document.getElementById('menuEndTime').value = latestClose;
|
||
}
|
||
}
|
||
|
||
const imagesSummary = imageParts.length > 0 ? `<li>${imageParts.join(', ')} images</li>` : '';
|
||
|
||
addMessage('ai', `
|
||
<p>Your menu is ready to save!</p>
|
||
${config.menuId && config.menuName
|
||
? `<p>Adding to: <strong>${config.menuName}</strong></p>`
|
||
: `<p><strong>${business.name || 'Your Restaurant'}</strong></p>`}
|
||
<ul style="margin: 12px 0; padding-left: 20px; color: var(--gray-600);">
|
||
<li>${topCats.length} categories${subCats.length > 0 ? ` (${subCats.length} subcategories)` : ''}</li>
|
||
<li>${modifiers.length} modifier templates</li>
|
||
<li>${items.length} menu items</li>
|
||
${imagesSummary}
|
||
</ul>
|
||
<p>Click "Save Menu" below to add everything to your Payfrit account.</p>
|
||
`);
|
||
|
||
document.getElementById('finalActions').classList.remove('hidden');
|
||
document.getElementById('previewBtn').disabled = false;
|
||
}
|
||
|
||
async function saveMenu() {
|
||
console.log('=== SAVE MENU CALLED ===');
|
||
console.log('Data to save:', config.extractedData);
|
||
|
||
// In add-menu mode, skip menu name/hours/community meal — already set
|
||
if (!config.menuId) {
|
||
const detectedMenus = config.extractedData.menus || [];
|
||
if (detectedMenus.length > 1) {
|
||
// Multi-menu mode — collect selected menus
|
||
const selectedMenus = [];
|
||
detectedMenus.forEach((m, i) => {
|
||
const cb = document.getElementById(`menu_${i}`);
|
||
if (cb && cb.checked) selectedMenus.push(m.name);
|
||
});
|
||
if (selectedMenus.length === 0) {
|
||
showToast('Please select at least one menu', 'error');
|
||
return;
|
||
}
|
||
config.extractedData.selectedMenus = selectedMenus;
|
||
config.extractedData.menuName = selectedMenus[0]; // fallback
|
||
} else {
|
||
const menuName = document.getElementById('menuNameInput').value.trim() || 'Main Menu';
|
||
const menuStartTime = document.getElementById('menuStartTime')?.value || '';
|
||
const menuEndTime = document.getElementById('menuEndTime')?.value || '';
|
||
|
||
// Validate menu hours fall within business operating hours
|
||
if (menuStartTime && menuEndTime) {
|
||
const hoursSchedule = config.extractedData.business.hoursSchedule || [];
|
||
if (hoursSchedule.length > 0) {
|
||
let earliestOpen = '23:59';
|
||
let latestClose = '00:00';
|
||
hoursSchedule.forEach(day => {
|
||
if (day.open && day.open < earliestOpen) earliestOpen = day.open;
|
||
if (day.close && day.close > latestClose) latestClose = day.close;
|
||
});
|
||
|
||
if (menuStartTime < earliestOpen || menuEndTime > latestClose) {
|
||
showToast(`Menu hours must be within business operating hours (${earliestOpen} - ${latestClose})`, 'error');
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
config.extractedData.menuName = menuName;
|
||
config.extractedData.menuStartTime = menuStartTime;
|
||
config.extractedData.menuEndTime = menuEndTime;
|
||
}
|
||
|
||
// Community meal participation type (1=provide meals, 2=food bank)
|
||
const communityMealRadio = document.querySelector('input[name="communityMealType"]:checked');
|
||
config.extractedData.communityMealType = communityMealRadio ? parseInt(communityMealRadio.value) : 1;
|
||
}
|
||
|
||
const saveBtn = document.querySelector('#finalActions .btn-success');
|
||
const originalText = saveBtn.innerHTML;
|
||
saveBtn.innerHTML = '<div class="loading-spinner" style="width:16px;height:16px;border-width:2px;"></div> Saving...';
|
||
saveBtn.disabled = true;
|
||
|
||
try {
|
||
const response = await fetch(`${config.apiBaseUrl}/setup/saveWizard.php`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
businessId: config.businessId || 0,
|
||
menuId: config.menuId || 0,
|
||
userId: config.userId,
|
||
data: config.extractedData,
|
||
tempFolder: config.tempFolder
|
||
})
|
||
});
|
||
|
||
console.log('HTTP Status:', response.status, response.statusText);
|
||
|
||
const responseText = await response.text();
|
||
console.log('Raw response:', responseText);
|
||
|
||
let result;
|
||
try {
|
||
result = JSON.parse(responseText);
|
||
} catch (e) {
|
||
console.error('Failed to parse JSON response:', e);
|
||
throw new Error('Invalid JSON response from server: ' + responseText.substring(0, 200));
|
||
}
|
||
|
||
console.log('=== SAVE RESPONSE ===');
|
||
console.log('Full result:', result);
|
||
if (result.errors && result.errors.length > 0) {
|
||
console.error('Backend errors:', result.errors);
|
||
}
|
||
if (result.steps && result.steps.length > 0) {
|
||
console.log('Backend steps:', result.steps);
|
||
}
|
||
console.log('====================');
|
||
|
||
if (!result.OK) {
|
||
const errorMsg = result.errors && result.errors.length > 0
|
||
? result.errors.join('; ')
|
||
: (result.MESSAGE || 'Save failed');
|
||
throw new Error(errorMsg);
|
||
}
|
||
|
||
showToast('Menu saved successfully!', 'success');
|
||
|
||
// Use the businessId from the response (in case it was newly created)
|
||
// Lucee serializes struct keys as uppercase, so check both cases
|
||
const summary = result.summary || result.SUMMARY || {};
|
||
const finalBusinessId = summary.businessId || summary.BUSINESSID || summary.businessid || config.businessId;
|
||
|
||
// Update localStorage with the new business ID to keep user logged in
|
||
localStorage.setItem('payfrit_portal_business', finalBusinessId);
|
||
|
||
// Upload header image if one was selected
|
||
if (config.headerImageFile && finalBusinessId) {
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('BusinessID', finalBusinessId);
|
||
formData.append('header', config.headerImageFile);
|
||
|
||
const headerResp = await fetch(`${config.apiBaseUrl}/menu/uploadHeader.php`, {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
const headerResult = await headerResp.json();
|
||
if (headerResult.OK) {
|
||
console.log('Header image uploaded:', headerResult.HEADERURL);
|
||
} else {
|
||
console.error('Header upload failed:', headerResult.MESSAGE);
|
||
showToast('Menu saved, but header image upload failed. You can upload it later in Settings.', 'warning');
|
||
}
|
||
} catch (headerErr) {
|
||
console.error('Header upload error:', headerErr);
|
||
showToast('Menu saved, but header image upload failed. You can upload it later in Settings.', 'warning');
|
||
}
|
||
}
|
||
|
||
// Upload item images if any were matched
|
||
const itemIdMap = summary.itemIdMap || summary.ITEMIDMAP || {};
|
||
const itemImageEntries = Object.entries(config.itemImages || {});
|
||
if (itemImageEntries.length > 0) {
|
||
console.log('Uploading', itemImageEntries.length, 'item images...');
|
||
saveBtn.innerHTML = '<div class="loading-spinner" style="width:16px;height:16px;border-width:2px;"></div> Uploading images...';
|
||
|
||
let uploadedCount = 0;
|
||
let failedCount = 0;
|
||
|
||
for (const [frontendId, file] of itemImageEntries) {
|
||
// Look up database ID from the map (try frontend ID, then item name)
|
||
let dbItemId = itemIdMap[frontendId];
|
||
if (!dbItemId) {
|
||
// Try to find by item name
|
||
const item = config.extractedData.items.find(i => i.id === frontendId);
|
||
if (item && item.name) {
|
||
dbItemId = itemIdMap[item.name];
|
||
}
|
||
}
|
||
|
||
if (!dbItemId) {
|
||
console.warn('No database ID found for frontend ID:', frontendId);
|
||
failedCount++;
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('ItemID', dbItemId);
|
||
formData.append('photo', file);
|
||
|
||
const imgResp = await fetch(`${config.apiBaseUrl}/menu/uploadItemPhoto.php`, {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
const imgResult = await imgResp.json();
|
||
if (imgResult.OK) {
|
||
uploadedCount++;
|
||
} else {
|
||
console.error('Item image upload failed:', imgResult.MESSAGE);
|
||
failedCount++;
|
||
}
|
||
} catch (imgErr) {
|
||
console.error('Item image upload error:', imgErr);
|
||
failedCount++;
|
||
}
|
||
}
|
||
|
||
console.log(`Item images: ${uploadedCount} uploaded, ${failedCount} failed`);
|
||
if (failedCount > 0) {
|
||
showToast(`${uploadedCount} images uploaded, ${failedCount} failed. You can add images later in Menu Builder.`, 'warning');
|
||
} else if (uploadedCount > 0) {
|
||
showToast(`${uploadedCount} item images uploaded!`, 'success');
|
||
}
|
||
}
|
||
|
||
// Redirect back after a moment
|
||
setTimeout(() => {
|
||
window.location.href = config.menuId ? 'menu-builder.html' : 'index.html#menu';
|
||
}, 1500);
|
||
|
||
} catch (error) {
|
||
console.error('Save error:', error);
|
||
showToast('Failed to save: ' + error.message, 'error');
|
||
saveBtn.innerHTML = originalText;
|
||
saveBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
function startOver() {
|
||
if (!confirm('Are you sure you want to start over? All extracted data will be lost.')) {
|
||
return;
|
||
}
|
||
|
||
config.uploadedFiles = [];
|
||
config.extractedData = { business: {}, categories: [], modifiers: [], items: [] };
|
||
config.currentStep = 1;
|
||
|
||
document.getElementById('conversation').innerHTML = '';
|
||
document.getElementById('filePreviewGrid').innerHTML = '';
|
||
document.getElementById('uploadSection').style.display = 'block';
|
||
document.getElementById('uploadZone').classList.remove('has-files');
|
||
document.getElementById('uploadActions').style.display = 'none';
|
||
document.getElementById('finalActions').classList.add('hidden');
|
||
document.getElementById('previewBtn').disabled = true;
|
||
|
||
updateProgress(1);
|
||
}
|
||
|
||
function openPreview() {
|
||
const { business, categories, modifiers, items } = config.extractedData;
|
||
|
||
// Build preview HTML
|
||
let previewHtml = `
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<title>Menu Preview - ${business.name || 'Restaurant'}</title>
|
||
<style>
|
||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
|
||
h1 { border-bottom: 2px solid #333; padding-bottom: 10px; }
|
||
h2 { color: #6366f1; margin-top: 24px; cursor: pointer; }
|
||
h2:hover { color: #4f46e5; }
|
||
.category { margin-bottom: 24px; }
|
||
.category-items { padding-left: 20px; }
|
||
.item { padding: 12px 0; border-bottom: 1px solid #eee; }
|
||
.item-header { display: flex; justify-content: space-between; }
|
||
.item-name { font-weight: 600; }
|
||
.item-price { color: #22c55e; font-weight: 600; }
|
||
.item-desc { color: #666; font-size: 14px; margin-top: 4px; }
|
||
.item-mods { margin-top: 8px; }
|
||
.mod-tag { display: inline-block; background: #e0e7ff; color: #4338ca; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-right: 4px; }
|
||
.business-info { background: #f3f4f6; padding: 16px; border-radius: 8px; margin-bottom: 24px; }
|
||
.collapsed .category-items { display: none; }
|
||
.toggle-icon { float: right; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>${business.name || 'Menu Preview'}</h1>
|
||
<div class="business-info">
|
||
${business.address ? `<div><strong>Address:</strong> ${business.address}</div>` : ''}
|
||
${business.phone ? `<div><strong>Phone:</strong> ${business.phone}</div>` : ''}
|
||
${business.hours ? `<div><strong>Hours:</strong> ${business.hours}</div>` : ''}
|
||
</div>
|
||
`;
|
||
|
||
// Build subcategory lookup for preview
|
||
const previewSubcatMap = {};
|
||
categories.forEach(cat => {
|
||
if (cat.parentCategoryName) {
|
||
if (!previewSubcatMap[cat.parentCategoryName]) previewSubcatMap[cat.parentCategoryName] = [];
|
||
previewSubcatMap[cat.parentCategoryName].push(cat);
|
||
}
|
||
});
|
||
const previewRendered = new Set();
|
||
|
||
function renderPreviewCategory(cat, isSubcat) {
|
||
previewRendered.add(cat.name);
|
||
const catItems = items.filter(item => item.category === cat.name);
|
||
const tag = isSubcat ? 'h3' : 'h2';
|
||
const indent = isSubcat ? 'margin-left: 20px;' : '';
|
||
let html = `
|
||
<div class="category" style="${indent}">
|
||
<${tag} onclick="this.parentElement.classList.toggle('collapsed')" ${isSubcat ? 'style="font-size: 16px;"' : ''}>
|
||
${cat.name} <span class="toggle-icon">[+/-]</span>
|
||
</${tag}>
|
||
<div class="category-items">
|
||
${catItems.map(item => `
|
||
<div class="item">
|
||
<div class="item-header">
|
||
<span class="item-name">${item.name}</span>
|
||
<span class="item-price">$${(item.price || 0).toFixed(2)}</span>
|
||
</div>
|
||
${item.description ? `<div class="item-desc">${item.description}</div>` : ''}
|
||
${item.modifiers && item.modifiers.length > 0 ? `
|
||
<div class="item-mods">
|
||
${item.modifiers.map(m => `<span class="mod-tag">${m}</span>`).join('')}
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`).join('')}
|
||
${catItems.length === 0 && !(previewSubcatMap[cat.name]?.length) ? '<div class="item" style="color:#999;">No items in this category</div>' : ''}
|
||
</div>
|
||
</div>`;
|
||
// Render subcategories inline
|
||
if (previewSubcatMap[cat.name]) {
|
||
previewSubcatMap[cat.name].forEach(sub => {
|
||
html += renderPreviewCategory(sub, true);
|
||
});
|
||
}
|
||
return html;
|
||
}
|
||
|
||
categories.filter(c => !c.parentCategoryName).forEach(cat => {
|
||
previewHtml += renderPreviewCategory(cat, false);
|
||
});
|
||
// Render any unrendered categories
|
||
categories.forEach(cat => {
|
||
if (!previewRendered.has(cat.name)) {
|
||
previewHtml += renderPreviewCategory(cat, false);
|
||
}
|
||
});
|
||
|
||
previewHtml += `
|
||
</body>
|
||
</html>
|
||
`;
|
||
|
||
const previewWindow = window.open('', 'MenuPreview', 'width=900,height=700');
|
||
previewWindow.document.write(previewHtml);
|
||
previewWindow.document.close();
|
||
}
|
||
|
||
// Helper functions
|
||
function addMessage(type, content) {
|
||
const conversation = document.getElementById('conversation');
|
||
const avatar = type === 'ai'
|
||
? '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm0 18a8 8 0 1 1 8-8 8 8 0 0 1-8 8z"/><path d="M12 6v6l4 2"/></svg>'
|
||
: 'U';
|
||
|
||
const message = document.createElement('div');
|
||
message.className = 'message';
|
||
message.innerHTML = `
|
||
<div class="message-avatar ${type}">${avatar}</div>
|
||
<div class="message-content">${content}</div>
|
||
`;
|
||
conversation.appendChild(message);
|
||
|
||
// Scroll to bottom
|
||
message.scrollIntoView({ behavior: 'smooth' });
|
||
}
|
||
|
||
function updateCommunityMealSelection() {
|
||
const selected = document.querySelector('input[name="communityMealType"]:checked').value;
|
||
const opt1 = document.getElementById('communityMealOption1');
|
||
const opt2 = document.getElementById('communityMealOption2');
|
||
if (selected === '1') {
|
||
opt1.style.borderColor = 'var(--primary)';
|
||
opt1.style.background = 'rgba(99, 102, 241, 0.05)';
|
||
opt2.style.borderColor = 'var(--gray-200)';
|
||
opt2.style.background = '';
|
||
} else {
|
||
opt2.style.borderColor = 'var(--primary)';
|
||
opt2.style.background = 'rgba(99, 102, 241, 0.05)';
|
||
opt1.style.borderColor = 'var(--gray-200)';
|
||
opt1.style.background = '';
|
||
}
|
||
}
|
||
|
||
function updateProgress(step) {
|
||
config.currentStep = step;
|
||
document.querySelectorAll('.progress-step').forEach(el => {
|
||
const s = parseInt(el.dataset.step);
|
||
el.classList.remove('active', 'completed');
|
||
if (s < step) el.classList.add('completed');
|
||
if (s === step) el.classList.add('active');
|
||
});
|
||
}
|
||
|
||
function showToast(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);
|
||
}
|
||
|
||
async function loadBusinessInfo() {
|
||
try {
|
||
const response = await fetch(`${config.apiBaseUrl}/businesses/get.php`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ BusinessID: config.businessId })
|
||
});
|
||
const result = await response.json();
|
||
|
||
if (result.OK && result.BUSINESS) {
|
||
const biz = result.BUSINESS;
|
||
document.getElementById('businessName').textContent = biz.NAME || 'Business';
|
||
document.getElementById('businessAvatar').textContent = (biz.NAME || 'B')[0].toUpperCase();
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to load business info:', e);
|
||
}
|
||
}
|
||
|
||
function toggleSidebar() {
|
||
document.getElementById('sidebar').classList.toggle('collapsed');
|
||
}
|
||
|
||
function logout() {
|
||
localStorage.removeItem('payfrit_portal_token');
|
||
localStorage.removeItem('payfrit_portal_userid');
|
||
localStorage.removeItem('payfrit_portal_business');
|
||
window.location.href = 'signup.html';
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|