This repository has been archived on 2026-03-21. You can view files and clone it, but cannot push or open issues or pull requests.
payfrit-biz/portal/setup-wizard.html
John Mizerek 76f089d1b9 Auto-populate tax rate from ZIP code using API Ninjas
When business info step loads with a ZIP code, automatically looks up
the combined sales tax rate and pre-fills the field. User can still
edit if needed. Field gets light green background to indicate auto-fill.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-13 11:52:56 -08:00

3228 lines
120 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Menu 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;
}
/* 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 your menu from a website URL or upload images/PDFs</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>
</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>
<p style="margin:16px 0 0;color:var(--gray-400);font-size:12px;">Works with most restaurant websites, DoorDash, Yelp, Toast, Square, and more</p>
<!-- 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 12px;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>
<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>
</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 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 class="summary-stat">
<span class="summary-stat-label">Categories</span>
<span class="summary-stat-value" id="summaryCategories">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()">&times;</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})">&times;</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})">&times;</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})">&times;</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})">&times;</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 tabUrl = document.getElementById('tabUrl');
const tabUpload = document.getElementById('tabUpload');
const urlPanel = document.getElementById('urlImportPanel');
const filePanel = document.getElementById('fileUploadPanel');
if (tab === 'url') {
tabUrl.style.background = 'var(--primary)';
tabUrl.style.color = 'white';
tabUpload.style.background = 'var(--gray-100)';
tabUpload.style.color = 'var(--gray-700)';
urlPanel.style.display = 'block';
filePanel.style.display = 'none';
} else {
tabUpload.style.background = 'var(--primary)';
tabUpload.style.color = 'white';
tabUrl.style.background = 'var(--gray-100)';
tabUrl.style.color = 'var(--gray-700)';
urlPanel.style.display = 'none';
filePanel.style.display = 'block';
}
}
// 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;
}
// 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>Crawling website and extracting menu data...</span>
</div>
<p style="font-size:13px;color:var(--gray-500);margin-top:8px;">This may take 30-60 seconds while I fetch pages, download images, and analyze everything.</p>
`);
try {
const response = await fetch(`${config.apiBaseUrl}/setup/analyzeMenuUrl.cfm`, {
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');
}
// Store extracted data
config.extractedData = result.DATA;
config.sourceUrl = result.sourceUrl;
// Store image mappings for matching uploaded images to items
config.imageMappings = result.DATA.imageMappings || [];
// Log debug info
console.log('=== URL IMPORT RESPONSE ===');
console.log('Source URL:', result.sourceUrl);
console.log('Pages processed:', result.pagesProcessed);
console.log('Images found:', result.imagesFound);
console.log('Image mappings:', config.imageMappings.length);
console.log('Extracted data:', result.DATA);
if (result.steps) {
console.log('Steps:', result.steps);
}
console.log('===========================');
// Remove loading message and start conversation flow
document.getElementById('conversation').innerHTML = '';
// In add-menu mode, skip business info and header
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.cfm`);
const uploadResponse = await fetch(`${config.apiBaseUrl}/setup/uploadSavedPage.cfm`, {
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.cfm`, {
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.cfm`, {
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.cfm`, {
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.cfm`, {
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();
}
// 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);
// 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 || '';
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();
// 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
const hoursSchedule = parseHoursString(biz.hours || '');
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;">Business Hours</label>
<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.cfm?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 color from hex input (without #)
let brandColor = document.getElementById('bizBrandColorHex').value.replace(/^#/, '').toUpperCase();
if (!/^[0-9A-F]{6}$/.test(brandColor)) brandColor = 'E74C3C'; // Default if invalid
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,
hoursSchedule: hoursSchedule // Send the structured schedule instead of the raw hours string
};
// Move to header image step
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%;height:120px;background:#333;border-radius:8px;margin:12px 0;background-size:cover;background-position:center;display:${hasAutoHeader ? 'block' : 'none'};"></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() {
showCategoriesStep();
}
function skipHeaderImage() {
config.headerImageFile = null;
showCategoriesStep();
}
// Step 2: Categories
function showCategoriesStep() {
updateProgress(3);
const categories = config.extractedData.categories || [];
let categoriesHtml = categories.map((cat, i) => `
<div class="extracted-list-item">
<input type="checkbox" checked data-index="${i}">
<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('');
addMessage('ai', `
<p>I found <strong>${categories.length} menu categories</strong>:</p>
<div class="extracted-list" id="categoriesList">
${categoriesHtml}
</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) {
config.extractedData.categories.splice(index, 1);
// Rebuild the list
const list = document.getElementById('categoriesList');
const categories = config.extractedData.categories;
list.innerHTML = categories.map((cat, i) => `
<div class="extracted-list-item">
<input type="checkbox" checked data-index="${i}">
<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 addCategory() {
const input = document.getElementById('newCategoryName');
const name = input.value.trim();
if (!name) return;
config.extractedData.categories.push({ name, itemCount: 0 });
input.value = '';
// Rebuild list
removeCategory(-1); // Hacky way to rebuild without removing
config.extractedData.categories.pop(); // Undo the splice
const list = document.getElementById('categoriesList');
const categories = config.extractedData.categories;
list.innerHTML = categories.map((cat, i) => `
<div class="extracted-list-item">
<input type="checkbox" checked data-index="${i}">
<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 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) {
updatedCategories.push({
name: nameInput.value,
itemCount: config.extractedData.categories[checkbox.dataset.index]?.itemCount || 0
});
}
});
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 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 || [];
// DEBUG
console.log('=== showItemsStep DEBUG ===');
console.log('items.length:', items.length);
console.log('categories.length:', categories.length);
console.log('categories:', categories);
console.log('First 3 items:', items.slice(0, 3));
console.log('Item IDs sample:', items.slice(0, 5).map(i => i.id));
console.log('Item categories sample:', items.slice(0, 5).map(i => i.category));
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
let itemsByCategory = {};
let assignedItemIds = new Set();
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 = '';
for (const [catName, catItems] of Object.entries(itemsByCategory)) {
if (catItems.length === 0) continue;
itemsHtml += `
<div style="margin-bottom: 16px;">
<h4 style="margin-bottom: 8px; color: var(--gray-700);">${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) => `
<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>` : ''}
</td>
<td>$${parseFloat(item.price || 0).toFixed(2)}</td>
<td>
<div class="item-modifiers">
${(item.modifiers || []).map(m => `<span class="item-modifier-tag">${m}</span>`).join('')}
</div>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
}
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">
<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 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);
const { business, categories, modifiers, items } = config.extractedData;
document.getElementById('summaryCategories').textContent = categories.length;
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)
if (config.menuId) {
document.getElementById('menuNameInput').parentElement.style.display = 'none';
document.getElementById('menuStartTime').closest('.summary-stat').style.display = 'none';
document.getElementById('communityMealCard').style.display = 'none';
} else {
// 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>${categories.length} categories</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 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.cfm`, {
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.cfm`, {
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.cfm`, {
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>
`;
categories.forEach(cat => {
const catItems = items.filter(item => item.category === cat.name);
previewHtml += `
<div class="category">
<h2 onclick="this.parentElement.classList.toggle('collapsed')">
${cat.name} <span class="toggle-icon">[+/-]</span>
</h2>
<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 ? '<div class="item" style="color:#999;">No items in this category</div>' : ''}
</div>
</div>
`;
});
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.cfm`, {
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>