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:
parent
3d61d883a2
commit
8d5c0cc6ac
5 changed files with 2100 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
# Config files with secrets
|
||||||
|
config/claude.json
|
||||||
|
|
||||||
|
# Temp files
|
||||||
|
*.tmp
|
||||||
|
*.bak
|
||||||
272
api/setup/analyzeMenuImages.cfm
Normal file
272
api/setup/analyzeMenuImages.cfm
Normal 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
309
api/setup/saveWizard.cfm
Normal 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>
|
||||||
|
|
@ -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
1505
portal/setup-wizard.html
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue