Add Manage Menus toolbar button, photo upload, and various improvements

- Move menu manager button to toolbar next to Save Menu for visibility
- Implement server-side photo upload for menu items
- Strip base64 data URLs from save payload to reduce size
- Add scheduled tasks, quick tasks, ratings, and task categories APIs
- Add vertical support and brand color features

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-01-28 14:43:41 -08:00
parent 0d3c381ed6
commit 8f9da2fbf0
44 changed files with 2709 additions and 231 deletions

5
CHANGELOG.md Normal file
View file

@ -0,0 +1,5 @@
# Payfrit Portal Updates
## Week of 2026-01-26
- Fixed saved cart not being detected when entering child business

View file

@ -0,0 +1,37 @@
<cfsetting showdebugoutput="false">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// Add TaskCategoryIsActive column to TaskCategories table
try {
// Check if column exists
qCheck = queryExecute("
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'payfrit'
AND TABLE_NAME = 'TaskCategories'
AND COLUMN_NAME = 'TaskCategoryIsActive'
", [], { datasource: "payfrit" });
if (qCheck.recordCount == 0) {
queryExecute("
ALTER TABLE TaskCategories
ADD COLUMN TaskCategoryIsActive TINYINT(1) NOT NULL DEFAULT 1
", [], { datasource: "payfrit" });
writeOutput(serializeJSON({
"OK": true,
"MESSAGE": "Column TaskCategoryIsActive added to TaskCategories"
}));
} else {
writeOutput(serializeJSON({
"OK": true,
"MESSAGE": "Column already exists"
}));
}
} catch (any e) {
writeOutput(serializeJSON({
"OK": false,
"ERROR": e.message
}));
}
</cfscript>

View file

@ -0,0 +1,37 @@
<cfsetting showdebugoutput="false">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// Add CategoryID column to tt_TaskTypes (Services) table
try {
// Check if column exists
qCheck = queryExecute("
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'payfrit'
AND TABLE_NAME = 'tt_TaskTypes'
AND COLUMN_NAME = 'tt_TaskTypeCategoryID'
", [], { datasource: "payfrit" });
if (qCheck.recordCount == 0) {
queryExecute("
ALTER TABLE tt_TaskTypes
ADD COLUMN tt_TaskTypeCategoryID INT NULL
", [], { datasource: "payfrit" });
writeOutput(serializeJSON({
"OK": true,
"MESSAGE": "Column tt_TaskTypeCategoryID added to tt_TaskTypes"
}));
} else {
writeOutput(serializeJSON({
"OK": true,
"MESSAGE": "Column already exists"
}));
}
} catch (any e) {
writeOutput(serializeJSON({
"OK": false,
"ERROR": e.message
}));
}
</cfscript>

View file

@ -0,0 +1,37 @@
<cfsetting showdebugoutput="false">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// Fix orphaned tasks that had old category ID 4 (deleted Service Point)
// Update them to use new category ID 25 (new Service Point)
try {
// First check how many tasks are affected
qCount = queryExecute("
SELECT COUNT(*) as cnt FROM Tasks WHERE TaskCategoryID = 4
", [], { datasource: "payfrit" });
affectedCount = qCount.cnt;
if (affectedCount > 0) {
// Update them to the new category
queryExecute("
UPDATE Tasks SET TaskCategoryID = 25 WHERE TaskCategoryID = 4
", [], { datasource: "payfrit" });
writeOutput(serializeJSON({
"OK": true,
"MESSAGE": "Updated " & affectedCount & " tasks from category 4 to category 25"
}));
} else {
writeOutput(serializeJSON({
"OK": true,
"MESSAGE": "No tasks found with category ID 4"
}));
}
} catch (any e) {
writeOutput(serializeJSON({
"OK": false,
"ERROR": e.message
}));
}
</cfscript>

View file

@ -0,0 +1,37 @@
<cfsetting showdebugoutput="false">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// Add QuickTaskTemplateTypeID column to QuickTaskTemplates table if it doesn't exist
try {
// Check if column exists
qCheck = queryExecute("
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'payfrit'
AND TABLE_NAME = 'QuickTaskTemplates'
AND COLUMN_NAME = 'QuickTaskTemplateTypeID'
", [], { datasource: "payfrit" });
if (qCheck.recordCount == 0) {
queryExecute("
ALTER TABLE QuickTaskTemplates
ADD COLUMN QuickTaskTemplateTypeID INT NULL AFTER QuickTaskTemplateCategoryID
", [], { datasource: "payfrit" });
writeOutput(serializeJSON({
"OK": true,
"MESSAGE": "Column QuickTaskTemplateTypeID added to QuickTaskTemplates"
}));
} else {
writeOutput(serializeJSON({
"OK": true,
"MESSAGE": "Column already exists"
}));
}
} catch (any e) {
writeOutput(serializeJSON({
"OK": false,
"ERROR": e.message
}));
}
</cfscript>

View file

@ -0,0 +1,21 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// One-time cleanup: delete test tasks and reset
try {
// Delete tasks 30, 31, 32 (test tasks with bad data)
queryExecute("DELETE FROM Tasks WHERE TaskID IN (30, 31, 32)", [], { datasource: "payfrit" });
writeOutput(serializeJSON({
"OK": true,
"MESSAGE": "Cleanup complete - deleted test tasks"
}));
} catch (any e) {
writeOutput(serializeJSON({
"OK": false,
"ERROR": e.message
}));
}
</cfscript>

View file

@ -52,8 +52,7 @@ try {
SELECT SELECT
QuickTaskTemplateTitle as Title, QuickTaskTemplateTitle as Title,
QuickTaskTemplateDetails as Details, QuickTaskTemplateDetails as Details,
QuickTaskTemplateCategoryID as CategoryID, QuickTaskTemplateCategoryID as CategoryID
QuickTaskTemplateTypeID as TypeID
FROM QuickTaskTemplates FROM QuickTaskTemplates
WHERE QuickTaskTemplateID = :id WHERE QuickTaskTemplateID = :id
AND QuickTaskTemplateBusinessID = :businessID AND QuickTaskTemplateBusinessID = :businessID
@ -67,24 +66,21 @@ try {
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Template not found" }); apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Template not found" });
} }
// Create the task // Create the task (TaskClaimedByUserID=0 means unclaimed/pending)
queryExecute(" queryExecute("
INSERT INTO Tasks ( INSERT INTO Tasks (
TaskBusinessID, TaskCategoryID, TaskTypeID, TaskBusinessID, TaskCategoryID, TaskTypeID,
TaskTitle, TaskDetails, TaskStatusID, TaskAddedOn, TaskTitle, TaskDetails, TaskAddedOn, TaskClaimedByUserID
TaskSourceType, TaskSourceID
) VALUES ( ) VALUES (
:businessID, :categoryID, :typeID, :businessID, :categoryID, :typeID,
:title, :details, 0, NOW(), :title, :details, NOW(), 0
'quicktask', :templateID
) )
", { ", {
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }, businessID: { value: businessID, cfsqltype: "cf_sql_integer" },
categoryID: { value: qTemplate.CategoryID, cfsqltype: "cf_sql_integer", null: isNull(qTemplate.CategoryID) }, categoryID: { value: qTemplate.CategoryID, cfsqltype: "cf_sql_integer", null: isNull(qTemplate.CategoryID) },
typeID: { value: qTemplate.TypeID, cfsqltype: "cf_sql_integer", null: isNull(qTemplate.TypeID) }, typeID: { value: 0, cfsqltype: "cf_sql_integer" },
title: { value: qTemplate.Title, cfsqltype: "cf_sql_varchar" }, title: { value: qTemplate.Title, cfsqltype: "cf_sql_varchar" },
details: { value: qTemplate.Details, cfsqltype: "cf_sql_longvarchar", null: isNull(qTemplate.Details) }, details: { value: qTemplate.Details, cfsqltype: "cf_sql_longvarchar", null: isNull(qTemplate.Details) }
templateID: { value: templateID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" }); }, { datasource: "payfrit" });
qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" }); qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });

View file

@ -0,0 +1,39 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
try {
q = queryExecute("
SELECT TaskID, TaskTitle, TaskDetails, TaskCategoryID, TaskClaimedByUserID, TaskCompletedOn, TaskAddedOn
FROM Tasks
WHERE TaskBusinessID = 47
ORDER BY TaskID DESC
LIMIT 20
", [], { datasource: "payfrit" });
tasks = [];
for (row in q) {
arrayAppend(tasks, {
"TaskID": row.TaskID,
"Title": row.TaskTitle,
"Details": isNull(row.TaskDetails) ? "" : row.TaskDetails,
"CategoryID": row.TaskCategoryID,
"ClaimedByUserID": row.TaskClaimedByUserID,
"CompletedOn": isNull(row.TaskCompletedOn) ? "" : dateTimeFormat(row.TaskCompletedOn, "yyyy-mm-dd HH:nn:ss"),
"AddedOn": isNull(row.TaskAddedOn) ? "" : dateTimeFormat(row.TaskAddedOn, "yyyy-mm-dd HH:nn:ss")
});
}
writeOutput(serializeJSON({
"OK": true,
"COUNT": arrayLen(tasks),
"TASKS": tasks
}));
} catch (any e) {
writeOutput(serializeJSON({
"OK": false,
"ERROR": e.message
}));
}
</cfscript>

View file

@ -48,7 +48,6 @@ try {
qt.QuickTaskTemplateID, qt.QuickTaskTemplateID,
qt.QuickTaskTemplateName as Name, qt.QuickTaskTemplateName as Name,
qt.QuickTaskTemplateCategoryID as CategoryID, qt.QuickTaskTemplateCategoryID as CategoryID,
qt.QuickTaskTemplateTypeID as TypeID,
qt.QuickTaskTemplateTitle as Title, qt.QuickTaskTemplateTitle as Title,
qt.QuickTaskTemplateDetails as Details, qt.QuickTaskTemplateDetails as Details,
qt.QuickTaskTemplateIcon as Icon, qt.QuickTaskTemplateIcon as Icon,
@ -72,7 +71,6 @@ try {
"QuickTaskTemplateID": row.QuickTaskTemplateID, "QuickTaskTemplateID": row.QuickTaskTemplateID,
"Name": row.Name, "Name": row.Name,
"CategoryID": isNull(row.CategoryID) ? "" : row.CategoryID, "CategoryID": isNull(row.CategoryID) ? "" : row.CategoryID,
"TypeID": isNull(row.TypeID) ? "" : row.TypeID,
"Title": row.Title, "Title": row.Title,
"Details": isNull(row.Details) ? "" : row.Details, "Details": isNull(row.Details) ? "" : row.Details,
"Icon": isNull(row.Icon) ? "add_box" : row.Icon, "Icon": isNull(row.Icon) ? "add_box" : row.Icon,

View file

@ -0,0 +1,20 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
try {
// Delete all Quick Task templates for business 1
queryExecute("DELETE FROM QuickTaskTemplates WHERE QuickTaskTemplateBusinessID = 1", [], { datasource: "payfrit" });
writeOutput(serializeJSON({
"OK": true,
"MESSAGE": "All Quick Task templates purged"
}));
} catch (any e) {
writeOutput(serializeJSON({
"OK": false,
"ERROR": e.message
}));
}
</cfscript>

View file

@ -5,7 +5,7 @@
<cfscript> <cfscript>
// Create or update a quick task template // Create or update a quick task template
// Input: BusinessID (required), QuickTaskTemplateID (optional - for update), // Input: BusinessID (required), QuickTaskTemplateID (optional - for update),
// Name, Title, Details, CategoryID, TypeID, Icon, Color // Name, Title, Details, CategoryID, Icon, Color
// Output: { OK: true, TEMPLATE_ID: int } // Output: { OK: true, TEMPLATE_ID: int }
function apiAbort(required struct payload) { function apiAbort(required struct payload) {
@ -46,8 +46,12 @@ try {
templateName = structKeyExists(data, "Name") ? trim(toString(data.Name)) : ""; templateName = structKeyExists(data, "Name") ? trim(toString(data.Name)) : "";
templateTitle = structKeyExists(data, "Title") ? trim(toString(data.Title)) : ""; templateTitle = structKeyExists(data, "Title") ? trim(toString(data.Title)) : "";
templateDetails = structKeyExists(data, "Details") ? trim(toString(data.Details)) : ""; templateDetails = structKeyExists(data, "Details") ? trim(toString(data.Details)) : "";
categoryID = structKeyExists(data, "CategoryID") && isNumeric(data.CategoryID) && data.CategoryID > 0 ? int(data.CategoryID) : javaCast("null", ""); hasCategory = false;
typeID = structKeyExists(data, "TypeID") && isNumeric(data.TypeID) && data.TypeID > 0 ? int(data.TypeID) : javaCast("null", ""); catID = 0;
if (structKeyExists(data, "CategoryID") && isNumeric(data.CategoryID) && data.CategoryID > 0) {
catID = int(data.CategoryID);
hasCategory = true;
}
templateIcon = structKeyExists(data, "Icon") && len(trim(data.Icon)) ? trim(toString(data.Icon)) : "add_box"; templateIcon = structKeyExists(data, "Icon") && len(trim(data.Icon)) ? trim(toString(data.Icon)) : "add_box";
templateColor = structKeyExists(data, "Color") && len(trim(data.Color)) ? trim(toString(data.Color)) : "##6366f1"; templateColor = structKeyExists(data, "Color") && len(trim(data.Color)) ? trim(toString(data.Color)) : "##6366f1";
@ -58,6 +62,9 @@ try {
if (!len(templateTitle)) { if (!len(templateTitle)) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Title is required" }); apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Title is required" });
} }
if (!hasCategory) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Please select a category" });
}
if (templateID > 0) { if (templateID > 0) {
// UPDATE existing template // UPDATE existing template
@ -79,7 +86,6 @@ try {
QuickTaskTemplateTitle = :title, QuickTaskTemplateTitle = :title,
QuickTaskTemplateDetails = :details, QuickTaskTemplateDetails = :details,
QuickTaskTemplateCategoryID = :categoryID, QuickTaskTemplateCategoryID = :categoryID,
QuickTaskTemplateTypeID = :typeID,
QuickTaskTemplateIcon = :icon, QuickTaskTemplateIcon = :icon,
QuickTaskTemplateColor = :color QuickTaskTemplateColor = :color
WHERE QuickTaskTemplateID = :id WHERE QuickTaskTemplateID = :id
@ -87,8 +93,7 @@ try {
name: { value: templateName, cfsqltype: "cf_sql_varchar" }, name: { value: templateName, cfsqltype: "cf_sql_varchar" },
title: { value: templateTitle, cfsqltype: "cf_sql_varchar" }, title: { value: templateTitle, cfsqltype: "cf_sql_varchar" },
details: { value: templateDetails, cfsqltype: "cf_sql_longvarchar", null: !len(templateDetails) }, details: { value: templateDetails, cfsqltype: "cf_sql_longvarchar", null: !len(templateDetails) },
categoryID: { value: categoryID, cfsqltype: "cf_sql_integer", null: isNull(categoryID) }, categoryID: { value: catID, cfsqltype: "cf_sql_integer", null: !hasCategory },
typeID: { value: typeID, cfsqltype: "cf_sql_integer", null: isNull(typeID) },
icon: { value: templateIcon, cfsqltype: "cf_sql_varchar" }, icon: { value: templateIcon, cfsqltype: "cf_sql_varchar" },
color: { value: templateColor, cfsqltype: "cf_sql_varchar" }, color: { value: templateColor, cfsqltype: "cf_sql_varchar" },
id: { value: templateID, cfsqltype: "cf_sql_integer" } id: { value: templateID, cfsqltype: "cf_sql_integer" }
@ -115,18 +120,17 @@ try {
queryExecute(" queryExecute("
INSERT INTO QuickTaskTemplates ( INSERT INTO QuickTaskTemplates (
QuickTaskTemplateBusinessID, QuickTaskTemplateName, QuickTaskTemplateTitle, QuickTaskTemplateBusinessID, QuickTaskTemplateName, QuickTaskTemplateTitle,
QuickTaskTemplateDetails, QuickTaskTemplateCategoryID, QuickTaskTemplateTypeID, QuickTaskTemplateDetails, QuickTaskTemplateCategoryID,
QuickTaskTemplateIcon, QuickTaskTemplateColor, QuickTaskTemplateSortOrder QuickTaskTemplateIcon, QuickTaskTemplateColor, QuickTaskTemplateSortOrder
) VALUES ( ) VALUES (
:businessID, :name, :title, :details, :categoryID, :typeID, :icon, :color, :sortOrder :businessID, :name, :title, :details, :categoryID, :icon, :color, :sortOrder
) )
", { ", {
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }, businessID: { value: businessID, cfsqltype: "cf_sql_integer" },
name: { value: templateName, cfsqltype: "cf_sql_varchar" }, name: { value: templateName, cfsqltype: "cf_sql_varchar" },
title: { value: templateTitle, cfsqltype: "cf_sql_varchar" }, title: { value: templateTitle, cfsqltype: "cf_sql_varchar" },
details: { value: templateDetails, cfsqltype: "cf_sql_longvarchar", null: !len(templateDetails) }, details: { value: templateDetails, cfsqltype: "cf_sql_longvarchar", null: !len(templateDetails) },
categoryID: { value: categoryID, cfsqltype: "cf_sql_integer", null: isNull(categoryID) }, categoryID: { value: catID, cfsqltype: "cf_sql_integer", null: !hasCategory },
typeID: { value: typeID, cfsqltype: "cf_sql_integer", null: isNull(typeID) },
icon: { value: templateIcon, cfsqltype: "cf_sql_varchar" }, icon: { value: templateIcon, cfsqltype: "cf_sql_varchar" },
color: { value: templateColor, cfsqltype: "cf_sql_varchar" }, color: { value: templateColor, cfsqltype: "cf_sql_varchar" },
sortOrder: { value: nextSort, cfsqltype: "cf_sql_integer" } sortOrder: { value: nextSort, cfsqltype: "cf_sql_integer" }
@ -145,7 +149,10 @@ try {
apiAbort({ apiAbort({
"OK": false, "OK": false,
"ERROR": "server_error", "ERROR": "server_error",
"MESSAGE": e.message "MESSAGE": e.message,
"DETAIL": structKeyExists(e, "detail") ? e.detail : "none",
"TYPE": structKeyExists(e, "type") ? e.type : "none",
"TAG": (structKeyExists(e, "tagContext") && isArray(e.tagContext) && arrayLen(e.tagContext) > 0) ? serializeJSON(e.tagContext[1]) : "none"
}); });
} }
</cfscript> </cfscript>

View file

@ -48,10 +48,11 @@ try {
st.ScheduledTaskID, st.ScheduledTaskID,
st.ScheduledTaskName as Name, st.ScheduledTaskName as Name,
st.ScheduledTaskCategoryID as CategoryID, st.ScheduledTaskCategoryID as CategoryID,
st.ScheduledTaskTypeID as TypeID,
st.ScheduledTaskTitle as Title, st.ScheduledTaskTitle as Title,
st.ScheduledTaskDetails as Details, st.ScheduledTaskDetails as Details,
st.ScheduledTaskCronExpression as CronExpression, st.ScheduledTaskCronExpression as CronExpression,
COALESCE(st.ScheduledTaskScheduleType, 'cron') as ScheduleType,
st.ScheduledTaskIntervalMinutes as IntervalMinutes,
st.ScheduledTaskIsActive as IsActive, st.ScheduledTaskIsActive as IsActive,
st.ScheduledTaskLastRunOn as LastRunOn, st.ScheduledTaskLastRunOn as LastRunOn,
st.ScheduledTaskNextRunOn as NextRunOn, st.ScheduledTaskNextRunOn as NextRunOn,
@ -72,10 +73,11 @@ try {
"ScheduledTaskID": row.ScheduledTaskID, "ScheduledTaskID": row.ScheduledTaskID,
"Name": row.Name, "Name": row.Name,
"CategoryID": isNull(row.CategoryID) ? "" : row.CategoryID, "CategoryID": isNull(row.CategoryID) ? "" : row.CategoryID,
"TypeID": isNull(row.TypeID) ? "" : row.TypeID,
"Title": row.Title, "Title": row.Title,
"Details": isNull(row.Details) ? "" : row.Details, "Details": isNull(row.Details) ? "" : row.Details,
"CronExpression": row.CronExpression, "CronExpression": row.CronExpression,
"ScheduleType": row.ScheduleType,
"IntervalMinutes": isNull(row.IntervalMinutes) ? "" : row.IntervalMinutes,
"IsActive": row.IsActive ? true : false, "IsActive": row.IsActive ? true : false,
"LastRunOn": isNull(row.LastRunOn) ? "" : dateTimeFormat(row.LastRunOn, "yyyy-mm-dd HH:nn:ss"), "LastRunOn": isNull(row.LastRunOn) ? "" : dateTimeFormat(row.LastRunOn, "yyyy-mm-dd HH:nn:ss"),
"NextRunOn": isNull(row.NextRunOn) ? "" : dateTimeFormat(row.NextRunOn, "yyyy-mm-dd HH:nn:ss"), "NextRunOn": isNull(row.NextRunOn) ? "" : dateTimeFormat(row.NextRunOn, "yyyy-mm-dd HH:nn:ss"),

View file

@ -53,8 +53,7 @@ try {
SELECT SELECT
ScheduledTaskTitle as Title, ScheduledTaskTitle as Title,
ScheduledTaskDetails as Details, ScheduledTaskDetails as Details,
ScheduledTaskCategoryID as CategoryID, ScheduledTaskCategoryID as CategoryID
ScheduledTaskTypeID as TypeID
FROM ScheduledTaskDefinitions FROM ScheduledTaskDefinitions
WHERE ScheduledTaskID = :id AND ScheduledTaskBusinessID = :businessID WHERE ScheduledTaskID = :id AND ScheduledTaskBusinessID = :businessID
", { ", {
@ -66,24 +65,21 @@ try {
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Scheduled task not found" }); apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Scheduled task not found" });
} }
// Create the task // Create the task (TaskClaimedByUserID=0 means unclaimed/pending)
queryExecute(" queryExecute("
INSERT INTO Tasks ( INSERT INTO Tasks (
TaskBusinessID, TaskCategoryID, TaskTypeID, TaskBusinessID, TaskCategoryID, TaskTypeID,
TaskTitle, TaskDetails, TaskStatusID, TaskAddedOn, TaskTitle, TaskDetails, TaskAddedOn, TaskClaimedByUserID
TaskSourceType, TaskSourceID
) VALUES ( ) VALUES (
:businessID, :categoryID, :typeID, :businessID, :categoryID, :typeID,
:title, :details, 0, NOW(), :title, :details, NOW(), 0
'scheduled_manual', :scheduledTaskID
) )
", { ", {
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }, businessID: { value: businessID, cfsqltype: "cf_sql_integer" },
categoryID: { value: qDef.CategoryID, cfsqltype: "cf_sql_integer", null: isNull(qDef.CategoryID) }, categoryID: { value: qDef.CategoryID, cfsqltype: "cf_sql_integer", null: isNull(qDef.CategoryID) },
typeID: { value: qDef.TypeID, cfsqltype: "cf_sql_integer", null: isNull(qDef.TypeID) }, typeID: { value: 0, cfsqltype: "cf_sql_integer" },
title: { value: qDef.Title, cfsqltype: "cf_sql_varchar" }, title: { value: qDef.Title, cfsqltype: "cf_sql_varchar" },
details: { value: qDef.Details, cfsqltype: "cf_sql_longvarchar", null: isNull(qDef.Details) }, details: { value: qDef.Details, cfsqltype: "cf_sql_longvarchar", null: isNull(qDef.Details) }
scheduledTaskID: { value: scheduledTaskID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" }); }, { datasource: "payfrit" });
qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" }); qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });

View file

@ -70,10 +70,11 @@ try {
ScheduledTaskID, ScheduledTaskID,
ScheduledTaskBusinessID as BusinessID, ScheduledTaskBusinessID as BusinessID,
ScheduledTaskCategoryID as CategoryID, ScheduledTaskCategoryID as CategoryID,
ScheduledTaskTypeID as TypeID,
ScheduledTaskTitle as Title, ScheduledTaskTitle as Title,
ScheduledTaskDetails as Details, ScheduledTaskDetails as Details,
ScheduledTaskCronExpression as CronExpression ScheduledTaskCronExpression as CronExpression,
COALESCE(ScheduledTaskScheduleType, 'cron') as ScheduleType,
ScheduledTaskIntervalMinutes as IntervalMinutes
FROM ScheduledTaskDefinitions FROM ScheduledTaskDefinitions
WHERE ScheduledTaskIsActive = 1 WHERE ScheduledTaskIsActive = 1
AND ScheduledTaskNextRunOn <= NOW() AND ScheduledTaskNextRunOn <= NOW()
@ -82,30 +83,37 @@ try {
createdTasks = []; createdTasks = [];
for (task in dueTasks) { for (task in dueTasks) {
// Create the actual task // Create the actual task (TaskClaimedByUserID=0 means unclaimed/pending)
queryExecute(" queryExecute("
INSERT INTO Tasks ( INSERT INTO Tasks (
TaskBusinessID, TaskCategoryID, TaskTypeID, TaskBusinessID, TaskCategoryID, TaskTypeID,
TaskTitle, TaskDetails, TaskStatusID, TaskAddedOn, TaskTitle, TaskDetails, TaskAddedOn, TaskClaimedByUserID
TaskSourceType, TaskSourceID
) VALUES ( ) VALUES (
:businessID, :categoryID, :typeID, :businessID, :categoryID, :typeID,
:title, :details, 0, NOW(), :title, :details, NOW(), 0
'scheduled', :scheduledTaskID
) )
", { ", {
businessID: { value: task.BusinessID, cfsqltype: "cf_sql_integer" }, businessID: { value: task.BusinessID, cfsqltype: "cf_sql_integer" },
categoryID: { value: task.CategoryID, cfsqltype: "cf_sql_integer", null: isNull(task.CategoryID) }, categoryID: { value: task.CategoryID, cfsqltype: "cf_sql_integer", null: isNull(task.CategoryID) },
typeID: { value: task.TypeID, cfsqltype: "cf_sql_integer", null: isNull(task.TypeID) }, typeID: { value: 0, cfsqltype: "cf_sql_integer" },
title: { value: task.Title, cfsqltype: "cf_sql_varchar" }, title: { value: task.Title, cfsqltype: "cf_sql_varchar" },
details: { value: task.Details, cfsqltype: "cf_sql_longvarchar", null: isNull(task.Details) }, details: { value: task.Details, cfsqltype: "cf_sql_longvarchar", null: isNull(task.Details) }
scheduledTaskID: { value: task.ScheduledTaskID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" }); }, { datasource: "payfrit" });
qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" }); qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
// Calculate next run and update the scheduled task // Calculate next run based on schedule type
if (task.ScheduleType == "interval_after_completion" && !isNull(task.IntervalMinutes) && task.IntervalMinutes > 0) {
// After-completion interval: don't schedule next run until task is completed
// Set to far future (effectively paused until task completion triggers recalculation)
nextRun = javaCast("null", "");
} else if (task.ScheduleType == "interval" && !isNull(task.IntervalMinutes) && task.IntervalMinutes > 0) {
// Fixed interval: next run = NOW + interval minutes
nextRun = dateAdd("n", task.IntervalMinutes, now());
} else {
// Cron-based: use cron parser
nextRun = calculateNextRun(task.CronExpression); nextRun = calculateNextRun(task.CronExpression);
}
queryExecute(" queryExecute("
UPDATE ScheduledTaskDefinitions SET UPDATE ScheduledTaskDefinitions SET
@ -113,7 +121,7 @@ try {
ScheduledTaskNextRunOn = :nextRun ScheduledTaskNextRunOn = :nextRun
WHERE ScheduledTaskID = :id WHERE ScheduledTaskID = :id
", { ", {
nextRun: { value: nextRun, cfsqltype: "cf_sql_timestamp" }, nextRun: { value: nextRun, cfsqltype: "cf_sql_timestamp", null: isNull(nextRun) },
id: { value: task.ScheduledTaskID, cfsqltype: "cf_sql_integer" } id: { value: task.ScheduledTaskID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" }); }, { datasource: "payfrit" });

View file

@ -5,7 +5,8 @@
<cfscript> <cfscript>
// Create or update a scheduled task definition // Create or update a scheduled task definition
// Input: BusinessID (required), ScheduledTaskID (optional - for update), // Input: BusinessID (required), ScheduledTaskID (optional - for update),
// Name, Title, Details, CategoryID, TypeID, CronExpression, IsActive // Name, Title, Details, CategoryID, TypeID, CronExpression, IsActive,
// ScheduleType ('cron' or 'interval'), IntervalMinutes (for interval type)
// Output: { OK: true, SCHEDULED_TASK_ID: int } // Output: { OK: true, SCHEDULED_TASK_ID: int }
function apiAbort(required struct payload) { function apiAbort(required struct payload) {
@ -108,10 +109,13 @@ try {
taskTitle = structKeyExists(data, "Title") ? trim(toString(data.Title)) : ""; taskTitle = structKeyExists(data, "Title") ? trim(toString(data.Title)) : "";
taskDetails = structKeyExists(data, "Details") ? trim(toString(data.Details)) : ""; taskDetails = structKeyExists(data, "Details") ? trim(toString(data.Details)) : "";
categoryID = structKeyExists(data, "CategoryID") && isNumeric(data.CategoryID) && data.CategoryID > 0 ? int(data.CategoryID) : javaCast("null", ""); categoryID = structKeyExists(data, "CategoryID") && isNumeric(data.CategoryID) && data.CategoryID > 0 ? int(data.CategoryID) : javaCast("null", "");
typeID = structKeyExists(data, "TypeID") && isNumeric(data.TypeID) && data.TypeID > 0 ? int(data.TypeID) : javaCast("null", "");
cronExpression = structKeyExists(data, "CronExpression") ? trim(toString(data.CronExpression)) : ""; cronExpression = structKeyExists(data, "CronExpression") ? trim(toString(data.CronExpression)) : "";
isActive = structKeyExists(data, "IsActive") ? (data.IsActive ? 1 : 0) : 1; isActive = structKeyExists(data, "IsActive") ? (data.IsActive ? 1 : 0) : 1;
// New interval scheduling fields
scheduleType = structKeyExists(data, "ScheduleType") ? trim(toString(data.ScheduleType)) : "cron";
intervalMinutes = structKeyExists(data, "IntervalMinutes") && isNumeric(data.IntervalMinutes) ? int(data.IntervalMinutes) : javaCast("null", "");
// Validate required fields // Validate required fields
if (!len(taskName)) { if (!len(taskName)) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Name is required" }); apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Name is required" });
@ -119,18 +123,36 @@ try {
if (!len(taskTitle)) { if (!len(taskTitle)) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Title is required" }); apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Title is required" });
} }
// Validate based on schedule type
if (scheduleType == "interval" || scheduleType == "interval_after_completion") {
// Interval-based scheduling
if (isNull(intervalMinutes) || intervalMinutes < 1) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "IntervalMinutes is required for interval scheduling (minimum 1)" });
}
// Set a placeholder cron expression for interval type
if (!len(cronExpression)) {
cronExpression = "* * * * *";
}
// For NEW tasks: run immediately. For UPDATES: next run = NOW + interval
if (taskID == 0) {
nextRunOn = now(); // Run immediately on first creation
} else {
nextRunOn = dateAdd("n", intervalMinutes, now());
}
} else {
// Cron-based scheduling
if (!len(cronExpression)) { if (!len(cronExpression)) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "CronExpression is required" }); apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "CronExpression is required" });
} }
// Validate cron format (5 parts) // Validate cron format (5 parts)
cronParts = listToArray(cronExpression, " "); cronParts = listToArray(cronExpression, " ");
if (arrayLen(cronParts) != 5) { if (arrayLen(cronParts) != 5) {
apiAbort({ "OK": false, "ERROR": "invalid_cron", "MESSAGE": "Cron expression must have 5 parts: minute hour day month weekday" }); apiAbort({ "OK": false, "ERROR": "invalid_cron", "MESSAGE": "Cron expression must have 5 parts: minute hour day month weekday" });
} }
// Calculate next run time from cron
// Calculate next run time
nextRunOn = calculateNextRun(cronExpression); nextRunOn = calculateNextRun(cronExpression);
}
if (taskID > 0) { if (taskID > 0) {
// UPDATE existing // UPDATE existing
@ -152,8 +174,9 @@ try {
ScheduledTaskTitle = :title, ScheduledTaskTitle = :title,
ScheduledTaskDetails = :details, ScheduledTaskDetails = :details,
ScheduledTaskCategoryID = :categoryID, ScheduledTaskCategoryID = :categoryID,
ScheduledTaskTypeID = :typeID,
ScheduledTaskCronExpression = :cron, ScheduledTaskCronExpression = :cron,
ScheduledTaskScheduleType = :scheduleType,
ScheduledTaskIntervalMinutes = :intervalMinutes,
ScheduledTaskIsActive = :isActive, ScheduledTaskIsActive = :isActive,
ScheduledTaskNextRunOn = :nextRun ScheduledTaskNextRunOn = :nextRun
WHERE ScheduledTaskID = :id WHERE ScheduledTaskID = :id
@ -162,8 +185,9 @@ try {
title: { value: taskTitle, cfsqltype: "cf_sql_varchar" }, title: { value: taskTitle, cfsqltype: "cf_sql_varchar" },
details: { value: taskDetails, cfsqltype: "cf_sql_longvarchar", null: !len(taskDetails) }, details: { value: taskDetails, cfsqltype: "cf_sql_longvarchar", null: !len(taskDetails) },
categoryID: { value: categoryID, cfsqltype: "cf_sql_integer", null: isNull(categoryID) }, categoryID: { value: categoryID, cfsqltype: "cf_sql_integer", null: isNull(categoryID) },
typeID: { value: typeID, cfsqltype: "cf_sql_integer", null: isNull(typeID) },
cron: { value: cronExpression, cfsqltype: "cf_sql_varchar" }, cron: { value: cronExpression, cfsqltype: "cf_sql_varchar" },
scheduleType: { value: scheduleType, cfsqltype: "cf_sql_varchar" },
intervalMinutes: { value: intervalMinutes, cfsqltype: "cf_sql_integer", null: isNull(intervalMinutes) },
isActive: { value: isActive, cfsqltype: "cf_sql_bit" }, isActive: { value: isActive, cfsqltype: "cf_sql_bit" },
nextRun: { value: nextRunOn, cfsqltype: "cf_sql_timestamp" }, nextRun: { value: nextRunOn, cfsqltype: "cf_sql_timestamp" },
id: { value: taskID, cfsqltype: "cf_sql_integer" } id: { value: taskID, cfsqltype: "cf_sql_integer" }
@ -181,10 +205,11 @@ try {
queryExecute(" queryExecute("
INSERT INTO ScheduledTaskDefinitions ( INSERT INTO ScheduledTaskDefinitions (
ScheduledTaskBusinessID, ScheduledTaskName, ScheduledTaskTitle, ScheduledTaskBusinessID, ScheduledTaskName, ScheduledTaskTitle,
ScheduledTaskDetails, ScheduledTaskCategoryID, ScheduledTaskTypeID, ScheduledTaskDetails, ScheduledTaskCategoryID,
ScheduledTaskCronExpression, ScheduledTaskIsActive, ScheduledTaskNextRunOn ScheduledTaskCronExpression, ScheduledTaskScheduleType, ScheduledTaskIntervalMinutes,
ScheduledTaskIsActive, ScheduledTaskNextRunOn
) VALUES ( ) VALUES (
:businessID, :name, :title, :details, :categoryID, :typeID, :cron, :isActive, :nextRun :businessID, :name, :title, :details, :categoryID, :cron, :scheduleType, :intervalMinutes, :isActive, :nextRun
) )
", { ", {
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }, businessID: { value: businessID, cfsqltype: "cf_sql_integer" },
@ -192,19 +217,65 @@ try {
title: { value: taskTitle, cfsqltype: "cf_sql_varchar" }, title: { value: taskTitle, cfsqltype: "cf_sql_varchar" },
details: { value: taskDetails, cfsqltype: "cf_sql_longvarchar", null: !len(taskDetails) }, details: { value: taskDetails, cfsqltype: "cf_sql_longvarchar", null: !len(taskDetails) },
categoryID: { value: categoryID, cfsqltype: "cf_sql_integer", null: isNull(categoryID) }, categoryID: { value: categoryID, cfsqltype: "cf_sql_integer", null: isNull(categoryID) },
typeID: { value: typeID, cfsqltype: "cf_sql_integer", null: isNull(typeID) },
cron: { value: cronExpression, cfsqltype: "cf_sql_varchar" }, cron: { value: cronExpression, cfsqltype: "cf_sql_varchar" },
scheduleType: { value: scheduleType, cfsqltype: "cf_sql_varchar" },
intervalMinutes: { value: intervalMinutes, cfsqltype: "cf_sql_integer", null: isNull(intervalMinutes) },
isActive: { value: isActive, cfsqltype: "cf_sql_bit" }, isActive: { value: isActive, cfsqltype: "cf_sql_bit" },
nextRun: { value: nextRunOn, cfsqltype: "cf_sql_timestamp" } nextRun: { value: nextRunOn, cfsqltype: "cf_sql_timestamp" }
}, { datasource: "payfrit" }); }, { datasource: "payfrit" });
qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" }); qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
newScheduledTaskID = qNew.newID;
// Create the first task immediately
queryExecute("
INSERT INTO Tasks (
TaskBusinessID, TaskCategoryID, TaskTypeID,
TaskTitle, TaskDetails, TaskAddedOn, TaskClaimedByUserID
) VALUES (
:businessID, :categoryID, :typeID,
:title, :details, NOW(), 0
)
", {
businessID: { value: businessID, cfsqltype: "cf_sql_integer" },
categoryID: { value: categoryID, cfsqltype: "cf_sql_integer", null: isNull(categoryID) },
typeID: { value: 0, cfsqltype: "cf_sql_integer" },
title: { value: taskTitle, cfsqltype: "cf_sql_varchar" },
details: { value: taskDetails, cfsqltype: "cf_sql_longvarchar", null: !len(taskDetails) }
}, { datasource: "payfrit" });
qTask = queryExecute("SELECT LAST_INSERT_ID() as taskID", [], { datasource: "payfrit" });
// Now set the NEXT run time (not the immediate one we just created)
if (scheduleType == "interval" || scheduleType == "interval_after_completion") {
if (scheduleType == "interval_after_completion") {
// After-completion: don't schedule next until task is completed
actualNextRun = javaCast("null", "");
} else {
// Fixed interval: next run = NOW + interval
actualNextRun = dateAdd("n", intervalMinutes, now());
}
} else {
// Cron-based
actualNextRun = calculateNextRun(cronExpression);
}
queryExecute("
UPDATE ScheduledTaskDefinitions
SET ScheduledTaskLastRunOn = NOW(),
ScheduledTaskNextRunOn = :nextRun
WHERE ScheduledTaskID = :id
", {
nextRun: { value: actualNextRun, cfsqltype: "cf_sql_timestamp", null: isNull(actualNextRun) },
id: { value: newScheduledTaskID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
apiAbort({ apiAbort({
"OK": true, "OK": true,
"SCHEDULED_TASK_ID": qNew.newID, "SCHEDULED_TASK_ID": newScheduledTaskID,
"NEXT_RUN": dateTimeFormat(nextRunOn, "yyyy-mm-dd HH:nn:ss"), "TASK_ID": qTask.taskID,
"MESSAGE": "Scheduled task created" "NEXT_RUN": isNull(actualNextRun) ? "" : dateTimeFormat(actualNextRun, "yyyy-mm-dd HH:nn:ss"),
"MESSAGE": "Scheduled task created and first task added"
}); });
} }

View file

@ -23,6 +23,8 @@ try {
ScheduledTaskTitle VARCHAR(255) NOT NULL, ScheduledTaskTitle VARCHAR(255) NOT NULL,
ScheduledTaskDetails TEXT NULL, ScheduledTaskDetails TEXT NULL,
ScheduledTaskCronExpression VARCHAR(100) NOT NULL, ScheduledTaskCronExpression VARCHAR(100) NOT NULL,
ScheduledTaskScheduleType VARCHAR(20) DEFAULT 'cron',
ScheduledTaskIntervalMinutes INT NULL,
ScheduledTaskIsActive BIT(1) DEFAULT b'1', ScheduledTaskIsActive BIT(1) DEFAULT b'1',
ScheduledTaskLastRunOn DATETIME NULL, ScheduledTaskLastRunOn DATETIME NULL,
ScheduledTaskNextRunOn DATETIME NULL, ScheduledTaskNextRunOn DATETIME NULL,
@ -33,9 +35,28 @@ try {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
", [], { datasource: "payfrit" }); ", [], { datasource: "payfrit" });
// Add new columns if they don't exist (for existing tables)
try {
queryExecute("
ALTER TABLE ScheduledTaskDefinitions
ADD COLUMN ScheduledTaskScheduleType VARCHAR(20) DEFAULT 'cron' AFTER ScheduledTaskCronExpression
", [], { datasource: "payfrit" });
} catch (any e) {
// Column likely already exists
}
try {
queryExecute("
ALTER TABLE ScheduledTaskDefinitions
ADD COLUMN ScheduledTaskIntervalMinutes INT NULL AFTER ScheduledTaskScheduleType
", [], { datasource: "payfrit" });
} catch (any e) {
// Column likely already exists
}
apiAbort({ apiAbort({
"OK": true, "OK": true,
"MESSAGE": "ScheduledTaskDefinitions table created/verified" "MESSAGE": "ScheduledTaskDefinitions table created/verified with interval support"
}); });
} catch (any e) { } catch (any e) {

View file

@ -97,9 +97,11 @@ try {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "ScheduledTaskID is required" }); apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "ScheduledTaskID is required" });
} }
// Verify exists and get cron expression // Verify exists and get cron expression and schedule type
qCheck = queryExecute(" qCheck = queryExecute("
SELECT ScheduledTaskID, ScheduledTaskCronExpression as CronExpression SELECT ScheduledTaskID, ScheduledTaskCronExpression as CronExpression,
COALESCE(ScheduledTaskScheduleType, 'cron') as ScheduleType,
ScheduledTaskIntervalMinutes as IntervalMinutes
FROM ScheduledTaskDefinitions FROM ScheduledTaskDefinitions
WHERE ScheduledTaskID = :id AND ScheduledTaskBusinessID = :businessID WHERE ScheduledTaskID = :id AND ScheduledTaskBusinessID = :businessID
", { ", {
@ -111,10 +113,16 @@ try {
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Scheduled task not found" }); apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Scheduled task not found" });
} }
// If enabling, recalculate next run time // If enabling, recalculate next run time based on schedule type
nextRunUpdate = ""; nextRunUpdate = "";
if (isActive) { if (isActive) {
if ((qCheck.ScheduleType == "interval" || qCheck.ScheduleType == "interval_after_completion") && !isNull(qCheck.IntervalMinutes) && qCheck.IntervalMinutes > 0) {
// Interval-based: next run = NOW + interval minutes
nextRunOn = dateAdd("n", qCheck.IntervalMinutes, now());
} else {
// Cron-based: use cron parser
nextRunOn = calculateNextRun(qCheck.CronExpression); nextRunOn = calculateNextRun(qCheck.CronExpression);
}
nextRunUpdate = ", ScheduledTaskNextRunOn = :nextRun"; nextRunUpdate = ", ScheduledTaskNextRunOn = :nextRun";
} }

View file

@ -42,7 +42,8 @@ try {
BusinessStripeOnboardingComplete, BusinessStripeOnboardingComplete,
BusinessIsHiring, BusinessIsHiring,
BusinessHeaderImageExtension, BusinessHeaderImageExtension,
BusinessTaxRate BusinessTaxRate,
BusinessBrandColor
FROM Businesses FROM Businesses
WHERE BusinessID = :businessID WHERE BusinessID = :businessID
", { businessID: businessID }, { datasource: "payfrit" }); ", { businessID: businessID }, { datasource: "payfrit" });
@ -139,7 +140,8 @@ try {
"StripeConnected": (len(q.BusinessStripeAccountID) > 0 && q.BusinessStripeOnboardingComplete == 1), "StripeConnected": (len(q.BusinessStripeAccountID) > 0 && q.BusinessStripeOnboardingComplete == 1),
"IsHiring": q.BusinessIsHiring == 1, "IsHiring": q.BusinessIsHiring == 1,
"TaxRate": taxRate, "TaxRate": taxRate,
"TaxRatePercent": taxRate * 100 "TaxRatePercent": taxRate * 100,
"BrandColor": len(q.BusinessBrandColor) ? q.BusinessBrandColor : ""
}; };
// Add header image URL if extension exists // Add header image URL if extension exists

View file

@ -39,11 +39,16 @@ brandColor = structKeyExists(data, "BrandColor") && isSimpleValue(data.BrandColo
// Allow empty to clear, or validate hex format // Allow empty to clear, or validate hex format
if (len(brandColor) GT 0) { if (len(brandColor) GT 0) {
// Must start with # and have 6 hex chars // Strip leading # if present
if (left(brandColor, 1) != chr(35) || len(brandColor) != 7 || !reFind("^[0-9A-Fa-f]{6}$", right(brandColor, 6))) { if (left(brandColor, 1) == chr(35)) {
apiAbort({ "OK": false, "ERROR": "invalid_color", "MESSAGE": "Color must be in ##RRGGBB format" }); brandColor = right(brandColor, len(brandColor) - 1);
} }
brandColor = uCase(brandColor); // Must be exactly 6 hex chars
if (len(brandColor) != 6 || !reFind("^[0-9A-Fa-f]{6}$", brandColor)) {
apiAbort({ "OK": false, "ERROR": "invalid_color", "MESSAGE": "Color must be a valid 6-digit hex color (e.g. 1B4D3E or ##1B4D3E)" });
}
// Store with # prefix, uppercase
brandColor = chr(35) & uCase(brandColor);
} }
// Update the database // Update the database

View file

@ -11,7 +11,7 @@
*/ */
// Mode: "test" or "live" // Mode: "test" or "live"
stripeMode = "live"; stripeMode = "test";
// Test keys (safe to commit) // Test keys (safe to commit)
stripeTestSecretKey = "sk_test_LfbmDduJxTwbVZmvcByYmirw"; stripeTestSecretKey = "sk_test_LfbmDduJxTwbVZmvcByYmirw";

View file

@ -346,13 +346,23 @@ try {
}); });
} }
// Check for existing item photo
itemImageUrl = "";
itemsDir = expandPath("/uploads/items");
for (ext in ["jpg","jpeg","png","gif","webp","JPG","JPEG","PNG","GIF","WEBP"]) {
if (fileExists(itemsDir & "/" & qItems.ItemID[i] & "." & ext)) {
itemImageUrl = "/uploads/items/" & qItems.ItemID[i] & "." & ext;
break;
}
}
arrayAppend(itemsByCategory[catID], { arrayAppend(itemsByCategory[catID], {
"id": "item_" & qItems.ItemID[i], "id": "item_" & qItems.ItemID[i],
"dbId": qItems.ItemID[i], "dbId": qItems.ItemID[i],
"name": qItems.ItemName[i], "name": qItems.ItemName[i],
"description": isNull(qItems.ItemDescription[i]) ? "" : qItems.ItemDescription[i], "description": isNull(qItems.ItemDescription[i]) ? "" : qItems.ItemDescription[i],
"price": qItems.ItemPrice[i], "price": qItems.ItemPrice[i],
"imageUrl": javaCast("null", ""), "imageUrl": len(itemImageUrl) ? itemImageUrl : javaCast("null", ""),
"photoTaskId": javaCast("null", ""), "photoTaskId": javaCast("null", ""),
"modifiers": itemModifiers, "modifiers": itemModifiers,
"sortOrder": qItems.ItemSortOrder[i] "sortOrder": qItems.ItemSortOrder[i]

View file

@ -0,0 +1,68 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cftry>
<cfset itemsDir = expandPath("/uploads/items")>
<cfscript>
function apiAbort(payload) {
writeOutput(serializeJSON(payload));
abort;
}
// Get ItemID from form
itemId = 0;
if (structKeyExists(form, "ItemID") && isNumeric(form.ItemID) && form.ItemID GT 0) {
itemId = int(form.ItemID);
}
if (itemId LTE 0) {
apiAbort({ "OK": false, "ERROR": "missing_itemid", "MESSAGE": "ItemID is required" });
}
</cfscript>
<!--- Check if file was uploaded --->
<cfif NOT structKeyExists(form, "photo") OR form.photo EQ "">
<cfoutput>#serializeJSON({ "OK": false, "ERROR": "no_file", "MESSAGE": "No file was uploaded" })#</cfoutput>
<cfabort>
</cfif>
<!--- Upload the file to temp location first --->
<cffile action="UPLOAD" filefield="photo" destination="#itemsDir#/" nameconflict="MAKEUNIQUE" mode="755" result="uploadResult">
<!--- Validate file type --->
<cfset allowedExtensions = "jpg,jpeg,gif,png,webp">
<cfif NOT listFindNoCase(allowedExtensions, uploadResult.ClientFileExt)>
<cffile action="DELETE" file="#itemsDir#/#uploadResult.ServerFile#">
<cfoutput>#serializeJSON({ "OK": false, "ERROR": "invalid_type", "MESSAGE": "Only image files are accepted (jpg, jpeg, gif, png, webp)" })#</cfoutput>
<cfabort>
</cfif>
<!--- Delete old photo if exists (any extension) --->
<cfscript>
for (ext in listToArray(allowedExtensions)) {
oldFile = "#itemsDir#/#itemId#.#ext#";
if (fileExists(oldFile)) {
try { fileDelete(oldFile); } catch (any e) {}
}
}
</cfscript>
<!--- Rename to ItemID.ext --->
<cffile action="RENAME" source="#itemsDir#/#uploadResult.ServerFile#" destination="#itemsDir#/#itemId#.#uploadResult.ClientFileExt#" mode="755">
<!--- Return success with image URL --->
<cfoutput>#serializeJSON({
"OK": true,
"ERROR": "",
"MESSAGE": "Photo uploaded successfully",
"IMAGEURL": "/uploads/items/#itemId#.#uploadResult.ClientFileExt#"
})#</cfoutput>
<cfcatch type="any">
<cfheader statuscode="200" statustext="OK">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfoutput>#serializeJSON({ "OK": false, "ERROR": "server_error", "MESSAGE": cfcatch.message, "DETAIL": cfcatch.detail })#</cfoutput>
</cfcatch>
</cftry>

View file

@ -100,19 +100,18 @@ try {
lineItems = []; lineItems = [];
itemsById = {}; itemsById = {};
// First pass: create all items // First pass: create all items (use bracket notation to preserve key casing)
for (row in qItems) { for (row in qItems) {
item = { item = structNew("ordered");
"LineItemID": val(row.OrderLineItemID), item["LineItemID"] = val(row.OrderLineItemID);
"ItemID": val(row.OrderLineItemItemID), item["ItemID"] = val(row.OrderLineItemItemID);
"ParentLineItemID": val(row.OrderLineItemParentOrderLineItemID), item["ParentLineItemID"] = val(row.OrderLineItemParentOrderLineItemID);
"ItemName": row.ItemName ?: "", item["ItemName"] = row.ItemName ?: "";
"Quantity": val(row.OrderLineItemQuantity), item["Quantity"] = val(row.OrderLineItemQuantity);
"UnitPrice": val(row.OrderLineItemPrice), item["UnitPrice"] = val(row.OrderLineItemPrice);
"Remarks": row.OrderLineItemRemark ?: "", item["Remarks"] = row.OrderLineItemRemark ?: "";
"IsDefault": (val(row.ItemIsCheckedByDefault) == 1), item["IsDefault"] = (val(row.ItemIsCheckedByDefault) == 1);
"Modifiers": [] item["Modifiers"] = [];
};
itemsById[row.OrderLineItemID] = item; itemsById[row.OrderLineItemID] = item;
} }
@ -122,21 +121,21 @@ try {
parentID = row.OrderLineItemParentOrderLineItemID; parentID = row.OrderLineItemParentOrderLineItemID;
if (parentID > 0 && structKeyExists(itemsById, parentID)) { if (parentID > 0 && structKeyExists(itemsById, parentID)) {
// This is a modifier - add to parent // This is a modifier - add to parent (use bracket notation)
arrayAppend(itemsById[parentID].Modifiers, item); arrayAppend(itemsById[parentID]["Modifiers"], item);
} else { } else {
// This is a top-level item // This is a top-level item
arrayAppend(lineItems, item); arrayAppend(lineItems, item);
} }
} }
// Calculate subtotal from root line items // Calculate subtotal from root line items (use bracket notation)
subtotal = 0; subtotal = 0;
for (item in lineItems) { for (item in lineItems) {
itemTotal = item.UnitPrice * item.Quantity; itemTotal = item["UnitPrice"] * item["Quantity"];
// Add modifier prices // Add modifier prices
for (mod in item.Modifiers) { for (mod in item["Modifiers"]) {
itemTotal += mod.UnitPrice * mod.Quantity; itemTotal += mod["UnitPrice"] * mod["Quantity"];
} }
subtotal += itemTotal; subtotal += itemTotal;
} }
@ -151,57 +150,68 @@ try {
// Calculate total // Calculate total
total = subtotal + tax + tip; total = subtotal + tax + tip;
// Get staff who worked on this order (from Tasks table) // Get staff who worked on this order (from Tasks table) with pending rating tokens
qStaff = queryExecute(" qStaff = queryExecute("
SELECT DISTINCT u.UserID, u.UserFirstName SELECT DISTINCT u.UserID, u.UserFirstName,
(SELECT r.TaskRatingAccessToken
FROM TaskRatings r
INNER JOIN Tasks t2 ON t2.TaskID = r.TaskRatingTaskID
WHERE t2.TaskOrderID = :orderID
AND r.TaskRatingForUserID = u.UserID
AND r.TaskRatingDirection = 'customer_rates_worker'
AND r.TaskRatingCompletedOn IS NULL
AND r.TaskRatingExpiresOn > NOW()
LIMIT 1) AS RatingToken
FROM Tasks t FROM Tasks t
INNER JOIN Users u ON u.UserID = t.TaskClaimedByUserID INNER JOIN Users u ON u.UserID = t.TaskClaimedByUserID
WHERE t.TaskOrderID = :orderID WHERE t.TaskOrderID = :orderID
AND t.TaskClaimedByUserID > 0 AND t.TaskClaimedByUserID > 0
", { orderID: orderID }); ", { orderID: orderID });
// Build staff array with avatar URLs // Build staff array with avatar URLs and rating tokens (use ordered structs)
staff = []; staff = [];
for (row in qStaff) { for (row in qStaff) {
arrayAppend(staff, { staffMember = structNew("ordered");
"UserID": row.UserID, staffMember["UserID"] = row.UserID;
"FirstName": row.UserFirstName, staffMember["FirstName"] = row.UserFirstName;
"AvatarUrl": "https://biz.payfrit.com/uploads/users/" & row.UserID & ".jpg" staffMember["AvatarUrl"] = "https://biz.payfrit.com/uploads/users/" & row.UserID & ".jpg";
}); staffMember["RatingToken"] = row.RatingToken ?: "";
arrayAppend(staff, staffMember);
} }
// Build response // Build response (use ordered structs to preserve key casing)
order = { customer = structNew("ordered");
"OrderID": qOrder.OrderID, customer["UserID"] = qOrder.OrderUserID;
"BusinessID": qOrder.OrderBusinessID, customer["FirstName"] = qOrder.UserFirstName;
"BusinessName": qOrder.BusinessName ?: "", customer["LastName"] = qOrder.UserLastName;
"Status": qOrder.OrderStatusID, customer["Phone"] = qOrder.UserContactNumber;
"StatusText": getStatusText(qOrder.OrderStatusID), customer["Email"] = qOrder.UserEmailAddress;
"OrderTypeID": qOrder.OrderTypeID ?: 0,
"OrderTypeName": getOrderTypeName(qOrder.OrderTypeID ?: 0), servicePoint = structNew("ordered");
"Subtotal": subtotal, servicePoint["ServicePointID"] = qOrder.OrderServicePointID;
"Tax": tax, servicePoint["Name"] = qOrder.ServicePointName;
"Tip": tip, servicePoint["TypeID"] = qOrder.ServicePointTypeID;
"Total": total,
"Notes": qOrder.OrderRemarks, order = structNew("ordered");
"CreatedOn": dateTimeFormat(qOrder.OrderAddedOn, "yyyy-mm-dd HH:nn:ss"), order["OrderID"] = qOrder.OrderID;
"SubmittedOn": len(qOrder.OrderSubmittedOn) ? dateTimeFormat(qOrder.OrderSubmittedOn, "yyyy-mm-dd HH:nn:ss") : "", order["BusinessID"] = qOrder.OrderBusinessID;
"UpdatedOn": len(qOrder.OrderLastEditedOn) ? dateTimeFormat(qOrder.OrderLastEditedOn, "yyyy-mm-dd HH:nn:ss") : "", order["BusinessName"] = qOrder.BusinessName ?: "";
"Customer": { order["Status"] = qOrder.OrderStatusID;
"UserID": qOrder.OrderUserID, order["StatusText"] = getStatusText(qOrder.OrderStatusID);
"FirstName": qOrder.UserFirstName, order["OrderTypeID"] = qOrder.OrderTypeID ?: 0;
"LastName": qOrder.UserLastName, order["OrderTypeName"] = getOrderTypeName(qOrder.OrderTypeID ?: 0);
"Phone": qOrder.UserContactNumber, order["Subtotal"] = subtotal;
"Email": qOrder.UserEmailAddress order["Tax"] = tax;
}, order["Tip"] = tip;
"ServicePoint": { order["Total"] = total;
"ServicePointID": qOrder.OrderServicePointID, order["Notes"] = qOrder.OrderRemarks;
"Name": qOrder.ServicePointName, order["CreatedOn"] = dateTimeFormat(qOrder.OrderAddedOn, "yyyy-mm-dd HH:nn:ss");
"TypeID": qOrder.ServicePointTypeID order["SubmittedOn"] = len(qOrder.OrderSubmittedOn) ? dateTimeFormat(qOrder.OrderSubmittedOn, "yyyy-mm-dd HH:nn:ss") : "";
}, order["UpdatedOn"] = len(qOrder.OrderLastEditedOn) ? dateTimeFormat(qOrder.OrderLastEditedOn, "yyyy-mm-dd HH:nn:ss") : "";
"LineItems": lineItems, order["Customer"] = customer;
"Staff": staff order["ServicePoint"] = servicePoint;
}; order["LineItems"] = lineItems;
order["Staff"] = staff;
response["OK"] = true; response["OK"] = true;
response["ORDER"] = order; response["ORDER"] = order;

View file

@ -0,0 +1,122 @@
<cfsetting showdebugoutput="false">
<cfcontent type="application/json; charset=utf-8">
<cfscript>
/**
* Create an admin rating for a worker on a completed task
*
* POST: {
* TaskID: 123,
* AdminUserID: 456,
* onTime: true/false,
* completedScope: true/false,
* requiredFollowup: true/false,
* continueAllow: true/false
* }
*/
function readJsonBody() {
var raw = getHttpRequestData().content;
if (isNull(raw) || len(trim(raw)) == 0) return {};
try {
var data = deserializeJSON(raw);
return isStruct(data) ? data : {};
} catch (any e) {
return {};
}
}
function generateToken() {
return lcase(replace(createUUID(), "-", "", "all"));
}
try {
data = readJsonBody();
taskID = val(structKeyExists(data, "TaskID") ? data.TaskID : 0);
adminUserID = val(structKeyExists(data, "AdminUserID") ? data.AdminUserID : 0);
if (taskID == 0) {
writeOutput(serializeJSON({ "OK": false, "ERROR": "missing_task", "MESSAGE": "TaskID is required." }));
abort;
}
if (adminUserID == 0) {
writeOutput(serializeJSON({ "OK": false, "ERROR": "missing_admin", "MESSAGE": "AdminUserID is required." }));
abort;
}
// Verify task exists and is completed
qTask = queryExecute("
SELECT t.TaskID, t.TaskClaimedByUserID, t.TaskCompletedOn, t.TaskBusinessID
FROM Tasks t
WHERE t.TaskID = :taskID
", { taskID: taskID });
if (qTask.recordCount == 0) {
writeOutput(serializeJSON({ "OK": false, "ERROR": "not_found", "MESSAGE": "Task not found." }));
abort;
}
if (len(trim(qTask.TaskCompletedOn)) == 0) {
writeOutput(serializeJSON({ "OK": false, "ERROR": "not_completed", "MESSAGE": "Task has not been completed yet." }));
abort;
}
workerUserID = qTask.TaskClaimedByUserID;
if (workerUserID == 0) {
writeOutput(serializeJSON({ "OK": false, "ERROR": "no_worker", "MESSAGE": "No worker assigned to this task." }));
abort;
}
// Check if admin rating already exists for this task
qExisting = queryExecute("
SELECT TaskRatingID FROM TaskRatings
WHERE TaskRatingTaskID = :taskID
AND TaskRatingDirection = 'admin_rates_worker'
LIMIT 1
", { taskID: taskID });
if (qExisting.recordCount > 0) {
writeOutput(serializeJSON({ "OK": false, "ERROR": "already_rated", "MESSAGE": "This task has already been rated by an admin." }));
abort;
}
// Insert the admin rating (completed immediately since admin submits directly)
token = generateToken();
queryExecute("
INSERT INTO TaskRatings (
TaskRatingTaskID, TaskRatingByUserID, TaskRatingForUserID, TaskRatingDirection,
TaskRatingOnTime, TaskRatingCompletedScope, TaskRatingRequiredFollowup, TaskRatingContinueAllow,
TaskRatingAccessToken, TaskRatingExpiresOn, TaskRatingCompletedOn
) VALUES (
:taskID, :adminUserID, :workerUserID, 'admin_rates_worker',
:onTime, :completedScope, :requiredFollowup, :continueAllow,
:token, DATE_ADD(NOW(), INTERVAL 24 HOUR), NOW()
)
", {
taskID: taskID,
adminUserID: adminUserID,
workerUserID: workerUserID,
onTime: { value: structKeyExists(data,"onTime") ? (data.onTime ? 1 : 0) : javaCast("null",""), cfsqltype: "cf_sql_tinyint", null: !structKeyExists(data,"onTime") },
completedScope: { value: structKeyExists(data,"completedScope") ? (data.completedScope ? 1 : 0) : javaCast("null",""), cfsqltype: "cf_sql_tinyint", null: !structKeyExists(data,"completedScope") },
requiredFollowup: { value: structKeyExists(data,"requiredFollowup") ? (data.requiredFollowup ? 1 : 0) : javaCast("null",""), cfsqltype: "cf_sql_tinyint", null: !structKeyExists(data,"requiredFollowup") },
continueAllow: { value: structKeyExists(data,"continueAllow") ? (data.continueAllow ? 1 : 0) : javaCast("null",""), cfsqltype: "cf_sql_tinyint", null: !structKeyExists(data,"continueAllow") },
token: token
});
ratingID = queryExecute("SELECT LAST_INSERT_ID() AS id", {}).id;
writeOutput(serializeJSON({
"OK": true,
"MESSAGE": "Rating submitted successfully.",
"RatingID": ratingID
}));
} catch (any e) {
writeOutput(serializeJSON({
"OK": false,
"ERROR": "server_error",
"MESSAGE": "Error creating rating",
"DETAIL": e.message
}));
}
</cfscript>

View file

@ -0,0 +1,92 @@
<cfsetting showdebugoutput="false">
<cfcontent type="application/json; charset=utf-8">
<cfscript>
/**
* List completed tasks that admin can rate
* Returns tasks completed in the last 7 days where admin hasn't yet rated the worker
*
* POST: { BusinessID: 123 }
*/
function readJsonBody() {
var raw = getHttpRequestData().content;
if (isNull(raw) || len(trim(raw)) == 0) return {};
try {
var data = deserializeJSON(raw);
return isStruct(data) ? data : {};
} catch (any e) {
return {};
}
}
try {
data = readJsonBody();
businessID = val(structKeyExists(data, "BusinessID") ? data.BusinessID : 0);
if (businessID == 0) {
writeOutput(serializeJSON({ "OK": false, "ERROR": "missing_business", "MESSAGE": "BusinessID is required." }));
abort;
}
// Get completed tasks from last 7 days where a worker was assigned
// and no admin rating exists yet
qTasks = queryExecute("
SELECT t.TaskID, t.TaskTitle, t.TaskCompletedOn, t.TaskClaimedByUserID, t.TaskOrderID,
u.UserFirstName AS WorkerFirstName, u.UserLastName AS WorkerLastName,
o.OrderID, o.OrderUserID,
cu.UserFirstName AS CustomerFirstName, cu.UserLastName AS CustomerLastName,
sp.ServicePointName,
(SELECT COUNT(*) FROM TaskRatings r
WHERE r.TaskRatingTaskID = t.TaskID
AND r.TaskRatingDirection = 'admin_rates_worker') AS HasAdminRating
FROM Tasks t
INNER JOIN Users u ON u.UserID = t.TaskClaimedByUserID
LEFT JOIN Orders o ON o.OrderID = t.TaskOrderID
LEFT JOIN Users cu ON cu.UserID = o.OrderUserID
LEFT JOIN ServicePoints sp ON sp.ServicePointID = o.OrderServicePointID
WHERE t.TaskBusinessID = :businessID
AND t.TaskCompletedOn IS NOT NULL
AND t.TaskCompletedOn > DATE_SUB(NOW(), INTERVAL 7 DAY)
AND t.TaskClaimedByUserID > 0
HAVING HasAdminRating = 0
ORDER BY t.TaskCompletedOn DESC
LIMIT 50
", { businessID: businessID });
tasks = [];
for (row in qTasks) {
// Build task title
taskTitle = row.TaskTitle;
if (len(taskTitle) == 0 && row.OrderID > 0) {
taskTitle = "Order ##" & row.OrderID;
}
if (len(taskTitle) == 0) {
taskTitle = "Task ##" & row.TaskID;
}
arrayAppend(tasks, {
"TaskID": row.TaskID,
"TaskTitle": taskTitle,
"CompletedOn": dateTimeFormat(row.TaskCompletedOn, "yyyy-mm-dd HH:nn:ss"),
"WorkerUserID": row.TaskClaimedByUserID,
"WorkerName": trim(row.WorkerFirstName & " " & row.WorkerLastName),
"CustomerName": len(row.CustomerFirstName) ? trim(row.CustomerFirstName & " " & row.CustomerLastName) : "",
"ServicePointName": row.ServicePointName ?: "",
"OrderID": row.OrderID ?: 0
});
}
writeOutput(serializeJSON({
"OK": true,
"TASKS": tasks
}));
} catch (any e) {
writeOutput(serializeJSON({
"OK": false,
"ERROR": "server_error",
"MESSAGE": "Error loading tasks",
"DETAIL": e.message
}));
}
</cfscript>

59
api/ratings/setup.cfm Normal file
View file

@ -0,0 +1,59 @@
<cfsetting showdebugoutput="false">
<cfcontent type="application/json; charset=utf-8">
<cfscript>
try {
// Create TaskRatings table
queryExecute("
CREATE TABLE IF NOT EXISTS TaskRatings (
TaskRatingID INT AUTO_INCREMENT PRIMARY KEY,
TaskRatingTaskID INT NOT NULL,
TaskRatingByUserID INT NOT NULL,
TaskRatingForUserID INT NOT NULL,
TaskRatingDirection VARCHAR(25) NOT NULL,
-- Customer/Admin rates Worker
TaskRatingOnTime TINYINT(1) NULL,
TaskRatingCompletedScope TINYINT(1) NULL,
TaskRatingRequiredFollowup TINYINT(1) NULL,
TaskRatingContinueAllow TINYINT(1) NULL,
-- Worker rates Customer
TaskRatingPrepared TINYINT(1) NULL,
TaskRatingRespectful TINYINT(1) NULL,
TaskRatingWouldAutoAssign TINYINT(1) NULL,
-- For rating links in receipts
TaskRatingAccessToken VARCHAR(64) NOT NULL UNIQUE,
TaskRatingExpiresOn DATETIME NOT NULL,
-- Timestamps
TaskRatingCreatedOn DATETIME DEFAULT CURRENT_TIMESTAMP,
TaskRatingCompletedOn DATETIME NULL,
INDEX idx_task (TaskRatingTaskID),
INDEX idx_for_user (TaskRatingForUserID),
INDEX idx_by_user (TaskRatingByUserID),
INDEX idx_token (TaskRatingAccessToken)
)
", {}, { datasource: "payfrit" });
// Verify table was created
cols = queryExecute("DESCRIBE TaskRatings", {}, { datasource: "payfrit" });
colNames = [];
for (c in cols) {
arrayAppend(colNames, c.Field);
}
writeOutput(serializeJSON({
"OK": true,
"MESSAGE": "TaskRatings table created successfully",
"COLUMNS": colNames
}));
} catch (any e) {
writeOutput(serializeJSON({
"OK": false,
"ERROR": e.message
}));
}
</cfscript>

144
api/ratings/submit.cfm Normal file
View file

@ -0,0 +1,144 @@
<cfsetting showdebugoutput="false">
<cfcontent type="application/json; charset=utf-8">
<cfscript>
function readJsonBody() {
var raw = getHttpRequestData().content;
if (isNull(raw) || len(trim(raw)) == 0) return {};
try {
var data = deserializeJSON(raw);
return isStruct(data) ? data : {};
} catch (any e) {
return {};
}
}
try {
data = readJsonBody();
// Token can come from URL param or JSON body
token = "";
if (structKeyExists(url, "token")) token = trim(url.token);
if (len(token) == 0 && structKeyExists(data, "token")) token = trim(data.token);
if (len(token) == 0) {
writeOutput(serializeJSON({ "OK": false, "ERROR": "missing_token", "MESSAGE": "Rating token is required." }));
abort;
}
// Look up the rating by token
qRating = queryExecute("
SELECT r.*, t.TaskTitle,
u_for.UserFirstName AS ForUserFirstName, u_for.UserLastName AS ForUserLastName,
u_by.UserFirstName AS ByUserFirstName, u_by.UserLastName AS ByUserLastName
FROM TaskRatings r
JOIN Tasks t ON t.TaskID = r.TaskRatingTaskID
LEFT JOIN Users u_for ON u_for.UserID = r.TaskRatingForUserID
LEFT JOIN Users u_by ON u_by.UserID = r.TaskRatingByUserID
WHERE r.TaskRatingAccessToken = ?
LIMIT 1
", [{ value = token, cfsqltype = "cf_sql_varchar" }]);
if (qRating.recordCount == 0) {
writeOutput(serializeJSON({ "OK": false, "ERROR": "invalid_token", "MESSAGE": "Rating not found or link is invalid." }));
abort;
}
// Check if expired
if (qRating.TaskRatingExpiresOn < now()) {
writeOutput(serializeJSON({ "OK": false, "ERROR": "expired", "MESSAGE": "This rating link has expired." }));
abort;
}
// Check if already completed
if (len(trim(qRating.TaskRatingCompletedOn)) > 0) {
writeOutput(serializeJSON({ "OK": false, "ERROR": "already_submitted", "MESSAGE": "This rating has already been submitted." }));
abort;
}
// If GET request (or no rating data), return rating info for display
isSubmission = structKeyExists(data, "onTime") || structKeyExists(data, "completedScope")
|| structKeyExists(data, "requiredFollowup") || structKeyExists(data, "continueAllow")
|| structKeyExists(data, "prepared") || structKeyExists(data, "respectful")
|| structKeyExists(data, "wouldAutoAssign");
if (!isSubmission) {
// Return rating details for UI to display
result = {
"OK": true,
"RatingID": qRating.TaskRatingID,
"Direction": qRating.TaskRatingDirection,
"TaskTitle": qRating.TaskTitle,
"ForUserName": trim(qRating.ForUserFirstName & " " & qRating.ForUserLastName),
"ExpiresOn": dateTimeFormat(qRating.TaskRatingExpiresOn, "yyyy-mm-dd HH:nn:ss")
};
// Include appropriate questions based on direction
if (qRating.TaskRatingDirection == "customer_rates_worker" || qRating.TaskRatingDirection == "admin_rates_worker") {
result["Questions"] = {
"onTime": "Was the worker on time?",
"completedScope": "Was the scope completed?",
"requiredFollowup": "Was follow-up required?",
"continueAllow": "Continue to allow these tasks?"
};
} else if (qRating.TaskRatingDirection == "worker_rates_customer") {
result["Questions"] = {
"prepared": "Was the customer prepared?",
"completedScope": "Was the scope clear?",
"respectful": "Was the customer respectful?",
"wouldAutoAssign": "Would you serve this customer again?"
};
}
writeOutput(serializeJSON(result));
abort;
}
// Process submission based on direction
if (qRating.TaskRatingDirection == "customer_rates_worker" || qRating.TaskRatingDirection == "admin_rates_worker") {
queryExecute("
UPDATE TaskRatings SET
TaskRatingOnTime = ?,
TaskRatingCompletedScope = ?,
TaskRatingRequiredFollowup = ?,
TaskRatingContinueAllow = ?,
TaskRatingCompletedOn = NOW()
WHERE TaskRatingID = ?
", [
{ value = structKeyExists(data,"onTime") ? (data.onTime ? 1 : 0) : javaCast("null",""), cfsqltype = "cf_sql_tinyint", null = !structKeyExists(data,"onTime") },
{ value = structKeyExists(data,"completedScope") ? (data.completedScope ? 1 : 0) : javaCast("null",""), cfsqltype = "cf_sql_tinyint", null = !structKeyExists(data,"completedScope") },
{ value = structKeyExists(data,"requiredFollowup") ? (data.requiredFollowup ? 1 : 0) : javaCast("null",""), cfsqltype = "cf_sql_tinyint", null = !structKeyExists(data,"requiredFollowup") },
{ value = structKeyExists(data,"continueAllow") ? (data.continueAllow ? 1 : 0) : javaCast("null",""), cfsqltype = "cf_sql_tinyint", null = !structKeyExists(data,"continueAllow") },
{ value = qRating.TaskRatingID, cfsqltype = "cf_sql_integer" }
]);
} else if (qRating.TaskRatingDirection == "worker_rates_customer") {
queryExecute("
UPDATE TaskRatings SET
TaskRatingPrepared = ?,
TaskRatingCompletedScope = ?,
TaskRatingRespectful = ?,
TaskRatingWouldAutoAssign = ?,
TaskRatingCompletedOn = NOW()
WHERE TaskRatingID = ?
", [
{ value = structKeyExists(data,"prepared") ? (data.prepared ? 1 : 0) : javaCast("null",""), cfsqltype = "cf_sql_tinyint", null = !structKeyExists(data,"prepared") },
{ value = structKeyExists(data,"completedScope") ? (data.completedScope ? 1 : 0) : javaCast("null",""), cfsqltype = "cf_sql_tinyint", null = !structKeyExists(data,"completedScope") },
{ value = structKeyExists(data,"respectful") ? (data.respectful ? 1 : 0) : javaCast("null",""), cfsqltype = "cf_sql_tinyint", null = !structKeyExists(data,"respectful") },
{ value = structKeyExists(data,"wouldAutoAssign") ? (data.wouldAutoAssign ? 1 : 0) : javaCast("null",""), cfsqltype = "cf_sql_tinyint", null = !structKeyExists(data,"wouldAutoAssign") },
{ value = qRating.TaskRatingID, cfsqltype = "cf_sql_integer" }
]);
}
writeOutput(serializeJSON({
"OK": true,
"MESSAGE": "Rating submitted successfully. Thank you for your feedback!"
}));
} catch (any e) {
writeOutput(serializeJSON({
"OK": false,
"ERROR": "server_error",
"MESSAGE": "Error submitting rating",
"DETAIL": e.message
}));
}
</cfscript>

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). 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 as a single string like ""Mon-Thu 11am-9pm, Fri 11am-10pm, Sat 4pm-10pm, Sun 4pm-9pm"" - 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). 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,14 +190,52 @@
<!--- MERGE PHASE: Combine all results ---> <!--- MERGE PHASE: Combine all results --->
<!--- 1. Extract business info (from first result that has it) ---> <!--- 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">
<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)>
<cfif structKeyExists(result.business, "name") AND len(result.business.name)> <cfloop list="#bizFields#" index="fieldName">
<cfset mergedBusiness = result.business> <cfif structKeyExists(result.business, fieldName)>
<cfbreak> <cfset fieldVal = result.business[fieldName]>
<cfif isSimpleValue(fieldVal) AND len(trim(fieldVal))>
<cfset mergedBusiness[fieldName] = trim(fieldVal)>
<cfelseif isStruct(fieldVal)>
<!--- Convert struct to readable string (e.g. hours as key-value pairs) --->
<cfset parts = arrayNew(1)>
<cfloop collection="#fieldVal#" item="k">
<cfif isSimpleValue(fieldVal[k])>
<cfset arrayAppend(parts, k & ": " & fieldVal[k])>
</cfif> </cfif>
</cfloop>
<cfif arrayLen(parts)>
<cfset mergedBusiness[fieldName] = arrayToList(parts, ", ")>
</cfif>
<cfelseif isArray(fieldVal) AND arrayLen(fieldVal)>
<!--- Convert array to readable string --->
<cfset parts = arrayNew(1)>
<cfloop array="#fieldVal#" index="entry">
<cfif isSimpleValue(entry) AND len(trim(entry))>
<cfset arrayAppend(parts, trim(entry))>
<cfelseif isStruct(entry)>
<!--- e.g. {day:"Mon", hours:"11am-9pm"} --->
<cfset entryParts = arrayNew(1)>
<cfloop collection="#entry#" item="k">
<cfif isSimpleValue(entry[k])>
<cfset arrayAppend(entryParts, entry[k])>
</cfif>
</cfloop>
<cfif arrayLen(entryParts)>
<cfset arrayAppend(parts, arrayToList(entryParts, " "))>
</cfif>
</cfif>
</cfloop>
<cfif arrayLen(parts)>
<cfset mergedBusiness[fieldName] = arrayToList(parts, ", ")>
</cfif>
</cfif>
</cfif>
</cfloop>
</cfif> </cfif>
</cfloop> </cfloop>

View file

@ -53,6 +53,9 @@ try {
// Extract phone number // Extract phone number
bizPhone = structKeyExists(biz, "phone") && isSimpleValue(biz.phone) ? trim(biz.phone) : ""; bizPhone = structKeyExists(biz, "phone") && isSimpleValue(biz.phone) ? trim(biz.phone) : "";
// Extract tax rate (stored as decimal, e.g. 8.25% -> 0.0825)
bizTaxRate = structKeyExists(biz, "taxRatePercent") && isSimpleValue(biz.taxRatePercent) ? val(biz.taxRatePercent) / 100 : 0;
// 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) : "";
@ -93,16 +96,22 @@ try {
addressId = qNewAddr.id; addressId = qNewAddr.id;
response.steps.append("Created address record (ID: " & addressId & ")"); response.steps.append("Created address record (ID: " & addressId & ")");
// Get community meal type (1=provide meals, 2=food bank donation)
communityMealType = structKeyExists(wizardData, "communityMealType") && isSimpleValue(wizardData.communityMealType) ? val(wizardData.communityMealType) : 1;
if (communityMealType < 1 || communityMealType > 2) communityMealType = 1;
// Create new business with address link and phone // Create new business with address link and phone
queryExecute(" queryExecute("
INSERT INTO Businesses (BusinessName, BusinessPhone, BusinessUserID, BusinessAddressID, BusinessDeliveryZipCodes, BusinessAddedOn) INSERT INTO Businesses (BusinessName, BusinessPhone, BusinessUserID, BusinessAddressID, BusinessDeliveryZipCodes, BusinessCommunityMealType, BusinessTaxRate, BusinessAddedOn)
VALUES (:name, :phone, :userId, :addressId, :deliveryZips, NOW()) VALUES (:name, :phone, :userId, :addressId, :deliveryZips, :communityMealType, :taxRate, NOW())
", { ", {
name: bizName, name: bizName,
phone: bizPhone, phone: bizPhone,
userId: userId, userId: userId,
addressId: addressId, addressId: addressId,
deliveryZips: len(zip) ? zip : "" deliveryZips: len(zip) ? zip : "",
communityMealType: communityMealType,
taxRate: { value: bizTaxRate, cfsqltype: "cf_sql_decimal" }
}, { datasource: "payfrit" }); }, { datasource: "payfrit" });
qNewBiz = queryExecute("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" }); qNewBiz = queryExecute("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" });

View file

@ -58,15 +58,21 @@ try {
} }
} }
// Get task type name if TaskTypeID provided // Get task type info if TaskTypeID provided (name + category)
taskTypeName = ""; taskTypeName = "";
taskTypeCategoryID = 0;
if (taskTypeID > 0) { if (taskTypeID > 0) {
typeQuery = queryExecute(" typeQuery = queryExecute("
SELECT tt_TaskTypeName FROM tt_TaskTypes WHERE tt_TaskTypeID = :typeID SELECT tt_TaskTypeName, tt_TaskTypeCategoryID FROM tt_TaskTypes WHERE tt_TaskTypeID = :typeID
", { typeID: { value: taskTypeID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); ", { typeID: { value: taskTypeID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
if (typeQuery.recordCount && len(trim(typeQuery.tt_TaskTypeName))) { if (typeQuery.recordCount) {
if (len(trim(typeQuery.tt_TaskTypeName))) {
taskTypeName = typeQuery.tt_TaskTypeName; taskTypeName = typeQuery.tt_TaskTypeName;
} }
if (!isNull(typeQuery.tt_TaskTypeCategoryID) && isNumeric(typeQuery.tt_TaskTypeCategoryID) && typeQuery.tt_TaskTypeCategoryID > 0) {
taskTypeCategoryID = typeQuery.tt_TaskTypeCategoryID;
}
}
} }
// Create task title and details - use task type name if available // Create task title and details - use task type name if available
@ -90,7 +96,14 @@ try {
taskDetails &= "Customer is requesting assistance"; taskDetails &= "Customer is requesting assistance";
} }
// Look up or create a "Service" category for this business // Determine category: use task type's category if set, otherwise fallback to "Service" category
categoryID = 0;
if (taskTypeCategoryID > 0) {
// Use the task type's assigned category
categoryID = taskTypeCategoryID;
} else {
// Fallback: look up or create a "Service" category for this business
catQuery = queryExecute(" catQuery = queryExecute("
SELECT TaskCategoryID FROM TaskCategories SELECT TaskCategoryID FROM TaskCategories
WHERE TaskCategoryBusinessID = :businessID AND TaskCategoryName = 'Service' WHERE TaskCategoryBusinessID = :businessID AND TaskCategoryName = 'Service'
@ -109,6 +122,7 @@ try {
} else { } else {
categoryID = catQuery.TaskCategoryID; categoryID = catQuery.TaskCategoryID;
} }
}
// Insert task // Insert task
queryExecute(" queryExecute("

View file

@ -26,11 +26,18 @@
</cftry> </cftry>
</cffunction> </cffunction>
<cffunction name="generateToken" access="public" returntype="string" output="false">
<cfreturn lcase(replace(createUUID(), "-", "", "all"))>
</cffunction>
<cfset data = readJsonBody()> <cfset data = readJsonBody()>
<cfset TaskID = val( structKeyExists(data,"TaskID") ? data.TaskID : 0 )> <cfset TaskID = val( structKeyExists(data,"TaskID") ? data.TaskID : 0 )>
<!--- Get UserID from request (auth header) or from JSON body as fallback ---> <!--- Get UserID from request (auth header) or from JSON body as fallback --->
<cfset UserID = val( structKeyExists(request,"UserID") ? request.UserID : (structKeyExists(data,"UserID") ? data.UserID : 0) )> <cfset UserID = val( structKeyExists(request,"UserID") ? request.UserID : (structKeyExists(data,"UserID") ? data.UserID : 0) )>
<!--- Optional: Worker rating of customer (when required or voluntary) --->
<cfset workerRating = structKeyExists(data,"workerRating") ? data.workerRating : {}>
<cfif TaskID LTE 0> <cfif TaskID LTE 0>
<cfset apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "TaskID is required." })> <cfset apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "TaskID is required." })>
</cfif> </cfif>
@ -38,9 +45,11 @@
<cftry> <cftry>
<!--- Verify task exists ---> <!--- Verify task exists --->
<cfset qTask = queryExecute(" <cfset qTask = queryExecute("
SELECT TaskID, TaskClaimedByUserID, TaskCompletedOn, TaskOrderID, TaskTypeID SELECT t.TaskID, t.TaskClaimedByUserID, t.TaskCompletedOn, t.TaskOrderID, t.TaskTypeID, t.TaskBusinessID,
FROM Tasks o.OrderUserID, o.OrderServicePointID
WHERE TaskID = ? FROM Tasks t
LEFT JOIN Orders o ON o.OrderID = t.TaskOrderID
WHERE t.TaskID = ?
", [ { value = TaskID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })> ", [ { value = TaskID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
<cfif qTask.recordCount EQ 0> <cfif qTask.recordCount EQ 0>
@ -63,6 +72,35 @@
<cfset apiAbort({ "OK": false, "ERROR": "already_completed", "MESSAGE": "Task has already been completed." })> <cfset apiAbort({ "OK": false, "ERROR": "already_completed", "MESSAGE": "Task has already been completed." })>
</cfif> </cfif>
<!--- Check if this is a service point task (customer-facing) --->
<cfset hasServicePoint = (val(qTask.OrderServicePointID) GT 0)>
<cfset customerUserID = val(qTask.OrderUserID)>
<cfset workerUserID = val(qTask.TaskClaimedByUserID)>
<cfset ratingRequired = false>
<cfset ratingsCreated = []>
<!--- For service point tasks, check if worker rating of customer is required (10% based on TaskID) --->
<!--- Use TaskID modulo for deterministic selection - same task always has same requirement --->
<cfif hasServicePoint AND customerUserID GT 0 AND workerUserID GT 0>
<cfset ratingRequired = (TaskID MOD 10 EQ 0)>
<!--- If rating required but not provided, return error --->
<cfif ratingRequired AND structIsEmpty(workerRating)>
<cfset apiAbort({
"OK": false,
"ERROR": "rating_required",
"MESSAGE": "Please rate the customer before completing this task.",
"CustomerUserID": customerUserID,
"Questions": {
"prepared": "Was the customer prepared?",
"completedScope": "Was the scope clear?",
"respectful": "Was the customer respectful?",
"wouldAutoAssign": "Would you serve this customer again?"
}
})>
</cfif>
</cfif>
<!--- Mark task as completed ---> <!--- Mark task as completed --->
<cfset queryExecute(" <cfset queryExecute("
UPDATE Tasks UPDATE Tasks
@ -82,12 +120,60 @@
<cfset orderUpdated = true> <cfset orderUpdated = true>
</cfif> </cfif>
<!--- Create rating records for service point tasks --->
<cfif hasServicePoint AND customerUserID GT 0 AND workerUserID GT 0>
<!--- 1. Customer rates Worker (always created, submitted via receipt link) --->
<cfset customerToken = generateToken()>
<cfset queryExecute("
INSERT INTO TaskRatings (
TaskRatingTaskID, TaskRatingByUserID, TaskRatingForUserID, TaskRatingDirection,
TaskRatingAccessToken, TaskRatingExpiresOn
) VALUES (
?, ?, ?, 'customer_rates_worker',
?, DATE_ADD(NOW(), INTERVAL 24 HOUR)
)
", [
{ value = TaskID, cfsqltype = "cf_sql_integer" },
{ value = customerUserID, cfsqltype = "cf_sql_integer" },
{ value = workerUserID, cfsqltype = "cf_sql_integer" },
{ value = customerToken, cfsqltype = "cf_sql_varchar" }
], { datasource = "payfrit" })>
<cfset arrayAppend(ratingsCreated, { "direction": "customer_rates_worker", "token": customerToken })>
<!--- 2. Worker rates Customer (if provided or required) --->
<cfif NOT structIsEmpty(workerRating)>
<cfset workerToken = generateToken()>
<cfset queryExecute("
INSERT INTO TaskRatings (
TaskRatingTaskID, TaskRatingByUserID, TaskRatingForUserID, TaskRatingDirection,
TaskRatingPrepared, TaskRatingCompletedScope, TaskRatingRespectful, TaskRatingWouldAutoAssign,
TaskRatingAccessToken, TaskRatingExpiresOn, TaskRatingCompletedOn
) VALUES (
?, ?, ?, 'worker_rates_customer',
?, ?, ?, ?,
?, DATE_ADD(NOW(), INTERVAL 24 HOUR), NOW()
)
", [
{ value = TaskID, cfsqltype = "cf_sql_integer" },
{ value = workerUserID, cfsqltype = "cf_sql_integer" },
{ value = customerUserID, cfsqltype = "cf_sql_integer" },
{ value = structKeyExists(workerRating,"prepared") ? (workerRating.prepared ? 1 : 0) : javaCast("null",""), cfsqltype = "cf_sql_tinyint", null = !structKeyExists(workerRating,"prepared") },
{ value = structKeyExists(workerRating,"completedScope") ? (workerRating.completedScope ? 1 : 0) : javaCast("null",""), cfsqltype = "cf_sql_tinyint", null = !structKeyExists(workerRating,"completedScope") },
{ value = structKeyExists(workerRating,"respectful") ? (workerRating.respectful ? 1 : 0) : javaCast("null",""), cfsqltype = "cf_sql_tinyint", null = !structKeyExists(workerRating,"respectful") },
{ value = structKeyExists(workerRating,"wouldAutoAssign") ? (workerRating.wouldAutoAssign ? 1 : 0) : javaCast("null",""), cfsqltype = "cf_sql_tinyint", null = !structKeyExists(workerRating,"wouldAutoAssign") },
{ value = workerToken, cfsqltype = "cf_sql_varchar" }
], { datasource = "payfrit" })>
<cfset arrayAppend(ratingsCreated, { "direction": "worker_rates_customer", "submitted": true })>
</cfif>
</cfif>
<cfset apiAbort({ <cfset apiAbort({
"OK": true, "OK": true,
"ERROR": "", "ERROR": "",
"MESSAGE": "Task completed successfully.", "MESSAGE": "Task completed successfully.",
"TaskID": TaskID, "TaskID": TaskID,
"OrderUpdated": orderUpdated "OrderUpdated": orderUpdated,
"RatingsCreated": ratingsCreated
})> })>
<cfcatch> <cfcatch>

View file

@ -0,0 +1,107 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// Delete (deactivate) a task category
// Input: BusinessID, TaskCategoryID
// Output: { OK: true }
function apiAbort(required struct payload) {
writeOutput(serializeJSON(payload));
abort;
}
function readJsonBody() {
var raw = getHttpRequestData().content;
if (isNull(raw)) raw = "";
if (!len(trim(raw))) return {};
try {
var data = deserializeJSON(raw);
if (isStruct(data)) return data;
} catch (any e) {}
return {};
}
try {
data = readJsonBody();
// Get BusinessID
businessID = 0;
httpHeaders = getHttpRequestData().headers;
if (structKeyExists(url, "bid") && isNumeric(url.bid)) {
businessID = int(url.bid);
} else if (structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID)) {
businessID = int(data.BusinessID);
} else if (structKeyExists(httpHeaders, "X-Business-ID") && isNumeric(httpHeaders["X-Business-ID"])) {
businessID = int(httpHeaders["X-Business-ID"]);
}
if (businessID == 0) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
}
// Get CategoryID
categoryID = structKeyExists(data, "TaskCategoryID") && isNumeric(data.TaskCategoryID) ? int(data.TaskCategoryID) : 0;
if (categoryID == 0) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "TaskCategoryID is required" });
}
// Verify ownership
qCheck = queryExecute("
SELECT TaskCategoryID FROM TaskCategories
WHERE TaskCategoryID = :id AND TaskCategoryBusinessID = :businessID
", {
id: { value: categoryID, cfsqltype: "cf_sql_integer" },
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
if (qCheck.recordCount == 0) {
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Category not found" });
}
// Check if any tasks use this category
qTasks = queryExecute("
SELECT COUNT(*) as cnt FROM Tasks
WHERE TaskCategoryID = :id
", {
id: { value: categoryID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
if (qTasks.cnt > 0) {
// Soft delete - just deactivate
queryExecute("
UPDATE TaskCategories SET TaskCategoryIsActive = 0
WHERE TaskCategoryID = :id
", {
id: { value: categoryID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
apiAbort({
"OK": true,
"MESSAGE": "Category deactivated (has " & qTasks.cnt & " tasks)"
});
} else {
// Hard delete - no tasks use it
queryExecute("
DELETE FROM TaskCategories WHERE TaskCategoryID = :id
", {
id: { value: categoryID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
apiAbort({
"OK": true,
"MESSAGE": "Category deleted"
});
}
} catch (any e) {
apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": e.message
});
}
</cfscript>

View file

@ -50,7 +50,8 @@ try {
tt_TaskTypeDescription as TaskTypeDescription, tt_TaskTypeDescription as TaskTypeDescription,
tt_TaskTypeIcon as TaskTypeIcon, tt_TaskTypeIcon as TaskTypeIcon,
tt_TaskTypeColor as TaskTypeColor, tt_TaskTypeColor as TaskTypeColor,
tt_TaskTypeSortOrder as SortOrder tt_TaskTypeSortOrder as SortOrder,
tt_TaskTypeCategoryID as CategoryID
FROM tt_TaskTypes FROM tt_TaskTypes
WHERE tt_TaskTypeBusinessID = :businessID WHERE tt_TaskTypeBusinessID = :businessID
ORDER BY tt_TaskTypeSortOrder, tt_TaskTypeID ORDER BY tt_TaskTypeSortOrder, tt_TaskTypeID
@ -65,7 +66,8 @@ try {
"TaskTypeName": row.TaskTypeName, "TaskTypeName": row.TaskTypeName,
"TaskTypeDescription": isNull(row.TaskTypeDescription) ? "" : row.TaskTypeDescription, "TaskTypeDescription": isNull(row.TaskTypeDescription) ? "" : row.TaskTypeDescription,
"TaskTypeIcon": isNull(row.TaskTypeIcon) ? "notifications" : row.TaskTypeIcon, "TaskTypeIcon": isNull(row.TaskTypeIcon) ? "notifications" : row.TaskTypeIcon,
"TaskTypeColor": isNull(row.TaskTypeColor) ? "##9C27B0" : row.TaskTypeColor "TaskTypeColor": isNull(row.TaskTypeColor) ? "##9C27B0" : row.TaskTypeColor,
"CategoryID": isNull(row.CategoryID) ? "" : row.CategoryID
}); });
} }

View file

@ -0,0 +1,83 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// List task categories for a business
// Categories ARE Task Types (merged concept)
// Input: BusinessID (required)
// Output: { OK: true, CATEGORIES: [...] }
function apiAbort(required struct payload) {
writeOutput(serializeJSON(payload));
abort;
}
function readJsonBody() {
var raw = getHttpRequestData().content;
if (isNull(raw)) raw = "";
if (!len(trim(raw))) return {};
try {
var data = deserializeJSON(raw);
if (isStruct(data)) return data;
} catch (any e) {}
return {};
}
try {
data = readJsonBody();
// Get BusinessID from URL, body, or header
businessID = 0;
httpHeaders = getHttpRequestData().headers;
if (structKeyExists(url, "bid") && isNumeric(url.bid)) {
businessID = int(url.bid);
} else if (structKeyExists(url, "BusinessID") && isNumeric(url.BusinessID)) {
businessID = int(url.BusinessID);
} else if (structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID)) {
businessID = int(data.BusinessID);
} else if (structKeyExists(httpHeaders, "X-Business-ID") && isNumeric(httpHeaders["X-Business-ID"])) {
businessID = int(httpHeaders["X-Business-ID"]);
}
if (businessID == 0) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
}
// Get Task Categories (separate from Services/Task Types)
qCategories = queryExecute("
SELECT
TaskCategoryID,
TaskCategoryName,
TaskCategoryColor
FROM TaskCategories
WHERE TaskCategoryBusinessID = :businessID
AND TaskCategoryIsActive = 1
ORDER BY TaskCategoryName
", {
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
categories = [];
for (row in qCategories) {
arrayAppend(categories, {
"TaskCategoryID": row.TaskCategoryID,
"TaskCategoryName": row.TaskCategoryName,
"TaskCategoryColor": row.TaskCategoryColor
});
}
apiAbort({
"OK": true,
"CATEGORIES": categories
});
} catch (any e) {
apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": e.message
});
}
</cfscript>

124
api/tasks/saveCategory.cfm Normal file
View file

@ -0,0 +1,124 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// Create or update a task category
// Input: BusinessID, TaskCategoryID (optional, for update), Name, Color
// Output: { OK: true, CATEGORY_ID: int }
function apiAbort(required struct payload) {
writeOutput(serializeJSON(payload));
abort;
}
function readJsonBody() {
var raw = getHttpRequestData().content;
if (isNull(raw)) raw = "";
if (!len(trim(raw))) return {};
try {
var data = deserializeJSON(raw);
if (isStruct(data)) return data;
} catch (any e) {}
return {};
}
try {
data = readJsonBody();
// Get BusinessID
businessID = 0;
httpHeaders = getHttpRequestData().headers;
if (structKeyExists(url, "bid") && isNumeric(url.bid)) {
businessID = int(url.bid);
} else if (structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID)) {
businessID = int(data.BusinessID);
} else if (structKeyExists(httpHeaders, "X-Business-ID") && isNumeric(httpHeaders["X-Business-ID"])) {
businessID = int(httpHeaders["X-Business-ID"]);
}
if (businessID == 0) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
}
// Get fields
categoryID = structKeyExists(data, "TaskCategoryID") && isNumeric(data.TaskCategoryID) ? int(data.TaskCategoryID) : 0;
categoryName = structKeyExists(data, "Name") ? trim(toString(data.Name)) : "";
categoryColor = structKeyExists(data, "Color") ? trim(toString(data.Color)) : "##6366f1";
if (!len(categoryName)) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Name is required" });
}
// Validate color format
if (!reFindNoCase("^##[0-9A-F]{6}$", categoryColor)) {
// Try to fix common issues
if (reFindNoCase("^[0-9A-F]{6}$", categoryColor)) {
categoryColor = "##" & categoryColor;
} else if (reFindNoCase("^##[0-9A-F]{6}$", categoryColor)) {
categoryColor = "##" & right(categoryColor, 6);
} else {
categoryColor = "##6366f1";
}
}
if (categoryID > 0) {
// UPDATE existing category
qCheck = queryExecute("
SELECT TaskCategoryID FROM TaskCategories
WHERE TaskCategoryID = :id AND TaskCategoryBusinessID = :businessID
", {
id: { value: categoryID, cfsqltype: "cf_sql_integer" },
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
if (qCheck.recordCount == 0) {
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Category not found" });
}
queryExecute("
UPDATE TaskCategories SET
TaskCategoryName = :name,
TaskCategoryColor = :color
WHERE TaskCategoryID = :id
", {
name: { value: categoryName, cfsqltype: "cf_sql_varchar" },
color: { value: categoryColor, cfsqltype: "cf_sql_varchar" },
id: { value: categoryID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
apiAbort({
"OK": true,
"CATEGORY_ID": categoryID,
"MESSAGE": "Category updated"
});
} else {
// INSERT new category
queryExecute("
INSERT INTO TaskCategories (TaskCategoryBusinessID, TaskCategoryName, TaskCategoryColor)
VALUES (:businessID, :name, :color)
", {
businessID: { value: businessID, cfsqltype: "cf_sql_integer" },
name: { value: categoryName, cfsqltype: "cf_sql_varchar" },
color: { value: categoryColor, cfsqltype: "cf_sql_varchar" }
}, { datasource: "payfrit" });
qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
apiAbort({
"OK": true,
"CATEGORY_ID": qNew.newID,
"MESSAGE": "Category created"
});
}
} catch (any e) {
apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": e.message
});
}
</cfscript>

View file

@ -78,6 +78,14 @@ try {
apiAbort({ "OK": false, "ERROR": "invalid_params", "MESSAGE": "TaskTypeColor must be a valid hex color" }); apiAbort({ "OK": false, "ERROR": "invalid_params", "MESSAGE": "TaskTypeColor must be a valid hex color" });
} }
// Get TaskTypeCategoryID (optional - links to TaskCategories for task creation)
taskTypeCategoryID = javaCast("null", "");
if (structKeyExists(data, "TaskTypeCategoryID") && isNumeric(data.TaskTypeCategoryID) && data.TaskTypeCategoryID > 0) {
taskTypeCategoryID = int(data.TaskTypeCategoryID);
} else if (structKeyExists(data, "CategoryID") && isNumeric(data.CategoryID) && data.CategoryID > 0) {
taskTypeCategoryID = int(data.CategoryID);
}
// Get TaskTypeID (optional - for update) // Get TaskTypeID (optional - for update)
taskTypeID = 0; taskTypeID = 0;
if (structKeyExists(data, "TaskTypeID") && isNumeric(data.TaskTypeID)) { if (structKeyExists(data, "TaskTypeID") && isNumeric(data.TaskTypeID)) {
@ -104,13 +112,15 @@ try {
SET tt_TaskTypeName = :taskTypeName, SET tt_TaskTypeName = :taskTypeName,
tt_TaskTypeDescription = :taskTypeDescription, tt_TaskTypeDescription = :taskTypeDescription,
tt_TaskTypeIcon = :taskTypeIcon, tt_TaskTypeIcon = :taskTypeIcon,
tt_TaskTypeColor = :taskTypeColor tt_TaskTypeColor = :taskTypeColor,
tt_TaskTypeCategoryID = :categoryID
WHERE tt_TaskTypeID = :taskTypeID WHERE tt_TaskTypeID = :taskTypeID
", { ", {
taskTypeName: { value: taskTypeName, cfsqltype: "cf_sql_varchar" }, taskTypeName: { value: taskTypeName, cfsqltype: "cf_sql_varchar" },
taskTypeDescription: { value: taskTypeDescription, cfsqltype: "cf_sql_varchar", null: !len(taskTypeDescription) }, taskTypeDescription: { value: taskTypeDescription, cfsqltype: "cf_sql_varchar", null: !len(taskTypeDescription) },
taskTypeIcon: { value: taskTypeIcon, cfsqltype: "cf_sql_varchar" }, taskTypeIcon: { value: taskTypeIcon, cfsqltype: "cf_sql_varchar" },
taskTypeColor: { value: taskTypeColor, cfsqltype: "cf_sql_varchar" }, taskTypeColor: { value: taskTypeColor, cfsqltype: "cf_sql_varchar" },
categoryID: { value: taskTypeCategoryID, cfsqltype: "cf_sql_integer", null: isNull(taskTypeCategoryID) },
taskTypeID: { value: taskTypeID, cfsqltype: "cf_sql_integer" } taskTypeID: { value: taskTypeID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" }); }, { datasource: "payfrit" });
@ -122,14 +132,15 @@ try {
} else { } else {
// INSERT new task type // INSERT new task type
queryExecute(" queryExecute("
INSERT INTO tt_TaskTypes (tt_TaskTypeName, tt_TaskTypeDescription, tt_TaskTypeIcon, tt_TaskTypeColor, tt_TaskTypeBusinessID) INSERT INTO tt_TaskTypes (tt_TaskTypeName, tt_TaskTypeDescription, tt_TaskTypeIcon, tt_TaskTypeColor, tt_TaskTypeBusinessID, tt_TaskTypeCategoryID)
VALUES (:taskTypeName, :taskTypeDescription, :taskTypeIcon, :taskTypeColor, :businessID) VALUES (:taskTypeName, :taskTypeDescription, :taskTypeIcon, :taskTypeColor, :businessID, :categoryID)
", { ", {
taskTypeName: { value: taskTypeName, cfsqltype: "cf_sql_varchar" }, taskTypeName: { value: taskTypeName, cfsqltype: "cf_sql_varchar" },
taskTypeDescription: { value: taskTypeDescription, cfsqltype: "cf_sql_varchar", null: !len(taskTypeDescription) }, taskTypeDescription: { value: taskTypeDescription, cfsqltype: "cf_sql_varchar", null: !len(taskTypeDescription) },
taskTypeIcon: { value: taskTypeIcon, cfsqltype: "cf_sql_varchar" }, taskTypeIcon: { value: taskTypeIcon, cfsqltype: "cf_sql_varchar" },
taskTypeColor: { value: taskTypeColor, cfsqltype: "cf_sql_varchar" }, taskTypeColor: { value: taskTypeColor, cfsqltype: "cf_sql_varchar" },
businessID: { value: businessID, cfsqltype: "cf_sql_integer" } businessID: { value: businessID, cfsqltype: "cf_sql_integer" },
categoryID: { value: taskTypeCategoryID, cfsqltype: "cf_sql_integer", null: isNull(taskTypeCategoryID) }
}, { datasource: "payfrit" }); }, { datasource: "payfrit" });
qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" }); qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });

View file

@ -0,0 +1,137 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// Seed default task categories for a business
// Input: BusinessID (required)
// Output: { OK: true, CATEGORIES: [...] }
function apiAbort(required struct payload) {
writeOutput(serializeJSON(payload));
abort;
}
function readJsonBody() {
var raw = getHttpRequestData().content;
if (isNull(raw)) raw = "";
if (!len(trim(raw))) return {};
try {
var data = deserializeJSON(raw);
if (isStruct(data)) return data;
} catch (any e) {}
return {};
}
try {
data = readJsonBody();
// Get BusinessID from URL, body, or header
businessID = 0;
httpHeaders = getHttpRequestData().headers;
if (structKeyExists(url, "bid") && isNumeric(url.bid)) {
businessID = int(url.bid);
} else if (structKeyExists(url, "BusinessID") && isNumeric(url.BusinessID)) {
businessID = int(url.BusinessID);
} else if (structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID)) {
businessID = int(data.BusinessID);
} else if (structKeyExists(httpHeaders, "X-Business-ID") && isNumeric(httpHeaders["X-Business-ID"])) {
businessID = int(httpHeaders["X-Business-ID"]);
}
if (businessID == 0) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
}
// Check if categories already exist
qCheck = queryExecute("
SELECT COUNT(*) AS cnt FROM TaskCategories
WHERE TaskCategoryBusinessID = :businessID
", {
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
if (qCheck.cnt > 0) {
// Categories already exist, just return them
qCategories = queryExecute("
SELECT TaskCategoryID, TaskCategoryName, TaskCategoryColor
FROM TaskCategories
WHERE TaskCategoryBusinessID = :businessID AND TaskCategoryIsActive = 1
ORDER BY TaskCategoryName
", {
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
categories = [];
for (row in qCategories) {
arrayAppend(categories, {
"TaskCategoryID": row.TaskCategoryID,
"TaskCategoryName": row.TaskCategoryName,
"TaskCategoryColor": row.TaskCategoryColor
});
}
apiAbort({
"OK": true,
"MESSAGE": "Categories already exist",
"CATEGORIES": categories
});
}
// Default categories for restaurants
defaults = [
{ name: "Service Point", color: "##F44336" }, // Red - for service point requests
{ name: "Kitchen", color: "##FF9800" }, // Orange
{ name: "Bar", color: "##9C27B0" }, // Purple
{ name: "Cleaning", color: "##4CAF50" }, // Green
{ name: "Management", color: "##2196F3" }, // Blue
{ name: "Delivery", color: "##00BCD4" }, // Cyan
{ name: "General", color: "##607D8B" } // Blue Grey
];
// Insert defaults
for (cat in defaults) {
queryExecute("
INSERT INTO TaskCategories (TaskCategoryBusinessID, TaskCategoryName, TaskCategoryColor)
VALUES (:businessID, :name, :color)
", {
businessID: { value: businessID, cfsqltype: "cf_sql_integer" },
name: { value: cat.name, cfsqltype: "cf_sql_varchar" },
color: { value: cat.color, cfsqltype: "cf_sql_varchar" }
}, { datasource: "payfrit" });
}
// Return the created categories
qCategories = queryExecute("
SELECT TaskCategoryID, TaskCategoryName, TaskCategoryColor
FROM TaskCategories
WHERE TaskCategoryBusinessID = :businessID AND TaskCategoryIsActive = 1
ORDER BY TaskCategoryName
", {
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
categories = [];
for (row in qCategories) {
arrayAppend(categories, {
"TaskCategoryID": row.TaskCategoryID,
"TaskCategoryName": row.TaskCategoryName,
"TaskCategoryColor": row.TaskCategoryColor
});
}
apiAbort({
"OK": true,
"MESSAGE": "Created " & arrayLen(defaults) & " default categories",
"CATEGORIES": categories
});
} catch (any e) {
apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": e.message
});
}
</cfscript>

View file

@ -84,7 +84,7 @@
} }
.task-bar.flashing { .task-bar.flashing {
animation: flash 0.5s ease-in-out infinite; animation: flash 1.5s ease-in-out infinite;
} }
@keyframes flash { @keyframes flash {
@ -264,6 +264,56 @@
--task-color-glow: rgba(236, 72, 153, 0.3); --task-color-glow: rgba(236, 72, 153, 0.3);
} }
/* Fullscreen/maximize button */
.fullscreen-btn {
background: transparent;
border: 1px solid #444;
color: #666;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
margin-left: 16px;
}
.fullscreen-btn:hover {
border-color: #666;
color: #888;
}
.fullscreen-btn:active {
transform: scale(0.95);
}
/* Maximized mode - hide header, use full screen */
body.maximized .header {
display: none;
}
body.maximized .task-container {
padding-top: 20px;
}
body.maximized .restore-btn {
display: block;
}
.restore-btn {
display: none;
position: fixed;
top: 10px;
right: 10px;
background: rgba(50, 50, 50, 0.8);
border: none;
color: #888;
padding: 12px 16px;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
z-index: 200;
}
/* Connection status */ /* Connection status */
.status-indicator { .status-indicator {
position: fixed; position: fixed;
@ -289,7 +339,10 @@
<body> <body>
<div class="header"> <div class="header">
<h1>Payfrit Tasks<span id="businessName"></span></h1> <h1>Payfrit Tasks<span id="businessName"></span></h1>
<div style="display: flex; align-items: center;">
<div class="clock" id="clock">--:--:--</div> <div class="clock" id="clock">--:--:--</div>
<button class="fullscreen-btn" id="fullscreenBtn" onclick="toggleFullscreen()"></button>
</div>
</div> </div>
<div class="task-container" id="taskContainer"> <div class="task-container" id="taskContainer">
@ -326,6 +379,7 @@
</div> </div>
<div class="status-indicator" id="statusIndicator"></div> <div class="status-indicator" id="statusIndicator"></div>
<button class="restore-btn" id="restoreBtn" onclick="toggleFullscreen()"></button>
<script src="hud.js"></script> <script src="hud.js"></script>
</body> </body>

View file

@ -623,6 +623,21 @@
</div> </div>
</div> </div>
</div> </div>
<div class="card">
<div class="card-header">
<h3>Customer Preview</h3>
</div>
<div class="card-body">
<p style="color: #666; font-size: 13px; margin-bottom: 12px;">See how your business looks in the Payfrit app. Open this link on your phone to preview your menu as a customer would see it.</p>
<a id="previewAppLink" href="#" onclick="Portal.openCustomerPreview(); return false;" class="btn btn-primary" style="display: inline-flex; align-items: center; gap: 8px; width: 100%; justify-content: center;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4M10 17l5-5-5-5M13 12H3"/>
</svg>
Open in Payfrit App
</a>
</div>
</div>
</div> </div>
</section> </section>
</div> </div>

View file

@ -911,6 +911,12 @@
</button> </button>
</div> </div>
<div class="toolbar-group" style="margin-left: auto;"> <div class="toolbar-group" style="margin-left: auto;">
<button class="toolbar-btn" onclick="MenuBuilder.showMenuManager()" title="Manage Menus" style="font-weight: 600;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
Manage Menus
</button>
<button class="toolbar-btn primary" onclick="MenuBuilder.saveMenu()"> <button class="toolbar-btn primary" onclick="MenuBuilder.saveMenu()">
<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="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/> <path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/>
@ -1014,11 +1020,6 @@
<select id="menuSelector" onchange="MenuBuilder.onMenuSelect(this.value)" style="padding: 8px 12px; border: 1px solid var(--gray-300); border-radius: 6px; background: #fff; font-size: 14px; cursor: pointer;"> <select id="menuSelector" onchange="MenuBuilder.onMenuSelect(this.value)" style="padding: 8px 12px; border: 1px solid var(--gray-300); border-radius: 6px; background: #fff; font-size: 14px; cursor: pointer;">
<option value="0">All Categories</option> <option value="0">All Categories</option>
</select> </select>
<button class="btn btn-sm btn-secondary" onclick="MenuBuilder.showMenuManager()" title="Manage Menus">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/>
</svg>
</button>
</div> </div>
</div> </div>
@ -2829,27 +2830,58 @@
// Upload photo // Upload photo
uploadPhoto(itemId) { uploadPhoto(itemId) {
// Create file input // Find the item and check it has a database ID
const input = document.createElement('input'); let targetItem = null;
input.type = 'file';
input.accept = 'image/*';
input.onchange = async (e) => {
const file = e.target.files[0];
if (file) {
// For demo, just use a data URL
const reader = new FileReader();
reader.onload = () => {
for (const cat of this.menu.categories) { for (const cat of this.menu.categories) {
const item = cat.items.find(i => i.id === itemId); const item = cat.items.find(i => i.id === itemId);
if (item) { if (item) { targetItem = item; break; }
item.imageUrl = reader.result;
break;
} }
if (!targetItem || !targetItem.dbId) {
this.toast('Please save the menu first before uploading photos', 'error');
return;
} }
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!validTypes.includes(file.type)) {
this.toast('Please select a valid image (JPG, PNG, GIF, or WebP)', 'error');
return;
}
if (file.size > 5 * 1024 * 1024) {
this.toast('Image must be under 5MB', 'error');
return;
}
this.toast('Uploading photo...', 'info');
try {
const formData = new FormData();
formData.append('photo', file);
formData.append('ItemID', targetItem.dbId);
const response = await fetch(`${this.config.apiBaseUrl}/menu/uploadItemPhoto.cfm`, {
method: 'POST',
body: formData,
credentials: 'include'
});
const data = await response.json();
if (data.OK) {
targetItem.imageUrl = data.IMAGEURL + '?t=' + Date.now();
this.render(); this.render();
this.toast('Photo uploaded', 'success'); this.toast('Photo uploaded!', 'success');
}; } else {
reader.readAsDataURL(file); this.toast(data.MESSAGE || 'Failed to upload photo', 'error');
}
} catch (err) {
console.error('Photo upload error:', err);
this.toast('Failed to upload photo', 'error');
} }
}; };
input.click(); input.click();
@ -3553,9 +3585,19 @@
} }
console.log('[MenuBuilder] Menu data:', JSON.stringify(this.menu, null, 2)); console.log('[MenuBuilder] Menu data:', JSON.stringify(this.menu, null, 2));
// Deep clone menu and strip base64 data URLs to avoid huge payloads
const menuClone = JSON.parse(JSON.stringify(this.menu));
for (const cat of menuClone.categories) {
for (const item of (cat.items || [])) {
if (item.imageUrl && item.imageUrl.startsWith('data:')) {
item.imageUrl = null;
}
}
}
const payload = { const payload = {
BusinessID: this.config.businessId, BusinessID: this.config.businessId,
Menu: this.menu Menu: menuClone
}; };
console.log('[MenuBuilder] Full payload:', JSON.stringify(payload, null, 2)); console.log('[MenuBuilder] Full payload:', JSON.stringify(payload, null, 2));

View file

@ -1,10 +1,10 @@
/* Payfrit Business Portal - Modern Admin UI */ /* Payfrit Business Portal - Modern Admin UI */
:root { :root {
--primary: #6366f1; --primary: #00d974;
--primary-dark: #4f46e5; --primary-dark: #00b862;
--primary-light: #818cf8; --primary-light: #33ff9f;
--primary-hover: #4f46e5; --primary-hover: #00b862;
--success: #22c55e; --success: #22c55e;
--warning: #f59e0b; --warning: #f59e0b;
--danger: #ef4444; --danger: #ef4444;
@ -396,7 +396,7 @@ body {
} }
.stat-icon.orders { .stat-icon.orders {
background: rgba(99, 102, 241, 0.1); background: rgba(0, 217, 116, 0.1);
color: var(--primary); color: var(--primary);
} }
@ -572,7 +572,7 @@ body {
.form-textarea:focus { .form-textarea:focus {
outline: none; outline: none;
border-color: var(--primary); border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); box-shadow: 0 0 0 3px rgba(0, 217, 116, 0.1);
} }
.form-row { .form-row {
@ -681,7 +681,7 @@ body {
} }
.status-icon.pending { .status-icon.pending {
background: rgba(99, 102, 241, 0.1); background: rgba(0, 217, 116, 0.1);
color: var(--primary); color: var(--primary);
} }
@ -875,7 +875,7 @@ body {
} }
.status-badge.submitted { .status-badge.submitted {
background: rgba(99, 102, 241, 0.1); background: rgba(0, 217, 116, 0.1);
color: var(--primary); color: var(--primary);
} }

View file

@ -1099,6 +1099,16 @@ const Portal = {
}, },
// Connect Stripe - initiate onboarding // Connect Stripe - initiate onboarding
// Open customer preview in Payfrit app via deep link
openCustomerPreview() {
const businessId = this.config.businessId;
const businessName = encodeURIComponent(
this.currentBusiness?.BUSINESSNAME || this.currentBusiness?.BusinessName || 'Preview'
);
const deepLink = `payfrit://open?businessId=${businessId}&businessName=${businessName}`;
window.location.href = deepLink;
},
async connectStripe() { async connectStripe() {
this.toast('Starting Stripe setup...', 'info'); this.toast('Starting Stripe setup...', 'info');

View file

@ -835,6 +835,43 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Community Meal Participation -->
<div class="summary-card" style="margin-top: 16px;">
<div class="summary-card-header">
<h3>Community Meal Participation</h3>
<p style="margin: 4px 0 0; color: var(--gray-500); font-size: 14px;">Choose how this location participates.</p>
</div>
<div class="summary-card-body" style="flex-direction: column; gap: 12px;">
<label style="display: flex; align-items: flex-start; gap: 12px; cursor: pointer; padding: 12px; border: 2px solid var(--primary); border-radius: 8px; background: rgba(99, 102, 241, 0.05);" id="communityMealOption1">
<input type="radio" name="communityMealType" value="1" checked
onchange="updateCommunityMealSelection()"
style="margin-top: 3px; width: 18px; height: 18px; accent-color: var(--primary); flex-shrink: 0;">
<div>
<strong style="display: block; color: var(--gray-900);">Provide Community Meals</strong>
<span style="color: var(--gray-500); font-size: 13px;">Offer one Community Meal per service window and receive a reduced Payfrit fee.</span>
</div>
</label>
<label style="display: flex; align-items: flex-start; gap: 12px; cursor: pointer; padding: 12px; border: 2px solid var(--gray-200); border-radius: 8px;" id="communityMealOption2">
<input type="radio" name="communityMealType" value="2"
onchange="updateCommunityMealSelection()"
style="margin-top: 3px; width: 18px; height: 18px; accent-color: var(--primary); flex-shrink: 0;">
<div>
<strong style="display: block; color: var(--gray-900);">Support a local food bank instead</strong>
<span style="color: var(--gray-500); font-size: 13px;">A combined 1.0% contribution (business + guest) is donated locally.</span>
</div>
</label>
<a href="/uploads/docs/Payfrit_Community_Meal_Participation_One_Pager.pdf" target="_blank"
style="display: inline-flex; align-items: center; gap: 6px; color: var(--primary); font-size: 13px; text-decoration: none; margin-top: 4px;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
Learn more about Community Meal Participation
</a>
</div>
</div>
<div class="action-buttons"> <div class="action-buttons">
<button class="btn btn-outline" onclick="startOver()">Start Over</button> <button class="btn btn-outline" onclick="startOver()">Start Over</button>
<button class="btn btn-success" onclick="saveMenu()"> <button class="btn btn-success" onclick="saveMenu()">
@ -920,6 +957,29 @@
</style> </style>
<script> <script>
// US States (from tt_States)
const US_STATES = [
{abbr:'AL',name:'Alabama'},{abbr:'AK',name:'Alaska'},{abbr:'AZ',name:'Arizona'},{abbr:'AR',name:'Arkansas'},
{abbr:'CA',name:'California'},{abbr:'CO',name:'Colorado'},{abbr:'CT',name:'Connecticut'},{abbr:'DE',name:'Delaware'},
{abbr:'DC',name:'Dist. of Columbia'},{abbr:'FL',name:'Florida'},{abbr:'GA',name:'Georgia'},{abbr:'HI',name:'Hawaii'},
{abbr:'ID',name:'Idaho'},{abbr:'IL',name:'Illinois'},{abbr:'IN',name:'Indiana'},{abbr:'IA',name:'Iowa'},
{abbr:'KS',name:'Kansas'},{abbr:'KY',name:'Kentucky'},{abbr:'LA',name:'Louisiana'},{abbr:'ME',name:'Maine'},
{abbr:'MD',name:'Maryland'},{abbr:'MA',name:'Massachusetts'},{abbr:'MI',name:'Michigan'},{abbr:'MN',name:'Minnesota'},
{abbr:'MS',name:'Mississippi'},{abbr:'MO',name:'Missouri'},{abbr:'MT',name:'Montana'},{abbr:'NE',name:'Nebraska'},
{abbr:'NV',name:'Nevada'},{abbr:'NH',name:'New Hampshire'},{abbr:'NJ',name:'New Jersey'},{abbr:'NM',name:'New Mexico'},
{abbr:'NY',name:'New York'},{abbr:'NC',name:'North Carolina'},{abbr:'ND',name:'North Dakota'},{abbr:'OH',name:'Ohio'},
{abbr:'OK',name:'Oklahoma'},{abbr:'OR',name:'Oregon'},{abbr:'PA',name:'Pennsylvania'},{abbr:'RI',name:'Rhode Island'},
{abbr:'SC',name:'South Carolina'},{abbr:'SD',name:'South Dakota'},{abbr:'TN',name:'Tennessee'},{abbr:'TX',name:'Texas'},
{abbr:'UT',name:'Utah'},{abbr:'VT',name:'Vermont'},{abbr:'VA',name:'Virginia'},{abbr:'WA',name:'Washington'},
{abbr:'WV',name:'West Virginia'},{abbr:'WI',name:'Wisconsin'},{abbr:'WY',name:'Wyoming'}
];
function buildStateOptions(selectedAbbr) {
const upper = (selectedAbbr || '').toUpperCase();
return '<option value="">Select...</option>' +
US_STATES.map(s => `<option value="${s.abbr}"${s.abbr === upper ? ' selected' : ''}>${s.abbr} - ${s.name}</option>`).join('');
}
// Configuration // Configuration
const config = { const config = {
businessId: null, businessId: null,
@ -1223,6 +1283,7 @@
'saturday': 5, 'sat': 5, 'saturday': 5, 'sat': 5,
'sunday': 6, 'sun': 6 'sunday': 6, 'sun': 6
}; };
const dayNames = Object.keys(dayMap);
// Initialize all days as open with defaults // Initialize all days as open with defaults
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {
@ -1233,11 +1294,6 @@
return schedule; return schedule;
} }
// Try to extract a time range
const timePattern = /(\d{1,2}):?(\d{2})?\s*(am|pm|a\.m\.|p\.m\.)?\s*(?:to|[-])\s*(\d{1,2}):?(\d{2})?\s*(am|pm|a\.m\.|p\.m\.)?/i;
const timeMatch = hoursText.match(timePattern);
if (timeMatch) {
const convertTo24Hour = (hour, minute, ampm) => { const convertTo24Hour = (hour, minute, ampm) => {
let h = parseInt(hour); let h = parseInt(hour);
const m = minute ? parseInt(minute) : 0; const m = minute ? parseInt(minute) : 0;
@ -1249,13 +1305,75 @@
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}; };
const timePattern = /(\d{1,2}):?(\d{2})?\s*(am|pm|a\.m\.|p\.m\.)?\s*(?:to|[-])\s*(\d{1,2}):?(\d{2})?\s*(am|pm|a\.m\.|p\.m\.)?/i;
// Day name pattern (matches day abbreviations/names)
const dayPattern = new RegExp('(' + dayNames.join('|') + ')', 'gi');
// Split by comma, semicolon, or newline to get individual segments
const segments = hoursText.split(/[,;\n]+/).map(s => s.trim()).filter(s => s.length);
let appliedAny = false;
for (const segment of segments) {
// Find time range in this segment
const timeMatch = segment.match(timePattern);
if (!timeMatch) continue;
const openTime = convertTo24Hour(timeMatch[1], timeMatch[2], timeMatch[3]); const openTime = convertTo24Hour(timeMatch[1], timeMatch[2], timeMatch[3]);
const closeTime = convertTo24Hour(timeMatch[4], timeMatch[5], timeMatch[6]); const closeTime = convertTo24Hour(timeMatch[4], timeMatch[5], timeMatch[6]);
// Apply extracted time to all days // Find all day names in this segment
const foundDays = [];
let dayMatch;
dayPattern.lastIndex = 0;
while ((dayMatch = dayPattern.exec(segment)) !== null) {
foundDays.push(dayMap[dayMatch[1].toLowerCase()]);
}
if (foundDays.length === 0) {
// No days specified - apply to all days
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {
schedule[i] = { open: openTime, close: closeTime, closed: false }; schedule[i] = { open: openTime, close: closeTime, closed: false };
} }
appliedAny = true;
} else if (foundDays.length >= 2) {
// Check for range (e.g. "Mon-Thu" or "Mon, Tue, Wed, Thur")
// Look for dash/en-dash between two day names indicating a range
const rangeMatch = segment.match(new RegExp('(' + dayNames.join('|') + ')\\s*[-]\\s*(' + dayNames.join('|') + ')', 'i'));
if (rangeMatch) {
// Day range like "Mon-Thu"
const startDay = dayMap[rangeMatch[1].toLowerCase()];
const endDay = dayMap[rangeMatch[2].toLowerCase()];
let d = startDay;
while (true) {
schedule[d] = { open: openTime, close: closeTime, closed: false };
if (d === endDay) break;
d = (d + 1) % 7;
}
} else {
// Comma-separated days like "Mon, Tue, Wed, Thur"
for (const d of foundDays) {
schedule[d] = { open: openTime, close: closeTime, closed: false };
}
}
appliedAny = true;
} else {
// Single day
schedule[foundDays[0]] = { open: openTime, close: closeTime, closed: false };
appliedAny = true;
}
}
// Fallback: if no segments matched, try the whole string as one time
if (!appliedAny) {
const timeMatch = hoursText.match(timePattern);
if (timeMatch) {
const openTime = convertTo24Hour(timeMatch[1], timeMatch[2], timeMatch[3]);
const closeTime = convertTo24Hour(timeMatch[4], timeMatch[5], timeMatch[6]);
for (let i = 0; i < 7; i++) {
schedule[i] = { open: openTime, close: closeTime, closed: false };
}
}
} }
return schedule; return schedule;
@ -1400,7 +1518,9 @@
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div class="extracted-value editable"> <div class="extracted-value editable">
<label style="font-size:12px;color:var(--gray-500);display:block;margin-bottom:4px;">State</label> <label style="font-size:12px;color:var(--gray-500);display:block;margin-bottom:4px;">State</label>
<input type="text" id="bizState" value="${state}" placeholder="CA" maxlength="2"> <select id="bizState" style="padding:8px 12px;border:1px solid var(--gray-300);border-radius:6px;font-size:14px;width:100%;background:white;">
${buildStateOptions(state)}
</select>
</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;">ZIP Code</label> <label style="font-size:12px;color:var(--gray-500);display:block;margin-bottom:4px;">ZIP Code</label>
@ -1411,6 +1531,11 @@
<label style="font-size:12px;color:var(--gray-500);display:block;margin-bottom:4px;">Phone</label> <label style="font-size:12px;color:var(--gray-500);display:block;margin-bottom:4px;">Phone</label>
<input type="text" id="bizPhone" value="${biz.phone || ''}" placeholder="Phone number"> <input type="text" id="bizPhone" value="${biz.phone || ''}" placeholder="Phone number">
</div> </div>
<div class="extracted-value editable">
<label style="font-size:12px;color:var(--gray-500);display:block;margin-bottom:4px;">Sales Tax Rate (%)</label>
<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"> <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;">
@ -1488,10 +1613,77 @@
state: document.getElementById('bizState').value, state: document.getElementById('bizState').value,
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,
hoursSchedule: hoursSchedule // Send the structured schedule instead of the raw hours string hoursSchedule: hoursSchedule // Send the structured schedule instead of the raw hours string
}; };
// Move to categories // Move to header image step
showHeaderImageStep();
}
// Header Image step - between business info and categories
function showHeaderImageStep() {
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>
<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;">
<li><strong>Size:</strong> 1220 x 400 pixels</li>
<li><strong>Format:</strong> JPG or PNG</li>
<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 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
</label>
</div>
<div class="action-buttons" style="margin-top:16px;">
<button class="btn btn-success" onclick="confirmHeaderImage()">
<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
</button>
<button class="btn btn-secondary" onclick="skipHeaderImage()">Skip for Now</button>
</div>
`);
}
function previewWizardHeader(input) {
if (!input.files || !input.files[0]) return;
const file = input.files[0];
if (file.size > 5 * 1024 * 1024) {
showToast('Image must be under 5MB', 'error');
input.value = '';
return;
}
const reader = new FileReader();
reader.onload = function(e) {
const preview = document.getElementById('headerUploadPreview');
preview.style.backgroundImage = `url(${e.target.result})`;
preview.style.display = 'block';
};
reader.readAsDataURL(file);
// Store the file for upload after save
config.headerImageFile = file;
}
function confirmHeaderImage() {
showCategoriesStep();
}
function skipHeaderImage() {
config.headerImageFile = null;
showCategoriesStep(); showCategoriesStep();
} }
@ -1906,7 +2098,7 @@
<strong>${item.name}</strong> <strong>${item.name}</strong>
${item.description ? `<br><small style="color:var(--gray-500);">${item.description}</small>` : ''} ${item.description ? `<br><small style="color:var(--gray-500);">${item.description}</small>` : ''}
</td> </td>
<td>$${(item.price || 0).toFixed(2)}</td> <td>$${parseFloat(item.price || 0).toFixed(2)}</td>
<td> <td>
<div class="item-modifiers"> <div class="item-modifiers">
${(item.modifiers || []).map(m => `<span class="item-modifier-tag">${m}</span>`).join('')} ${(item.modifiers || []).map(m => `<span class="item-modifier-tag">${m}</span>`).join('')}
@ -2024,6 +2216,10 @@
config.extractedData.menuStartTime = menuStartTime; config.extractedData.menuStartTime = menuStartTime;
config.extractedData.menuEndTime = menuEndTime; config.extractedData.menuEndTime = menuEndTime;
// Community meal participation type (1=provide meals, 2=food bank)
const communityMealRadio = document.querySelector('input[name="communityMealType"]:checked');
config.extractedData.communityMealType = communityMealRadio ? parseInt(communityMealRadio.value) : 1;
const saveBtn = document.querySelector('#finalActions .btn-success'); const saveBtn = document.querySelector('#finalActions .btn-success');
const originalText = saveBtn.innerHTML; const originalText = saveBtn.innerHTML;
saveBtn.innerHTML = '<div class="loading-spinner" style="width:16px;height:16px;border-width:2px;"></div> Saving...'; saveBtn.innerHTML = '<div class="loading-spinner" style="width:16px;height:16px;border-width:2px;"></div> Saving...';
@ -2075,11 +2271,37 @@
showToast('Menu saved successfully!', 'success'); showToast('Menu saved successfully!', 'success');
// Use the businessId from the response (in case it was newly created) // Use the businessId from the response (in case it was newly created)
const finalBusinessId = result.summary?.businessId || config.businessId; // Lucee serializes struct keys as uppercase, so check both cases
const summary = result.summary || result.SUMMARY || {};
const finalBusinessId = summary.businessId || summary.BUSINESSID || summary.businessid || config.businessId;
// Update localStorage with the new business ID to keep user logged in // Update localStorage with the new business ID to keep user logged in
localStorage.setItem('payfrit_portal_business', finalBusinessId); localStorage.setItem('payfrit_portal_business', finalBusinessId);
// Upload header image if one was selected
if (config.headerImageFile && finalBusinessId) {
try {
const formData = new FormData();
formData.append('BusinessID', finalBusinessId);
formData.append('header', config.headerImageFile);
const headerResp = await fetch(`${config.apiBaseUrl}/menu/uploadHeader.cfm`, {
method: 'POST',
body: formData
});
const headerResult = await headerResp.json();
if (headerResult.OK) {
console.log('Header image uploaded:', headerResult.HEADERURL);
} else {
console.error('Header upload failed:', headerResult.MESSAGE);
showToast('Menu saved, but header image upload failed. You can upload it later in Settings.', 'warning');
}
} catch (headerErr) {
console.error('Header upload error:', headerErr);
showToast('Menu saved, but header image upload failed. You can upload it later in Settings.', 'warning');
}
}
// Redirect to visual menu builder after a moment // Redirect to visual menu builder after a moment
setTimeout(() => { setTimeout(() => {
window.location.href = 'index.html#menu'; window.location.href = 'index.html#menu';
@ -2207,6 +2429,23 @@
message.scrollIntoView({ behavior: 'smooth' }); message.scrollIntoView({ behavior: 'smooth' });
} }
function updateCommunityMealSelection() {
const selected = document.querySelector('input[name="communityMealType"]:checked').value;
const opt1 = document.getElementById('communityMealOption1');
const opt2 = document.getElementById('communityMealOption2');
if (selected === '1') {
opt1.style.borderColor = 'var(--primary)';
opt1.style.background = 'rgba(99, 102, 241, 0.05)';
opt2.style.borderColor = 'var(--gray-200)';
opt2.style.background = '';
} else {
opt2.style.borderColor = 'var(--primary)';
opt2.style.background = 'rgba(99, 102, 241, 0.05)';
opt1.style.borderColor = 'var(--gray-200)';
opt1.style.background = '';
}
}
function updateProgress(step) { function updateProgress(step) {
config.currentStep = step; config.currentStep = step;
document.querySelectorAll('.progress-step').forEach(el => { document.querySelectorAll('.progress-step').forEach(el => {

655
verticals/hospitals.html Normal file
View file

@ -0,0 +1,655 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Payfrit for Hospitals - Mobile Ordering & Staff Services</title>
<meta name="description" content="Payfrit powers hospital food services, cafeterias, micro-markets, and patient room service. BLE-enabled ordering and task management for healthcare facilities.">
<style>
:root {
--bg-primary: #0a0a0a;
--bg-card: #141414;
--bg-secondary: #1a1a1a;
--text-primary: #ffffff;
--text-secondary: #a0a0a0;
--text-muted: #666666;
--border-color: #2a2a2a;
--primary: #00ff88;
--primary-hover: #00cc6a;
--primary-dim: rgba(0, 255, 136, 0.1);
--accent-blue: #3b82f6;
--accent-purple: #8b5cf6;
--accent-orange: #f59e0b;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
}
/* Navigation */
.nav {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
padding: 20px 40px;
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(10, 10, 10, 0.9);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--border-color);
}
.nav-logo {
display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
color: var(--text-primary);
}
.nav-logo-icon {
width: 40px;
height: 40px;
background: var(--primary);
color: #000;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 700;
}
.nav-logo-text {
font-size: 22px;
font-weight: 600;
}
.nav-links {
display: flex;
align-items: center;
gap: 32px;
}
.nav-link {
color: var(--text-secondary);
text-decoration: none;
font-size: 15px;
transition: color 0.2s;
}
.nav-link:hover {
color: var(--text-primary);
}
.nav-cta {
padding: 10px 24px;
background: var(--primary);
color: #000;
border: none;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
transition: all 0.2s;
}
.nav-cta:hover {
background: var(--primary-hover);
transform: translateY(-1px);
}
/* Hero Section */
.hero {
padding: 160px 40px 100px;
max-width: 1200px;
margin: 0 auto;
text-align: center;
}
.hero-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--primary-dim);
border: 1px solid rgba(0, 255, 136, 0.3);
border-radius: 100px;
font-size: 14px;
color: var(--primary);
margin-bottom: 24px;
}
.hero h1 {
font-size: 56px;
font-weight: 700;
line-height: 1.1;
margin-bottom: 24px;
background: linear-gradient(135deg, #fff 0%, #a0a0a0 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero-subtitle {
font-size: 20px;
color: var(--text-secondary);
max-width: 700px;
margin: 0 auto 40px;
}
.hero-cta-group {
display: flex;
gap: 16px;
justify-content: center;
flex-wrap: wrap;
}
.btn-primary {
padding: 16px 32px;
background: var(--primary);
color: #000;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
transition: all 0.2s;
}
.btn-primary:hover {
background: var(--primary-hover);
transform: translateY(-2px);
}
.btn-secondary {
padding: 16px 32px;
background: transparent;
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
text-decoration: none;
transition: all 0.2s;
}
.btn-secondary:hover {
background: var(--bg-secondary);
border-color: var(--text-muted);
}
/* Use Cases Section */
.section {
padding: 100px 40px;
max-width: 1200px;
margin: 0 auto;
}
.section-header {
text-align: center;
margin-bottom: 60px;
}
.section-label {
font-size: 14px;
font-weight: 600;
color: var(--primary);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 16px;
}
.section-title {
font-size: 40px;
font-weight: 700;
margin-bottom: 16px;
}
.section-subtitle {
font-size: 18px;
color: var(--text-secondary);
max-width: 600px;
margin: 0 auto;
}
/* Use Case Cards */
.use-cases-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
}
.use-case-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 16px;
padding: 32px;
transition: all 0.3s;
}
.use-case-card:hover {
border-color: var(--primary);
transform: translateY(-4px);
}
.use-case-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
font-size: 28px;
}
.use-case-icon.cafeteria { background: rgba(59, 130, 246, 0.15); }
.use-case-icon.retail { background: rgba(139, 92, 246, 0.15); }
.use-case-icon.roomservice { background: rgba(245, 158, 11, 0.15); }
.use-case-icon.staff { background: rgba(0, 255, 136, 0.15); }
.use-case-card h3 {
font-size: 22px;
font-weight: 600;
margin-bottom: 12px;
}
.use-case-card p {
color: var(--text-secondary);
font-size: 15px;
line-height: 1.7;
}
.use-case-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 20px;
}
.use-case-tag {
padding: 6px 12px;
background: var(--bg-secondary);
border-radius: 6px;
font-size: 13px;
color: var(--text-secondary);
}
/* Why Payfrit Section */
.why-section {
background: var(--bg-card);
border-top: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
}
.features-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 40px;
}
.feature {
text-align: center;
}
.feature-icon {
width: 64px;
height: 64px;
background: var(--primary-dim);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
}
.feature-icon svg {
width: 32px;
height: 32px;
stroke: var(--primary);
}
.feature h3 {
font-size: 20px;
font-weight: 600;
margin-bottom: 12px;
}
.feature p {
color: var(--text-secondary);
font-size: 15px;
}
/* Partners Section */
.partners-section {
text-align: center;
}
.partners-note {
color: var(--text-muted);
font-size: 14px;
margin-bottom: 32px;
}
.partners-logos {
display: flex;
justify-content: center;
align-items: center;
gap: 60px;
flex-wrap: wrap;
opacity: 0.6;
}
.partner-logo {
font-size: 24px;
font-weight: 600;
color: var(--text-secondary);
letter-spacing: -0.5px;
}
/* CTA Section */
.cta-section {
text-align: center;
padding: 120px 40px;
}
.cta-card {
background: linear-gradient(135deg, var(--bg-card) 0%, var(--bg-secondary) 100%);
border: 1px solid var(--border-color);
border-radius: 24px;
padding: 80px 40px;
max-width: 800px;
margin: 0 auto;
}
.cta-card h2 {
font-size: 36px;
font-weight: 700;
margin-bottom: 16px;
}
.cta-card p {
color: var(--text-secondary);
font-size: 18px;
margin-bottom: 32px;
}
/* Footer */
.footer {
padding: 40px;
border-top: 1px solid var(--border-color);
text-align: center;
}
.footer-text {
color: var(--text-muted);
font-size: 14px;
}
.footer-links {
margin-top: 16px;
display: flex;
justify-content: center;
gap: 24px;
}
.footer-link {
color: var(--text-secondary);
text-decoration: none;
font-size: 14px;
}
.footer-link:hover {
color: var(--primary);
}
/* Responsive */
@media (max-width: 968px) {
.hero h1 {
font-size: 40px;
}
.use-cases-grid {
grid-template-columns: 1fr;
}
.features-grid {
grid-template-columns: 1fr;
gap: 48px;
}
.nav-links {
display: none;
}
}
@media (max-width: 600px) {
.nav {
padding: 16px 20px;
}
.hero {
padding: 120px 20px 60px;
}
.hero h1 {
font-size: 32px;
}
.section {
padding: 60px 20px;
}
.section-title {
font-size: 28px;
}
.partners-logos {
gap: 32px;
}
}
</style>
</head>
<body>
<!-- Navigation -->
<nav class="nav">
<a href="/" class="nav-logo">
<div class="nav-logo-icon">P</div>
<span class="nav-logo-text">Payfrit</span>
</a>
<div class="nav-links">
<a href="#use-cases" class="nav-link">Use Cases</a>
<a href="#features" class="nav-link">Features</a>
<a href="/portal/signup.html" class="nav-cta">Get Started</a>
</div>
</nav>
<!-- Hero Section -->
<section class="hero">
<div class="hero-badge">
<span>Healthcare</span>
</div>
<h1>Modern Food Services for Modern Hospitals</h1>
<p class="hero-subtitle">
Payfrit powers cafeterias, micro-markets, branded restaurants, and patient room service
with BLE-enabled mobile ordering and intelligent task management.
</p>
<div class="hero-cta-group">
<a href="/portal/signup.html" class="btn-primary">Start Free Trial</a>
<a href="mailto:sales@payfrit.com" class="btn-secondary">Contact Sales</a>
</div>
</section>
<!-- Use Cases Section -->
<section class="section" id="use-cases">
<div class="section-header">
<div class="section-label">Use Cases</div>
<h2 class="section-title">One Platform, Every Touchpoint</h2>
<p class="section-subtitle">
Hospital food service is complex. Payfrit handles cafeterias, retail, room service, and staff operations in a single system.
</p>
</div>
<div class="use-cases-grid">
<div class="use-case-card">
<div class="use-case-icon cafeteria">&#127869;</div>
<h3>Hospital Cafeterias</h3>
<p>
Staff and visitors order from their phones, skip the line, and get notified when food is ready.
Reduce wait times during peak lunch hours and improve throughput.
</p>
<div class="use-case-tags">
<span class="use-case-tag">Mobile ordering</span>
<span class="use-case-tag">Order-ahead</span>
<span class="use-case-tag">Peak management</span>
</div>
</div>
<div class="use-case-card">
<div class="use-case-icon retail">&#127866;</div>
<h3>Branded Retail & Micro-Markets</h3>
<p>
Starbucks, Subway, grab-and-go markets, and gift shops all work with Payfrit.
Unified reporting across all vendors, regardless of who operates them.
</p>
<div class="use-case-tags">
<span class="use-case-tag">Multi-vendor</span>
<span class="use-case-tag">Grab-and-go</span>
<span class="use-case-tag">Gift shops</span>
</div>
</div>
<div class="use-case-card">
<div class="use-case-icon roomservice">&#128717;</div>
<h3>Patient Room Service</h3>
<p>
Patients order meals from bedside tablets or their own devices. Dietary restrictions are enforced automatically.
Kitchen staff see orders by floor and room for efficient delivery.
</p>
<div class="use-case-tags">
<span class="use-case-tag">Dietary compliance</span>
<span class="use-case-tag">Bedside ordering</span>
<span class="use-case-tag">Floor routing</span>
</div>
</div>
<div class="use-case-card">
<div class="use-case-icon staff">&#128736;</div>
<h3>Staff Services & Logistics</h3>
<p>
Beyond food: manage supply requests, equipment transport, housekeeping tasks, and maintenance calls.
Payfrit's task system routes requests to the right team automatically.
</p>
<div class="use-case-tags">
<span class="use-case-tag">Task routing</span>
<span class="use-case-tag">Supply requests</span>
<span class="use-case-tag">Maintenance</span>
</div>
</div>
</div>
</section>
<!-- Why Payfrit Section -->
<section class="section why-section" id="features">
<div class="section-header">
<div class="section-label">Why Payfrit</div>
<h2 class="section-title">Built for Complex Facilities</h2>
<p class="section-subtitle">
Hospitals aren't restaurants. Payfrit's architecture was designed for multi-location, multi-vendor, identity-aware operations.
</p>
</div>
<div class="features-grid">
<div class="feature">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M12 2v4M12 18v4M2 12h4M18 12h4"/>
<path d="M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
</svg>
</div>
<h3>BLE Service Points</h3>
<p>
Bluetooth beacons identify where orders should be delivered. A patient in room 412 or a doctor in the ER lounge
- orders route automatically.
</p>
</div>
<div class="feature">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/>
</svg>
</div>
<h3>Identity-Linked Ordering</h3>
<p>
Connect to staff directories or patient systems. Dietary restrictions, meal allowances, and departmental billing
happen automatically.
</p>
</div>
<div class="feature">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<h3>Intelligent Task System</h3>
<p>
Food orders, supply requests, and service calls all flow through one task system with priority routing,
worker assignment, and completion tracking.
</p>
</div>
</div>
</section>
<!-- Partners Section -->
<section class="section partners-section">
<p class="partners-note">Designed to work with major food service management companies</p>
<div class="partners-logos">
<span class="partner-logo">Sodexo</span>
<span class="partner-logo">Compass Group</span>
<span class="partner-logo">Aramark</span>
</div>
</section>
<!-- CTA Section -->
<section class="cta-section">
<div class="cta-card">
<h2>Ready to modernize hospital food services?</h2>
<p>See how Payfrit can reduce wait times, improve patient satisfaction, and streamline operations.</p>
<div class="hero-cta-group">
<a href="/portal/signup.html" class="btn-primary">Start Free Trial</a>
<a href="mailto:sales@payfrit.com" class="btn-secondary">Schedule Demo</a>
</div>
</div>
</section>
<!-- Footer -->
<footer class="footer">
<p class="footer-text">&copy; 2026 Payfrit. All rights reserved.</p>
<div class="footer-links">
<a href="/privacy.html" class="footer-link">Privacy Policy</a>
<a href="mailto:support@payfrit.com" class="footer-link">Support</a>
</div>
</footer>
</body>
</html>