Add brand color extraction and auto-header detection to setup wizard

This commit is contained in:
John Mizerek 2026-02-12 14:20:35 -08:00
parent 02d77b662a
commit 800d1f1246
3 changed files with 88 additions and 10 deletions

View file

@ -94,7 +94,7 @@
</cfif> </cfif>
<!--- System prompt for per-image analysis ---> <!--- System prompt for per-image analysis --->
<cfset systemPrompt = "You are an expert at extracting structured menu data from restaurant menu images. Extract ALL data visible on THIS image only. Return valid JSON with these keys: business (object with name, address, phone, hours - only if visible), categories (array of category names visible), modifiers (array of modifier templates with name, required boolean, appliesTo (string: 'category', 'item', or 'uncertain'), categoryName (if appliesTo='category'), and options array where each option is an object with 'name' and 'price' keys), items (array with name, description, price, category, and modifiers array). CRITICAL for hours: Extract ALL days' hours including Saturday and Sunday. Format as a single string with ALL days, e.g. ""Mon-Fri 10:30am-10pm, Sat 11am-10pm, Sun 11am-9pm"". If hours are shown as ""Monday - Friday: X"" followed by Saturday and Sunday hours, you MUST include the weekend hours too. Never omit weekend hours if they are visible. For modifier options, ALWAYS use format: {""name"": ""option name"", ""price"": 0}. IMPORTANT: Look for modifiers in these locations: (1) text under category headers (e.g., 'All burgers include...', 'Choose your size:'), (2) item descriptions (e.g., 'served with choice of side'), (3) asterisk notes, (4) header/footer text. For modifiers found under category headers, set appliesTo='category' and categoryName to the category. For modifiers clearly in item descriptions, assign them to that item's modifiers array. For modifiers where you're uncertain how they apply, set appliesTo='uncertain' and do NOT assign to items. Return ONLY valid JSON, no markdown, no explanation."> <cfset systemPrompt = "You are an expert at extracting structured menu data from restaurant menu images. Extract ALL data visible on THIS image only. Return valid JSON with these keys: business (object with name, address, phone, hours, brandColor - only if visible), categories (array of category names visible), modifiers (array of modifier templates with name, required boolean, appliesTo (string: 'category', 'item', or 'uncertain'), categoryName (if appliesTo='category'), and options array where each option is an object with 'name' and 'price' keys), items (array with name, description, price, category, and modifiers array), isHeaderCandidate (boolean - true if this image shows a restaurant interior, exterior, food photography, logo/branding, or banner that would work well as a menu header image). For brandColor: Extract the dominant accent/brand color from the menu design (logo, headers, accent elements). Return as a 6-digit hex code WITHOUT the # symbol (e.g. ""E74C3C"" for red). Choose a vibrant, appealing color that represents the restaurant's brand. CRITICAL for hours: Extract ALL days' hours including Saturday and Sunday. Format as a single string with ALL days, e.g. ""Mon-Fri 10:30am-10pm, Sat 11am-10pm, Sun 11am-9pm"". If hours are shown as ""Monday - Friday: X"" followed by Saturday and Sunday hours, you MUST include the weekend hours too. Never omit weekend hours if they are visible. For modifier options, ALWAYS use format: {""name"": ""option name"", ""price"": 0}. IMPORTANT: Look for modifiers in these locations: (1) text under category headers (e.g., 'All burgers include...', 'Choose your size:'), (2) item descriptions (e.g., 'served with choice of side'), (3) asterisk notes, (4) header/footer text. For modifiers found under category headers, set appliesTo='category' and categoryName to the category. For modifiers clearly in item descriptions, assign them to that item's modifiers array. For modifiers where you're uncertain how they apply, set appliesTo='uncertain' and do NOT assign to items. Return ONLY valid JSON, no markdown, no explanation.">
<!--- Process each image individually ---> <!--- Process each image individually --->
<cfset allResults = arrayNew(1)> <cfset allResults = arrayNew(1)>
@ -190,9 +190,18 @@
<!--- MERGE PHASE: Combine all results ---> <!--- MERGE PHASE: Combine all results --->
<!--- Track header candidates (image indices that would work as headers) --->
<cfset headerCandidateIndices = arrayNew(1)>
<cfloop from="1" to="#arrayLen(allResults)#" index="resultIdx">
<cfset result = allResults[resultIdx]>
<cfif structKeyExists(result, "isHeaderCandidate") AND result.isHeaderCandidate EQ true>
<cfset arrayAppend(headerCandidateIndices, resultIdx - 1)><!--- 0-indexed for JS --->
</cfif>
</cfloop>
<!--- 1. Extract business info (last image wins so user controls via upload order) ---> <!--- 1. Extract business info (last image wins so user controls via upload order) --->
<cfset mergedBusiness = structNew()> <cfset mergedBusiness = structNew()>
<cfset bizFields = "name,address,addressLine1,city,state,zip,phone,hours"> <cfset bizFields = "name,address,addressLine1,city,state,zip,phone,hours,brandColor">
<cfloop array="#allResults#" index="result"> <cfloop array="#allResults#" index="result">
<cfif structKeyExists(result, "business") AND isStruct(result.business)> <cfif structKeyExists(result, "business") AND isStruct(result.business)>
<cfloop list="#bizFields#" index="fieldName"> <cfloop list="#bizFields#" index="fieldName">
@ -398,10 +407,11 @@
<cfset finalData["categories"] = mergedCategories> <cfset finalData["categories"] = mergedCategories>
<cfset finalData["modifiers"] = mergedModifiers> <cfset finalData["modifiers"] = mergedModifiers>
<cfset finalData["items"] = mergedItems> <cfset finalData["items"] = mergedItems>
<cfset finalData["headerCandidateIndices"] = headerCandidateIndices>
<cfset response["OK"] = true> <cfset response["OK"] = true>
<cfset response["DATA"] = finalData> <cfset response["DATA"] = finalData>
<cfset response["imagesProcessed"] = arrayLen(imageDataArray)> <cfset response["imagesProcessed"] = arrayLen(imageDataArray)
<cfset response["DEBUG_RAW_RESULTS"] = allResults> <cfset response["DEBUG_RAW_RESULTS"] = allResults>
<!--- Debug: show first category structure ---> <!--- Debug: show first category structure --->
<cfif arrayLen(allResults) GT 0 AND structKeyExists(allResults[1], "categories") AND isArray(allResults[1].categories) AND arrayLen(allResults[1].categories) GT 0> <cfif arrayLen(allResults) GT 0 AND structKeyExists(allResults[1], "categories") AND isArray(allResults[1].categories) AND arrayLen(allResults[1].categories) GT 0>

View file

@ -57,6 +57,14 @@ try {
// Extract tax rate (stored as decimal, e.g. 8.25% -> 0.0825) // Extract tax rate (stored as decimal, e.g. 8.25% -> 0.0825)
bizTaxRate = structKeyExists(biz, "taxRatePercent") && isSimpleValue(biz.taxRatePercent) ? val(biz.taxRatePercent) / 100 : 0; bizTaxRate = structKeyExists(biz, "taxRatePercent") && isSimpleValue(biz.taxRatePercent) ? val(biz.taxRatePercent) / 100 : 0;
// Extract brand color (6-digit hex without #)
bizBrandColor = structKeyExists(biz, "brandColor") && isSimpleValue(biz.brandColor) ? trim(biz.brandColor) : "";
// Ensure it's a valid 6-digit hex (remove # if present)
bizBrandColor = reReplace(bizBrandColor, "^##?", "");
if (!reFind("^[0-9A-Fa-f]{6}$", bizBrandColor)) {
bizBrandColor = ""; // Invalid format, skip it
}
// Create address record first (use extracted address fields) - safely extract as simple values // Create address record first (use extracted address fields) - safely extract as simple values
addressLine1 = structKeyExists(biz, "addressLine1") && isSimpleValue(biz.addressLine1) ? trim(biz.addressLine1) : ""; addressLine1 = structKeyExists(biz, "addressLine1") && isSimpleValue(biz.addressLine1) ? trim(biz.addressLine1) : "";
city = structKeyExists(biz, "city") && isSimpleValue(biz.city) ? trim(biz.city) : ""; city = structKeyExists(biz, "city") && isSimpleValue(biz.city) ? trim(biz.city) : "";
@ -103,8 +111,8 @@ try {
// Create new business with address link and phone // Create new business with address link and phone
queryTimed(" queryTimed("
INSERT INTO Businesses (Name, Phone, UserID, AddressID, DeliveryZIPCodes, CommunityMealType, TaxRate, AddedOn) INSERT INTO Businesses (Name, Phone, UserID, AddressID, DeliveryZIPCodes, CommunityMealType, TaxRate, BrandColor, AddedOn)
VALUES (:name, :phone, :userId, :addressId, :deliveryZips, :communityMealType, :taxRate, NOW()) VALUES (:name, :phone, :userId, :addressId, :deliveryZips, :communityMealType, :taxRate, :brandColor, NOW())
", { ", {
name: bizName, name: bizName,
phone: bizPhone, phone: bizPhone,
@ -112,7 +120,8 @@ try {
addressId: addressId, addressId: addressId,
deliveryZips: len(zip) ? zip : "", deliveryZips: len(zip) ? zip : "",
communityMealType: communityMealType, communityMealType: communityMealType,
taxRate: { value: bizTaxRate, cfsqltype: "cf_sql_decimal" } taxRate: { value: bizTaxRate, cfsqltype: "cf_sql_decimal" },
brandColor: { value: len(bizBrandColor) ? bizBrandColor : javaCast("null", ""), cfsqltype: "cf_sql_varchar", null: !len(bizBrandColor) }
}, { datasource: "payfrit" }); }, { datasource: "payfrit" });
qNewBiz = queryTimed("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" }); qNewBiz = queryTimed("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" });

View file

@ -1263,6 +1263,20 @@
console.log('Merged DATA:', result.DATA); console.log('Merged DATA:', result.DATA);
console.log('========================'); 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 // Remove loading message and start conversation flow
document.getElementById('conversation').innerHTML = ''; document.getElementById('conversation').innerHTML = '';
@ -1450,6 +1464,20 @@
} }
} }
// 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 // Step 1: Business Info
async function showBusinessInfoStep() { async function showBusinessInfoStep() {
updateProgress(2); updateProgress(2);
@ -1556,6 +1584,14 @@
<input type="number" id="bizTaxRate" value="" placeholder="8.25" step="0.01" min="0" max="25" style="width:120px;"> <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> <span style="font-size:11px;color:var(--gray-400);margin-left:8px;">e.g. 8.25 for 8.25%</span>
</div> </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"> <div class="extracted-value editable">
<label style="font-size:12px;color:var(--gray-500);display:block;margin-bottom:4px;">Business Hours</label> <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;"> <table style="width:100%;border-collapse:collapse;margin-top:8px;">
@ -1626,6 +1662,10 @@
} }
// Update stored data with any edits // 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 = { config.extractedData.business = {
name: document.getElementById('bizName').value, name: document.getElementById('bizName').value,
addressLine1: document.getElementById('bizAddressLine1').value, addressLine1: document.getElementById('bizAddressLine1').value,
@ -1634,6 +1674,7 @@
zip: document.getElementById('bizZip').value, zip: document.getElementById('bizZip').value,
phone: document.getElementById('bizPhone').value, phone: document.getElementById('bizPhone').value,
taxRatePercent: parseFloat(document.getElementById('bizTaxRate').value) || 0, taxRatePercent: parseFloat(document.getElementById('bizTaxRate').value) || 0,
brandColor: brandColor,
hoursSchedule: hoursSchedule // Send the structured schedule instead of the raw hours string hoursSchedule: hoursSchedule // Send the structured schedule instead of the raw hours string
}; };
@ -1643,9 +1684,14 @@
// Header Image step - between business info and categories // Header Image step - between business info and categories
function showHeaderImageStep() { 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', ` addMessage('ai', `
<p><strong>Header Image</strong></p> <p><strong>Header Image</strong></p>
<p>This image appears at the top of your menu in the Payfrit app. It's the first thing customers see!</p> ${headerText}
<div style="background:var(--gray-100);border-radius:8px;padding:16px;margin:12px 0;"> <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> <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;"> <ul style="margin:0;padding-left:20px;color:var(--gray-600);font-size:13px;">
@ -1654,14 +1700,14 @@
<li><strong>Content:</strong> Your restaurant, food, or branding</li> <li><strong>Content:</strong> Your restaurant, food, or branding</li>
</ul> </ul>
</div> </div>
<div id="headerUploadPreview" style="width:100%;height:120px;background:#333;border-radius:8px;margin:12px 0;background-size:cover;background-position:center;display:none;"></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;"> <div style="display:flex;gap:12px;margin-top:12px;">
<label class="btn btn-secondary" style="cursor:pointer;flex:1;text-align:center;"> <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)"> <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;"> <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"/> <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
</svg> </svg>
Choose Image ${hasAutoHeader ? 'Choose Different Image' : 'Choose Image'}
</label> </label>
</div> </div>
<div class="action-buttons" style="margin-top:16px;"> <div class="action-buttons" style="margin-top:16px;">
@ -1669,11 +1715,24 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <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"/> <polyline points="20 6 9 17 4 12"/>
</svg> </svg>
Continue ${hasAutoHeader ? 'Use This Image' : 'Continue'}
</button> </button>
<button class="btn btn-secondary" onclick="skipHeaderImage()">Skip for Now</button> <button class="btn btn-secondary" onclick="skipHeaderImage()">Skip for Now</button>
</div> </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) { function previewWizardHeader(input) {