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>
<!--- 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 --->
<cfset allResults = arrayNew(1)>
@ -190,9 +190,18 @@
<!--- 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) --->
<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">
<cfif structKeyExists(result, "business") AND isStruct(result.business)>
<cfloop list="#bizFields#" index="fieldName">
@ -398,10 +407,11 @@
<cfset finalData["categories"] = mergedCategories>
<cfset finalData["modifiers"] = mergedModifiers>
<cfset finalData["items"] = mergedItems>
<cfset finalData["headerCandidateIndices"] = headerCandidateIndices>
<cfset response["OK"] = true>
<cfset response["DATA"] = finalData>
<cfset response["imagesProcessed"] = arrayLen(imageDataArray)>
<cfset response["imagesProcessed"] = arrayLen(imageDataArray)
<cfset response["DEBUG_RAW_RESULTS"] = allResults>
<!--- 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>

View file

@ -57,6 +57,14 @@ try {
// Extract tax rate (stored as decimal, e.g. 8.25% -> 0.0825)
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
addressLine1 = structKeyExists(biz, "addressLine1") && isSimpleValue(biz.addressLine1) ? trim(biz.addressLine1) : "";
city = structKeyExists(biz, "city") && isSimpleValue(biz.city) ? trim(biz.city) : "";
@ -103,8 +111,8 @@ try {
// Create new business with address link and phone
queryTimed("
INSERT INTO Businesses (Name, Phone, UserID, AddressID, DeliveryZIPCodes, CommunityMealType, TaxRate, AddedOn)
VALUES (:name, :phone, :userId, :addressId, :deliveryZips, :communityMealType, :taxRate, NOW())
INSERT INTO Businesses (Name, Phone, UserID, AddressID, DeliveryZIPCodes, CommunityMealType, TaxRate, BrandColor, AddedOn)
VALUES (:name, :phone, :userId, :addressId, :deliveryZips, :communityMealType, :taxRate, :brandColor, NOW())
", {
name: bizName,
phone: bizPhone,
@ -112,7 +120,8 @@ try {
addressId: addressId,
deliveryZips: len(zip) ? zip : "",
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" });
qNewBiz = queryTimed("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" });

View file

@ -1263,6 +1263,20 @@
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 = '';
@ -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
async function showBusinessInfoStep() {
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;">
<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;">
@ -1626,6 +1662,10 @@
}
// 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,
@ -1634,6 +1674,7 @@
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
};
@ -1643,9 +1684,14 @@
// 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>
<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;">
<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;">
@ -1654,14 +1700,14 @@
<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: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;">
<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>
Choose Image
${hasAutoHeader ? 'Choose Different Image' : 'Choose Image'}
</label>
</div>
<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">
<polyline points="20 6 9 17 4 12"/>
</svg>
Continue
${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) {