Add menu setup wizard with Claude Vision integration

- New setup-wizard.html: conversational wizard for uploading menu images
- analyzeMenuImages.cfm: sends images to Claude API, returns structured menu data
- saveWizard.cfm: saves extracted menu to database (categories, items, modifiers)
- Added Setup Wizard button to portal Menu page
- Added .gitignore for config files with secrets

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-01-14 16:02:21 -08:00
parent 3d61d883a2
commit 8d5c0cc6ac
5 changed files with 2100 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
# Config files with secrets
config/claude.json
# Temp files
*.tmp
*.bak

View file

@ -0,0 +1,272 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
/**
* Analyze Menu Images using Claude Vision API
*
* Accepts uploaded menu images (JPG, PNG, PDF) and sends them to Claude
* to extract structured menu data including:
* - Business info (name, address, phone, hours)
* - Categories
* - Modifier templates
* - Menu items with prices and descriptions
*
* Returns structured JSON for the setup wizard to display
*/
response = { "OK": false };
// Claude API key - should be in environment or config
CLAUDE_API_KEY = application.claudeApiKey ?: "";
// If not in application scope, try to read from config
if (!len(CLAUDE_API_KEY)) {
try {
configPath = expandPath("/biz.payfrit.com/config/claude.json");
if (fileExists(configPath)) {
configData = deserializeJSON(fileRead(configPath));
CLAUDE_API_KEY = configData.apiKey ?: "";
}
} catch (any e) {
// Config file doesn't exist or is invalid
}
}
if (!len(CLAUDE_API_KEY)) {
response.MESSAGE = "Claude API key not configured";
writeOutput(serializeJSON(response));
abort;
}
try {
// Check for uploaded files
uploadedFiles = [];
// Get form fields for file uploads (file0, file1, file2, etc.)
formFields = form.keyArray();
for (fieldName in formFields) {
if (reFindNoCase("^file\d+$", fieldName) && len(form[fieldName])) {
// This is a file field
arrayAppend(uploadedFiles, fieldName);
}
}
if (arrayLen(uploadedFiles) == 0) {
throw(message="No files uploaded");
}
// Process uploaded files - convert to base64
imageDataArray = [];
// Create temp directory for uploads if needed
uploadDir = getTempDirectory() & "payfrit_menu_uploads/";
if (!directoryExists(uploadDir)) {
directoryCreate(uploadDir);
}
for (fieldName in uploadedFiles) {
// Upload the file
uploadResult = fileUpload(
uploadDir,
fieldName,
"image/jpeg,image/png,image/gif,image/webp,application/pdf",
"makeunique"
);
if (uploadResult.fileWasSaved) {
filePath = uploadResult.serverDirectory & "/" & uploadResult.serverFile;
fileExt = lCase(uploadResult.serverFileExt);
// Read file and convert to base64
fileContent = fileReadBinary(filePath);
base64Content = toBase64(fileContent);
// Determine media type
mediaType = "image/jpeg";
if (fileExt == "png") mediaType = "image/png";
else if (fileExt == "gif") mediaType = "image/gif";
else if (fileExt == "webp") mediaType = "image/webp";
else if (fileExt == "pdf") mediaType = "application/pdf";
arrayAppend(imageDataArray, {
"type": "image",
"source": {
"type": "base64",
"media_type": mediaType,
"data": base64Content
}
});
// Clean up temp file
fileDelete(filePath);
}
}
if (arrayLen(imageDataArray) == 0) {
throw(message="No valid images could be processed");
}
// Build the prompt for Claude
systemPrompt = "You are an expert at extracting structured menu data from restaurant menu images. Your task is to analyze menu images and return the data in a specific JSON format. Be thorough and extract ALL items, categories, and any modifier patterns you can identify.";
userPrompt = "Please analyze these menu images and extract all the information into the following JSON structure:
{
""business"": {
""name"": ""Restaurant name if visible"",
""address"": ""Full address if visible"",
""phone"": ""Phone number(s) if visible"",
""hours"": ""Business hours if visible""
},
""categories"": [
{
""name"": ""Category name"",
""itemCount"": 0
}
],
""modifiers"": [
{
""name"": ""Modifier template name (e.g., 'Size', 'Protein Choice', 'Toast Type')"",
""required"": true or false,
""options"": [
{ ""name"": ""Option name"", ""price"": 0.00 }
]
}
],
""items"": [
{
""name"": ""Item name"",
""description"": ""Item description if any"",
""price"": 0.00,
""category"": ""Category this item belongs to"",
""modifiers"": [""Name of applicable modifier template""]
}
]
}
Important guidelines:
1. Extract EVERY menu item you can see, with accurate prices
2. Group items into their categories as shown on the menu
3. Look for modifier patterns like:
- Size options (Small/Regular/Large)
- Protein choices (Chicken/Beef/Shrimp)
- Bread choices (Wheat/Sourdough/White/Rye)
- Add-ons with prices (Add bacon +$2.00)
- Required choices (served with choice of side)
4. For modifiers that appear on multiple items, create a reusable template
5. Assign the appropriate modifier templates to each item
6. If a price has a '+' it's usually an upcharge modifier option
7. Pay attention to category headers and section dividers
Return ONLY the JSON object, no other text.";
// Build the messages array with images
messagesContent = [];
// Add each image
for (imgData in imageDataArray) {
arrayAppend(messagesContent, imgData);
}
// Add the text prompt
arrayAppend(messagesContent, {
"type": "text",
"text": userPrompt
});
// Call Claude API
httpService = new http();
httpService.setMethod("POST");
httpService.setUrl("https://api.anthropic.com/v1/messages");
httpService.addParam(type="header", name="Content-Type", value="application/json");
httpService.addParam(type="header", name="x-api-key", value=CLAUDE_API_KEY);
httpService.addParam(type="header", name="anthropic-version", value="2023-06-01");
requestBody = {
"model": "claude-sonnet-4-20250514",
"max_tokens": 8192,
"system": systemPrompt,
"messages": [
{
"role": "user",
"content": messagesContent
}
]
};
httpService.addParam(type="body", value=serializeJSON(requestBody));
httpResult = httpService.send().getPrefix();
if (httpResult.statusCode != "200 OK" && httpResult.statusCode != 200) {
throw(message="Claude API error: " & httpResult.statusCode, detail=httpResult.fileContent);
}
// Parse Claude's response
claudeResponse = deserializeJSON(httpResult.fileContent);
if (!structKeyExists(claudeResponse, "content") || !arrayLen(claudeResponse.content)) {
throw(message="Empty response from Claude");
}
// Extract the text content
responseText = "";
for (block in claudeResponse.content) {
if (block.type == "text") {
responseText = block.text;
break;
}
}
// Parse the JSON from Claude's response
// Claude might wrap it in markdown code blocks, so strip those
responseText = trim(responseText);
if (left(responseText, 7) == "```json") {
responseText = mid(responseText, 8, len(responseText) - 7);
}
if (left(responseText, 3) == "```") {
responseText = mid(responseText, 4, len(responseText) - 3);
}
if (right(responseText, 3) == "```") {
responseText = left(responseText, len(responseText) - 3);
}
responseText = trim(responseText);
extractedData = deserializeJSON(responseText);
// Update category item counts
if (structKeyExists(extractedData, "categories") && structKeyExists(extractedData, "items")) {
for (i = 1; i <= arrayLen(extractedData.categories); i++) {
catName = extractedData.categories[i].name;
itemCount = 0;
for (item in extractedData.items) {
if (structKeyExists(item, "category") && item.category == catName) {
itemCount++;
}
}
extractedData.categories[i].itemCount = itemCount;
}
}
// Add unique IDs to items
if (structKeyExists(extractedData, "items")) {
for (i = 1; i <= arrayLen(extractedData.items); i++) {
extractedData.items[i].id = "item_" & i;
}
}
response.OK = true;
response.DATA = extractedData;
response.imagesProcessed = arrayLen(imageDataArray);
} catch (any e) {
response.MESSAGE = e.message;
if (len(e.detail)) {
response.DETAIL = e.detail;
}
}
writeOutput(serializeJSON(response));
</cfscript>

309
api/setup/saveWizard.cfm Normal file
View file

@ -0,0 +1,309 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfsetting requesttimeout="300">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
/**
* Save Wizard Data
*
* Takes the extracted menu data from the setup wizard and saves it to the database.
* This transforms the wizard format into the format expected by the import system.
*
* POST JSON:
* {
* "businessId": "existing-business-id",
* "data": {
* "business": { "name": "...", "address": "...", "phone": "...", "hours": "..." },
* "categories": [ { "name": "...", "itemCount": 0 } ],
* "modifiers": [ { "name": "...", "required": true, "options": [...] } ],
* "items": [ { "name": "...", "price": 0, "category": "...", "modifiers": [...] } ]
* }
* }
*/
response = { "OK": false, "steps": [], "errors": [] };
try {
requestBody = toString(getHttpRequestData().content);
if (!len(requestBody)) {
throw(message="No request body provided");
}
data = deserializeJSON(requestBody);
businessId = structKeyExists(data, "businessId") ? val(data.businessId) : 0;
wizardData = structKeyExists(data, "data") ? data.data : {};
if (businessId == 0) {
throw(message="businessId is required");
}
// Verify business exists
qBiz = queryExecute("
SELECT BusinessID, BusinessName FROM Businesses WHERE BusinessID = :id
", { id: businessId }, { datasource: "payfrit" });
if (qBiz.recordCount == 0) {
throw(message="Business not found: " & businessId);
}
response.steps.append("Found business: " & qBiz.BusinessName);
// Update business info if provided
biz = structKeyExists(wizardData, "business") ? wizardData.business : {};
if (structKeyExists(biz, "name") && len(biz.name)) {
// Optionally update business name and other info
// For now we'll skip updating existing business - just add menu
response.steps.append("Business info available (not updating existing)");
}
// Build modifier template map
// The wizard format has modifiers as simple objects, we need to create IDs
modTemplates = structKeyExists(wizardData, "modifiers") ? wizardData.modifiers : [];
templateMap = {}; // Maps modifier name to database ItemID
response.steps.append("Processing " & arrayLen(modTemplates) & " modifier templates...");
for (i = 1; i <= arrayLen(modTemplates); i++) {
tmpl = modTemplates[i];
tmplName = tmpl.name;
required = structKeyExists(tmpl, "required") && tmpl.required == true;
options = structKeyExists(tmpl, "options") ? tmpl.options : [];
// Check if template already exists for this business
qTmpl = queryExecute("
SELECT i.ItemID FROM Items i
WHERE i.ItemBusinessID = :bizID
AND i.ItemName = :name
AND i.ItemParentItemID = 0
AND i.ItemIsCollapsible = 1
", { bizID: businessId, name: tmplName }, { datasource: "payfrit" });
if (qTmpl.recordCount > 0) {
templateItemID = qTmpl.ItemID;
response.steps.append("Template exists: " & tmplName & " (ID: " & templateItemID & ")");
} else {
// Create template as Item with ItemIsCollapsible=1 to mark as template
queryExecute("
INSERT INTO Items (
ItemBusinessID, ItemName, ItemParentItemID, ItemPrice,
ItemIsActive, ItemRequiresChildSelection, ItemMaxNumSelectionReq,
ItemSortOrder, ItemIsCollapsible
) VALUES (
:bizID, :name, 0, 0, 1, :required, 1, 0, 1
)
", {
bizID: businessId,
name: tmplName,
required: required ? 1 : 0
}, { datasource: "payfrit" });
qNewTmpl = queryExecute("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" });
templateItemID = qNewTmpl.id;
response.steps.append("Created template: " & tmplName & " (ID: " & templateItemID & ")");
}
templateMap[tmplName] = templateItemID;
// Create/update template options
optionOrder = 1;
for (opt in options) {
optName = opt.name;
optPrice = structKeyExists(opt, "price") ? val(opt.price) : 0;
qOpt = queryExecute("
SELECT ItemID FROM Items
WHERE ItemBusinessID = :bizID AND ItemName = :name AND ItemParentItemID = :parentID
", { bizID: businessId, name: optName, parentID: templateItemID }, { datasource: "payfrit" });
if (qOpt.recordCount == 0) {
queryExecute("
INSERT INTO Items (
ItemBusinessID, ItemName, ItemParentItemID, ItemPrice,
ItemIsActive, ItemSortOrder
) VALUES (
:bizID, :name, :parentID, :price, 1, :sortOrder
)
", {
bizID: businessId,
name: optName,
parentID: templateItemID,
price: optPrice,
sortOrder: optionOrder
}, { datasource: "payfrit" });
}
optionOrder++;
}
}
// Build category map
categories = structKeyExists(wizardData, "categories") ? wizardData.categories : [];
categoryMap = {}; // Maps category name to ItemID
response.steps.append("Processing " & arrayLen(categories) & " categories...");
catOrder = 1;
for (cat in categories) {
catName = cat.name;
// Check if category exists (Item at ParentID=0, not a template)
qCat = queryExecute("
SELECT ItemID FROM Items
WHERE ItemBusinessID = :bizID
AND ItemName = :name
AND ItemParentItemID = 0
AND (ItemIsCollapsible IS NULL OR ItemIsCollapsible = 0)
", { bizID: businessId, name: catName }, { datasource: "payfrit" });
if (qCat.recordCount > 0) {
categoryItemID = qCat.ItemID;
response.steps.append("Category exists: " & catName);
} else {
queryExecute("
INSERT INTO Items (
ItemBusinessID, ItemName, ItemParentItemID, ItemPrice,
ItemIsActive, ItemSortOrder, ItemIsCollapsible
) VALUES (
:bizID, :name, 0, 0, 1, :sortOrder, 0
)
", {
bizID: businessId,
name: catName,
sortOrder: catOrder
}, { datasource: "payfrit" });
qNewCat = queryExecute("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" });
categoryItemID = qNewCat.id;
response.steps.append("Created category: " & catName & " (ID: " & categoryItemID & ")");
}
categoryMap[catName] = categoryItemID;
catOrder++;
}
// Create menu items
items = structKeyExists(wizardData, "items") ? wizardData.items : [];
response.steps.append("Processing " & arrayLen(items) & " menu items...");
totalItems = 0;
totalLinks = 0;
// Track item order within each category
categoryItemOrder = {};
for (item in items) {
itemName = item.name;
itemDesc = structKeyExists(item, "description") ? item.description : "";
itemPrice = structKeyExists(item, "price") ? val(item.price) : 0;
itemCategory = structKeyExists(item, "category") ? item.category : "";
itemModifiers = structKeyExists(item, "modifiers") ? item.modifiers : [];
// Get category ID
if (!len(itemCategory) || !structKeyExists(categoryMap, itemCategory)) {
response.steps.append("Warning: Item '" & itemName & "' has unknown category '" & itemCategory & "' - skipping");
continue;
}
categoryItemID = categoryMap[itemCategory];
// Track sort order within category
if (!structKeyExists(categoryItemOrder, itemCategory)) {
categoryItemOrder[itemCategory] = 1;
}
itemOrder = categoryItemOrder[itemCategory];
categoryItemOrder[itemCategory]++;
// Check if item exists
qItem = queryExecute("
SELECT ItemID FROM Items
WHERE ItemBusinessID = :bizID AND ItemName = :name AND ItemParentItemID = :parentID
", { bizID: businessId, name: itemName, parentID: categoryItemID }, { datasource: "payfrit" });
if (qItem.recordCount > 0) {
menuItemID = qItem.ItemID;
// Update existing item
queryExecute("
UPDATE Items SET
ItemDescription = :desc,
ItemPrice = :price,
ItemSortOrder = :sortOrder
WHERE ItemID = :id
", {
desc: itemDesc,
price: itemPrice,
sortOrder: itemOrder,
id: menuItemID
}, { datasource: "payfrit" });
} else {
queryExecute("
INSERT INTO Items (
ItemBusinessID, ItemName, ItemDescription, ItemParentItemID,
ItemPrice, ItemIsActive, ItemSortOrder
) VALUES (
:bizID, :name, :desc, :parentID, :price, 1, :sortOrder
)
", {
bizID: businessId,
name: itemName,
desc: itemDesc,
parentID: categoryItemID,
price: itemPrice,
sortOrder: itemOrder
}, { datasource: "payfrit" });
qNewItem = queryExecute("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" });
menuItemID = qNewItem.id;
}
totalItems++;
// Link modifier templates to this item
modOrder = 1;
for (modName in itemModifiers) {
if (structKeyExists(templateMap, modName)) {
templateItemID = templateMap[modName];
// Check if link exists
qLink = queryExecute("
SELECT 1 FROM ItemTemplateLinks
WHERE ItemID = :itemID AND TemplateItemID = :templateID
", { itemID: menuItemID, templateID: templateItemID }, { datasource: "payfrit" });
if (qLink.recordCount == 0) {
queryExecute("
INSERT INTO ItemTemplateLinks (ItemID, TemplateItemID, SortOrder)
VALUES (:itemID, :templateID, :sortOrder)
", {
itemID: menuItemID,
templateID: templateItemID,
sortOrder: modOrder
}, { datasource: "payfrit" });
totalLinks++;
}
modOrder++;
}
}
}
response.steps.append("Created/updated " & totalItems & " items with " & totalLinks & " modifier links");
response.OK = true;
response.summary = {
"businessId": businessId,
"categoriesProcessed": arrayLen(categories),
"templatesProcessed": arrayLen(modTemplates),
"itemsProcessed": totalItems,
"linksCreated": totalLinks
};
} catch (any e) {
response.errors.append(e.message);
if (len(e.detail)) {
response.errors.append(e.detail);
}
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -245,6 +245,14 @@
<!-- Menu Page --> <!-- Menu Page -->
<section class="page" id="page-menu"> <section class="page" id="page-menu">
<div class="page-header"> <div class="page-header">
<a href="setup-wizard.html" class="btn btn-secondary" style="margin-right: auto;">
<svg width="16" height="16" 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>
Setup Wizard
</a>
<button class="btn btn-primary" onclick="Portal.showAddCategoryModal()"> <button class="btn btn-primary" onclick="Portal.showAddCategoryModal()">
<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">
<path d="M12 5v14M5 12h14"/> <path d="M12 5v14M5 12h14"/>

1505
portal/setup-wizard.html Normal file

File diff suppressed because it is too large Load diff