Add brand color extraction and auto-header detection to setup wizard
This commit is contained in:
parent
02d77b662a
commit
800d1f1246
3 changed files with 88 additions and 10 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Reference in a new issue