From a7464545dff0c5c618768a49f38c311f80497a5c Mon Sep 17 00:00:00 2001 From: John Mizerek Date: Sat, 14 Mar 2026 16:29:38 -0700 Subject: [PATCH] Support multiple menus in wizard import (Brunch/Lunch/Dinner) - Updated Claude prompt to detect separate menus vs categories - Added platformImageMap and subPagesVisited parsing from Playwright - Bumped Playwright wait from 5s to 10s for sub-page crawling - saveWizard.php creates separate Menus rows and assigns categories/items to the correct menu based on each item's "menu" field Co-Authored-By: Claude Opus 4.6 --- api/setup/analyzeMenuUrl.php | 22 +++++++++- api/setup/saveWizard.php | 81 +++++++++++++++++++++++++++++++----- 2 files changed, 91 insertions(+), 12 deletions(-) diff --git a/api/setup/analyzeMenuUrl.php b/api/setup/analyzeMenuUrl.php index d28ae3a..57dd60c 100644 --- a/api/setup/analyzeMenuUrl.php +++ b/api/setup/analyzeMenuUrl.php @@ -822,7 +822,7 @@ try { // Remote URL - use Playwright for JS-rendered content $response['steps'][] = "Fetching URL with Playwright: $targetUrl"; - $pwOutput = shell_exec("/opt/playwright/run.sh " . escapeshellarg($targetUrl) . " 5000 2>&1"); + $pwOutput = shell_exec("/opt/playwright/run.sh " . escapeshellarg($targetUrl) . " 10000 2>&1"); if (empty(trim($pwOutput ?? ''))) { throw new Exception("Playwright returned empty response"); } @@ -836,6 +836,19 @@ try { $playwrightImages = $pwResult['images'] ?? []; $response['steps'][] = "Fetched " . strlen($pageHtml) . " bytes via Playwright, " . count($playwrightImages) . " images captured"; + // Capture platform image map (ordering site food photos matched to item names) + $platformImageMap = []; + if (!empty($pwResult['platformImageMap']) && is_array($pwResult['platformImageMap'])) { + $platformImageMap = $pwResult['platformImageMap']; + $response['steps'][] = "Found " . count($platformImageMap) . " item images from ordering platform"; + } + if (!empty($pwResult['subPagesVisited']) && is_array($pwResult['subPagesVisited'])) { + $response['steps'][] = "Visited " . count($pwResult['subPagesVisited']) . " menu sub-pages: " . implode(', ', $pwResult['subPagesVisited']); + } + if (!empty($pwResult['platformPagesVisited']) && is_array($pwResult['platformPagesVisited'])) { + $response['steps'][] = "Visited " . count($pwResult['platformPagesVisited']) . " ordering platforms for photos: " . implode(', ', $pwResult['platformPagesVisited']); + } + // ========== WOOCOMMERCE FAST PATH ========== if (stripos($pageHtml, 'woocommerce') !== false || stripos($pageHtml, 'wc-add-to-cart') !== false || stripos($pageHtml, 'tm-extra-product-options') !== false) { $response['steps'][] = "WooCommerce site detected - running modifier extraction"; @@ -1671,7 +1684,7 @@ try { // ============================================================ // Claude API call for generic pages // ============================================================ - $systemPrompt = 'You are an expert at extracting structured menu data from restaurant website HTML. Extract ALL menu data visible in the HTML. Return valid JSON with these keys: business (object with name, address, phone, hours, brandColor), categories (array), modifiers (array), items (array with name, description, price, category, modifiers array, and imageUrl). CATEGORIES vs ITEMS (CRITICAL): A CATEGORY is a broad section heading that groups multiple items (e.g., \'Appetizers\', \'Tacos\', \'Drinks\', \'Desserts\'). An ITEM is an individual food or drink product with a name, description, and price. Do NOT create a category for each individual item. A typical restaurant has 5-15 categories and 30-150 items. If you find yourself creating more categories than items, you are wrong - those are items, not categories. Each item must have a \'category\' field set to the category it belongs to. CATEGORIES FORMAT: Each entry in the categories array can be either a simple string (for flat categories) OR an object with \'name\' and optional \'subcategories\' array. Example: ["Appetizers", {"name": "Drinks", "subcategories": ["Hot Drinks", "Cold Drinks"]}, "Desserts"]. SUBCATEGORY DETECTION: If a section header contains nested titled sections beneath it (sub-headers with their own items), the outer section is the PARENT and inner sections are SUBCATEGORIES. For items in subcategories, set their \'category\' field to the SUBCATEGORY name (not the parent). CRITICAL FOR IMAGES: Each menu item in the HTML is typically in a container (div, li, article) that also contains an img tag. Extract the img src URL and include it as \'imageUrl\' for that item. Look for img tags that are siblings or children within the same menu-item container. The image URL should be the full or relative src value from the img tag - NOT the alt text. CRITICAL: Extract EVERY menu item from ALL sources including embedded JSON (__NEXT_DATA__, window state, JSON-LD). For brandColor: suggest a vibrant hex (6 digits, no hash). For prices: numbers (e.g., 12.99). CRITICAL: Return ONLY valid JSON. All special characters in strings must be properly escaped. Never use smart/curly quotes. Use only ASCII double quotes for JSON string delimiters and backslash-escape any literal double quotes inside values.'; + $systemPrompt = 'You are an expert at extracting structured menu data from restaurant website HTML. Extract ALL menu data visible in the HTML. Return valid JSON with these keys: business (object with name, address, phone, hours, brandColor), menus (array of objects — see below), categories (array), modifiers (array), items (array with name, description, price, category, menu, modifiers array, and imageUrl). MENUS vs CATEGORIES (CRITICAL): A MENU is a distinct time-based or themed menu that a restaurant offers separately — e.g., "Brunch", "Lunch", "Dinner", "Happy Hour", "Late Night", "Kids Menu". If a restaurant has multiple menus, return a "menus" array of objects like [{"name": "Brunch"}, {"name": "Lunch"}, {"name": "Dinner"}]. Each item should have a "menu" field set to which menu it belongs to. If the restaurant only has one menu or the sections are food-type categories (not time/theme based), omit the "menus" key entirely and treat everything as categories within a single menu. CATEGORIES vs ITEMS (CRITICAL): A CATEGORY is a broad section heading that groups multiple items (e.g., \'Appetizers\', \'Tacos\', \'Drinks\', \'Desserts\'). An ITEM is an individual food or drink product with a name, description, and price. Do NOT create a category for each individual item. A typical restaurant has 5-15 categories and 30-150 items. If you find yourself creating more categories than items, you are wrong - those are items, not categories. Each item must have a \'category\' field set to the category it belongs to. CATEGORIES FORMAT: Each entry in the categories array can be either a simple string (for flat categories) OR an object with \'name\' and optional \'subcategories\' array. Example: ["Appetizers", {"name": "Drinks", "subcategories": ["Hot Drinks", "Cold Drinks"]}, "Desserts"]. SUBCATEGORY DETECTION: If a section header contains nested titled sections beneath it (sub-headers with their own items), the outer section is the PARENT and inner sections are SUBCATEGORIES. For items in subcategories, set their \'category\' field to the SUBCATEGORY name (not the parent). CRITICAL FOR IMAGES: Each menu item in the HTML is typically in a container (div, li, article) that also contains an img tag. Extract the img src URL and include it as \'imageUrl\' for that item. Look for img tags that are siblings or children within the same menu-item container. The image URL should be the full or relative src value from the img tag - NOT the alt text. CRITICAL: Extract EVERY menu item from ALL sources including embedded JSON (__NEXT_DATA__, window state, JSON-LD). For brandColor: suggest a vibrant hex (6 digits, no hash). For prices: numbers (e.g., 12.99). CRITICAL: Return ONLY valid JSON. All special characters in strings must be properly escaped. Never use smart/curly quotes. Use only ASCII double quotes for JSON string delimiters and backslash-escape any literal double quotes inside values.'; // Build message content $messagesContent = []; @@ -1755,6 +1768,11 @@ try { if (!isset($menuData['modifiers'])) $menuData['modifiers'] = []; if (!isset($menuData['items'])) $menuData['items'] = []; + // Pass through menus array if Claude detected multiple menus + if (!empty($menuData['menus']) && is_array($menuData['menus']) && count($menuData['menus']) > 1) { + $response['steps'][] = "Detected " . count($menuData['menus']) . " separate menus: " . implode(', ', array_column($menuData['menus'], 'name')); + } + // Convert categories to expected format $formattedCategories = []; foreach ($menuData['categories'] as $cat) { diff --git a/api/setup/saveWizard.php b/api/setup/saveWizard.php index fae495f..c36a960 100644 --- a/api/setup/saveWizard.php +++ b/api/setup/saveWizard.php @@ -385,12 +385,40 @@ try { } } - // Create or find menu + // Determine menu(s) to create + $selectedMenus = $wizardData['selectedMenus'] ?? []; + $isMultiMenu = !empty($selectedMenus) && count($selectedMenus) > 1; + + // Build list of menus to process + $menusToProcess = []; if ($providedMenuId > 0) { - $menuID = $providedMenuId; - $qName = queryOne("SELECT Name FROM Menus WHERE ID = ?", [$menuID]); - $menuName = $qName ? $qName['Name'] : "Menu $menuID"; - $response['steps'][] = "Using provided menu: $menuName (ID: $menuID)"; + $qName = queryOne("SELECT Name FROM Menus WHERE ID = ?", [$providedMenuId]); + $menusToProcess[] = ['id' => $providedMenuId, 'name' => $qName ? $qName['Name'] : "Menu $providedMenuId"]; + $response['steps'][] = "Using provided menu: " . $menusToProcess[0]['name'] . " (ID: $providedMenuId)"; + } elseif ($isMultiMenu) { + $menuOrder = 0; + foreach ($selectedMenus as $smName) { + $smName = trim($smName); + if (empty($smName)) continue; + + $qMenu = queryOne( + "SELECT ID FROM Menus WHERE BusinessID = ? AND Name = ? AND IsActive = 1", + [$businessId, $smName] + ); + if ($qMenu) { + $menusToProcess[] = ['id' => (int)$qMenu['ID'], 'name' => $smName]; + $response['steps'][] = "Using existing menu: $smName (ID: {$qMenu['ID']})"; + } else { + queryTimed( + "INSERT INTO Menus (BusinessID, Name, DaysActive, StartTime, EndTime, SortOrder, IsActive, AddedOn) VALUES (?, ?, 127, NULL, NULL, ?, 1, NOW())", + [$businessId, $smName, $menuOrder] + ); + $newMenuId = (int)lastInsertId(); + $menusToProcess[] = ['id' => $newMenuId, 'name' => $smName]; + $response['steps'][] = "Created menu: $smName (ID: $newMenuId)"; + } + $menuOrder++; + } } else { $menuName = is_string($wizardData['menuName'] ?? null) && strlen(trim($wizardData['menuName'] ?? '')) ? trim($wizardData['menuName']) @@ -440,6 +468,13 @@ try { $timeInfo = (strlen($menuStartTime) && strlen($menuEndTime)) ? " ($menuStartTime - $menuEndTime)" : ' (all day)'; $response['steps'][] = "Created menu: $menuName$timeInfo (ID: $menuID)"; } + $menusToProcess[] = ['id' => $menuID, 'name' => $menuName]; + } + + // For single menu, use the first (only) entry + if (!$isMultiMenu) { + $menuID = $menusToProcess[0]['id']; + $menuName = $menusToProcess[0]['name']; } // Build category map @@ -448,6 +483,26 @@ try { $response['steps'][] = 'Processing ' . count($categories) . ' categories...'; + // For multi-menu: build a map of menu name → menu ID + $menuNameToId = []; + foreach ($menusToProcess as $mp) { + $menuNameToId[$mp['name']] = $mp['id']; + } + + // For multi-menu: figure out which categories belong to which menu + // by looking at which items reference them + $allItems = $wizardData['items'] ?? []; + $categoryToMenu = []; + if ($isMultiMenu) { + foreach ($allItems as $item) { + $itemMenu = trim($item['menu'] ?? ''); + $itemCat = trim($item['category'] ?? ''); + if (strlen($itemMenu) && strlen($itemCat) && isset($menuNameToId[$itemMenu])) { + $categoryToMenu[$itemCat] = $menuNameToId[$itemMenu]; + } + } + } + // First pass: top-level categories $catOrder = 1; foreach ($categories as $c => $cat) { @@ -459,9 +514,12 @@ try { // Skip subcategories in first pass if (!empty($cat['parentCategoryName'])) continue; + // Determine which menu this category belongs to + $catMenuID = $isMultiMenu ? ($categoryToMenu[$catName] ?? $menusToProcess[0]['id']) : $menuID; + $qCat = queryOne( "SELECT ID FROM Categories WHERE BusinessID = ? AND Name = ? AND MenuID = ?", - [$businessId, $catName, $menuID] + [$businessId, $catName, $catMenuID] ); if ($qCat) { @@ -470,10 +528,11 @@ try { } else { queryTimed( "INSERT INTO Categories (BusinessID, MenuID, Name, SortOrder) VALUES (?, ?, ?, ?)", - [$businessId, $menuID, $catName, $catOrder] + [$businessId, $catMenuID, $catName, $catOrder] ); $categoryID = (int)lastInsertId(); - $response['steps'][] = "Created category: $catName in menu $menuName (ID: $categoryID)"; + $catMenuName = array_column($menusToProcess, 'name', 'id')[$catMenuID] ?? 'unknown'; + $response['steps'][] = "Created category: $catName in menu $catMenuName (ID: $categoryID)"; } $categoryMap[$catName] = $categoryID; @@ -493,9 +552,11 @@ try { continue; } + $catMenuID = $isMultiMenu ? ($categoryToMenu[$catName] ?? ($categoryToMenu[$parentName] ?? $menusToProcess[0]['id'])) : $menuID; + $qCat = queryOne( "SELECT ID FROM Categories WHERE BusinessID = ? AND Name = ? AND MenuID = ? AND ParentCategoryID = ?", - [$businessId, $catName, $menuID, $parentCatID] + [$businessId, $catName, $catMenuID, $parentCatID] ); if ($qCat) { @@ -504,7 +565,7 @@ try { } else { queryTimed( "INSERT INTO Categories (BusinessID, MenuID, Name, ParentCategoryID, SortOrder) VALUES (?, ?, ?, ?, ?)", - [$businessId, $menuID, $catName, $parentCatID, $catOrder] + [$businessId, $catMenuID, $catName, $parentCatID, $catOrder] ); $categoryID = (int)lastInsertId(); $response['steps'][] = "Created subcategory: $catName under $parentName (ID: $categoryID)";