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:
parent
0d3c381ed6
commit
8f9da2fbf0
44 changed files with 2709 additions and 231 deletions
5
CHANGELOG.md
Normal file
5
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Payfrit Portal Updates
|
||||
|
||||
## Week of 2026-01-26
|
||||
|
||||
- Fixed saved cart not being detected when entering child business
|
||||
37
api/admin/addCategoryIsActiveColumn.cfm
Normal file
37
api/admin/addCategoryIsActiveColumn.cfm
Normal 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>
|
||||
37
api/admin/addServiceCategoryColumn.cfm
Normal file
37
api/admin/addServiceCategoryColumn.cfm
Normal 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>
|
||||
37
api/admin/fixOrphanedTaskCategories.cfm
Normal file
37
api/admin/fixOrphanedTaskCategories.cfm
Normal 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>
|
||||
37
api/admin/quickTasks/addTypeIdColumn.cfm
Normal file
37
api/admin/quickTasks/addTypeIdColumn.cfm
Normal 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>
|
||||
21
api/admin/quickTasks/cleanup.cfm
Normal file
21
api/admin/quickTasks/cleanup.cfm
Normal 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>
|
||||
|
|
@ -52,8 +52,7 @@ try {
|
|||
SELECT
|
||||
QuickTaskTemplateTitle as Title,
|
||||
QuickTaskTemplateDetails as Details,
|
||||
QuickTaskTemplateCategoryID as CategoryID,
|
||||
QuickTaskTemplateTypeID as TypeID
|
||||
QuickTaskTemplateCategoryID as CategoryID
|
||||
FROM QuickTaskTemplates
|
||||
WHERE QuickTaskTemplateID = :id
|
||||
AND QuickTaskTemplateBusinessID = :businessID
|
||||
|
|
@ -67,24 +66,21 @@ try {
|
|||
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Template not found" });
|
||||
}
|
||||
|
||||
// Create the task
|
||||
// Create the task (TaskClaimedByUserID=0 means unclaimed/pending)
|
||||
queryExecute("
|
||||
INSERT INTO Tasks (
|
||||
TaskBusinessID, TaskCategoryID, TaskTypeID,
|
||||
TaskTitle, TaskDetails, TaskStatusID, TaskAddedOn,
|
||||
TaskSourceType, TaskSourceID
|
||||
TaskTitle, TaskDetails, TaskAddedOn, TaskClaimedByUserID
|
||||
) VALUES (
|
||||
:businessID, :categoryID, :typeID,
|
||||
:title, :details, 0, NOW(),
|
||||
'quicktask', :templateID
|
||||
:title, :details, NOW(), 0
|
||||
)
|
||||
", {
|
||||
businessID: { value: businessID, cfsqltype: "cf_sql_integer" },
|
||||
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" },
|
||||
details: { value: qTemplate.Details, cfsqltype: "cf_sql_longvarchar", null: isNull(qTemplate.Details) },
|
||||
templateID: { value: templateID, cfsqltype: "cf_sql_integer" }
|
||||
details: { value: qTemplate.Details, cfsqltype: "cf_sql_longvarchar", null: isNull(qTemplate.Details) }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
|
||||
|
|
|
|||
39
api/admin/quickTasks/debug.cfm
Normal file
39
api/admin/quickTasks/debug.cfm
Normal 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>
|
||||
|
|
@ -48,7 +48,6 @@ try {
|
|||
qt.QuickTaskTemplateID,
|
||||
qt.QuickTaskTemplateName as Name,
|
||||
qt.QuickTaskTemplateCategoryID as CategoryID,
|
||||
qt.QuickTaskTemplateTypeID as TypeID,
|
||||
qt.QuickTaskTemplateTitle as Title,
|
||||
qt.QuickTaskTemplateDetails as Details,
|
||||
qt.QuickTaskTemplateIcon as Icon,
|
||||
|
|
@ -72,7 +71,6 @@ try {
|
|||
"QuickTaskTemplateID": row.QuickTaskTemplateID,
|
||||
"Name": row.Name,
|
||||
"CategoryID": isNull(row.CategoryID) ? "" : row.CategoryID,
|
||||
"TypeID": isNull(row.TypeID) ? "" : row.TypeID,
|
||||
"Title": row.Title,
|
||||
"Details": isNull(row.Details) ? "" : row.Details,
|
||||
"Icon": isNull(row.Icon) ? "add_box" : row.Icon,
|
||||
|
|
|
|||
20
api/admin/quickTasks/purge.cfm
Normal file
20
api/admin/quickTasks/purge.cfm
Normal 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>
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
<cfscript>
|
||||
// Create or update a quick task template
|
||||
// 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 }
|
||||
|
||||
function apiAbort(required struct payload) {
|
||||
|
|
@ -46,8 +46,12 @@ try {
|
|||
templateName = structKeyExists(data, "Name") ? trim(toString(data.Name)) : "";
|
||||
templateTitle = structKeyExists(data, "Title") ? trim(toString(data.Title)) : "";
|
||||
templateDetails = structKeyExists(data, "Details") ? trim(toString(data.Details)) : "";
|
||||
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", "");
|
||||
hasCategory = false;
|
||||
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";
|
||||
templateColor = structKeyExists(data, "Color") && len(trim(data.Color)) ? trim(toString(data.Color)) : "##6366f1";
|
||||
|
||||
|
|
@ -58,6 +62,9 @@ try {
|
|||
if (!len(templateTitle)) {
|
||||
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) {
|
||||
// UPDATE existing template
|
||||
|
|
@ -79,7 +86,6 @@ try {
|
|||
QuickTaskTemplateTitle = :title,
|
||||
QuickTaskTemplateDetails = :details,
|
||||
QuickTaskTemplateCategoryID = :categoryID,
|
||||
QuickTaskTemplateTypeID = :typeID,
|
||||
QuickTaskTemplateIcon = :icon,
|
||||
QuickTaskTemplateColor = :color
|
||||
WHERE QuickTaskTemplateID = :id
|
||||
|
|
@ -87,8 +93,7 @@ try {
|
|||
name: { value: templateName, cfsqltype: "cf_sql_varchar" },
|
||||
title: { value: templateTitle, cfsqltype: "cf_sql_varchar" },
|
||||
details: { value: templateDetails, cfsqltype: "cf_sql_longvarchar", null: !len(templateDetails) },
|
||||
categoryID: { value: categoryID, cfsqltype: "cf_sql_integer", null: isNull(categoryID) },
|
||||
typeID: { value: typeID, cfsqltype: "cf_sql_integer", null: isNull(typeID) },
|
||||
categoryID: { value: catID, cfsqltype: "cf_sql_integer", null: !hasCategory },
|
||||
icon: { value: templateIcon, cfsqltype: "cf_sql_varchar" },
|
||||
color: { value: templateColor, cfsqltype: "cf_sql_varchar" },
|
||||
id: { value: templateID, cfsqltype: "cf_sql_integer" }
|
||||
|
|
@ -115,18 +120,17 @@ try {
|
|||
queryExecute("
|
||||
INSERT INTO QuickTaskTemplates (
|
||||
QuickTaskTemplateBusinessID, QuickTaskTemplateName, QuickTaskTemplateTitle,
|
||||
QuickTaskTemplateDetails, QuickTaskTemplateCategoryID, QuickTaskTemplateTypeID,
|
||||
QuickTaskTemplateDetails, QuickTaskTemplateCategoryID,
|
||||
QuickTaskTemplateIcon, QuickTaskTemplateColor, QuickTaskTemplateSortOrder
|
||||
) 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" },
|
||||
name: { value: templateName, cfsqltype: "cf_sql_varchar" },
|
||||
title: { value: templateTitle, cfsqltype: "cf_sql_varchar" },
|
||||
details: { value: templateDetails, cfsqltype: "cf_sql_longvarchar", null: !len(templateDetails) },
|
||||
categoryID: { value: categoryID, cfsqltype: "cf_sql_integer", null: isNull(categoryID) },
|
||||
typeID: { value: typeID, cfsqltype: "cf_sql_integer", null: isNull(typeID) },
|
||||
categoryID: { value: catID, cfsqltype: "cf_sql_integer", null: !hasCategory },
|
||||
icon: { value: templateIcon, cfsqltype: "cf_sql_varchar" },
|
||||
color: { value: templateColor, cfsqltype: "cf_sql_varchar" },
|
||||
sortOrder: { value: nextSort, cfsqltype: "cf_sql_integer" }
|
||||
|
|
@ -145,7 +149,10 @@ try {
|
|||
apiAbort({
|
||||
"OK": false,
|
||||
"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>
|
||||
|
|
|
|||
|
|
@ -48,10 +48,11 @@ try {
|
|||
st.ScheduledTaskID,
|
||||
st.ScheduledTaskName as Name,
|
||||
st.ScheduledTaskCategoryID as CategoryID,
|
||||
st.ScheduledTaskTypeID as TypeID,
|
||||
st.ScheduledTaskTitle as Title,
|
||||
st.ScheduledTaskDetails as Details,
|
||||
st.ScheduledTaskCronExpression as CronExpression,
|
||||
COALESCE(st.ScheduledTaskScheduleType, 'cron') as ScheduleType,
|
||||
st.ScheduledTaskIntervalMinutes as IntervalMinutes,
|
||||
st.ScheduledTaskIsActive as IsActive,
|
||||
st.ScheduledTaskLastRunOn as LastRunOn,
|
||||
st.ScheduledTaskNextRunOn as NextRunOn,
|
||||
|
|
@ -72,10 +73,11 @@ try {
|
|||
"ScheduledTaskID": row.ScheduledTaskID,
|
||||
"Name": row.Name,
|
||||
"CategoryID": isNull(row.CategoryID) ? "" : row.CategoryID,
|
||||
"TypeID": isNull(row.TypeID) ? "" : row.TypeID,
|
||||
"Title": row.Title,
|
||||
"Details": isNull(row.Details) ? "" : row.Details,
|
||||
"CronExpression": row.CronExpression,
|
||||
"ScheduleType": row.ScheduleType,
|
||||
"IntervalMinutes": isNull(row.IntervalMinutes) ? "" : row.IntervalMinutes,
|
||||
"IsActive": row.IsActive ? true : false,
|
||||
"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"),
|
||||
|
|
|
|||
|
|
@ -53,8 +53,7 @@ try {
|
|||
SELECT
|
||||
ScheduledTaskTitle as Title,
|
||||
ScheduledTaskDetails as Details,
|
||||
ScheduledTaskCategoryID as CategoryID,
|
||||
ScheduledTaskTypeID as TypeID
|
||||
ScheduledTaskCategoryID as CategoryID
|
||||
FROM ScheduledTaskDefinitions
|
||||
WHERE ScheduledTaskID = :id AND ScheduledTaskBusinessID = :businessID
|
||||
", {
|
||||
|
|
@ -66,24 +65,21 @@ try {
|
|||
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Scheduled task not found" });
|
||||
}
|
||||
|
||||
// Create the task
|
||||
// Create the task (TaskClaimedByUserID=0 means unclaimed/pending)
|
||||
queryExecute("
|
||||
INSERT INTO Tasks (
|
||||
TaskBusinessID, TaskCategoryID, TaskTypeID,
|
||||
TaskTitle, TaskDetails, TaskStatusID, TaskAddedOn,
|
||||
TaskSourceType, TaskSourceID
|
||||
TaskTitle, TaskDetails, TaskAddedOn, TaskClaimedByUserID
|
||||
) VALUES (
|
||||
:businessID, :categoryID, :typeID,
|
||||
:title, :details, 0, NOW(),
|
||||
'scheduled_manual', :scheduledTaskID
|
||||
:title, :details, NOW(), 0
|
||||
)
|
||||
", {
|
||||
businessID: { value: businessID, cfsqltype: "cf_sql_integer" },
|
||||
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" },
|
||||
details: { value: qDef.Details, cfsqltype: "cf_sql_longvarchar", null: isNull(qDef.Details) },
|
||||
scheduledTaskID: { value: scheduledTaskID, cfsqltype: "cf_sql_integer" }
|
||||
details: { value: qDef.Details, cfsqltype: "cf_sql_longvarchar", null: isNull(qDef.Details) }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
|
||||
|
|
|
|||
|
|
@ -70,10 +70,11 @@ try {
|
|||
ScheduledTaskID,
|
||||
ScheduledTaskBusinessID as BusinessID,
|
||||
ScheduledTaskCategoryID as CategoryID,
|
||||
ScheduledTaskTypeID as TypeID,
|
||||
ScheduledTaskTitle as Title,
|
||||
ScheduledTaskDetails as Details,
|
||||
ScheduledTaskCronExpression as CronExpression
|
||||
ScheduledTaskCronExpression as CronExpression,
|
||||
COALESCE(ScheduledTaskScheduleType, 'cron') as ScheduleType,
|
||||
ScheduledTaskIntervalMinutes as IntervalMinutes
|
||||
FROM ScheduledTaskDefinitions
|
||||
WHERE ScheduledTaskIsActive = 1
|
||||
AND ScheduledTaskNextRunOn <= NOW()
|
||||
|
|
@ -82,30 +83,37 @@ try {
|
|||
createdTasks = [];
|
||||
|
||||
for (task in dueTasks) {
|
||||
// Create the actual task
|
||||
// Create the actual task (TaskClaimedByUserID=0 means unclaimed/pending)
|
||||
queryExecute("
|
||||
INSERT INTO Tasks (
|
||||
TaskBusinessID, TaskCategoryID, TaskTypeID,
|
||||
TaskTitle, TaskDetails, TaskStatusID, TaskAddedOn,
|
||||
TaskSourceType, TaskSourceID
|
||||
TaskTitle, TaskDetails, TaskAddedOn, TaskClaimedByUserID
|
||||
) VALUES (
|
||||
:businessID, :categoryID, :typeID,
|
||||
:title, :details, 0, NOW(),
|
||||
'scheduled', :scheduledTaskID
|
||||
:title, :details, NOW(), 0
|
||||
)
|
||||
", {
|
||||
businessID: { value: task.BusinessID, cfsqltype: "cf_sql_integer" },
|
||||
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" },
|
||||
details: { value: task.Details, cfsqltype: "cf_sql_longvarchar", null: isNull(task.Details) },
|
||||
scheduledTaskID: { value: task.ScheduledTaskID, cfsqltype: "cf_sql_integer" }
|
||||
details: { value: task.Details, cfsqltype: "cf_sql_longvarchar", null: isNull(task.Details) }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
|
||||
|
||||
// Calculate next run and update the scheduled task
|
||||
nextRun = calculateNextRun(task.CronExpression);
|
||||
// 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);
|
||||
}
|
||||
|
||||
queryExecute("
|
||||
UPDATE ScheduledTaskDefinitions SET
|
||||
|
|
@ -113,7 +121,7 @@ try {
|
|||
ScheduledTaskNextRunOn = :nextRun
|
||||
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" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
<cfscript>
|
||||
// Create or update a scheduled task definition
|
||||
// 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 }
|
||||
|
||||
function apiAbort(required struct payload) {
|
||||
|
|
@ -108,10 +109,13 @@ try {
|
|||
taskTitle = structKeyExists(data, "Title") ? trim(toString(data.Title)) : "";
|
||||
taskDetails = structKeyExists(data, "Details") ? trim(toString(data.Details)) : "";
|
||||
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)) : "";
|
||||
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
|
||||
if (!len(taskName)) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Name is required" });
|
||||
|
|
@ -119,19 +123,37 @@ try {
|
|||
if (!len(taskTitle)) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Title is required" });
|
||||
}
|
||||
if (!len(cronExpression)) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "CronExpression is required" });
|
||||
}
|
||||
|
||||
// Validate cron format (5 parts)
|
||||
cronParts = listToArray(cronExpression, " ");
|
||||
if (arrayLen(cronParts) != 5) {
|
||||
apiAbort({ "OK": false, "ERROR": "invalid_cron", "MESSAGE": "Cron expression must have 5 parts: minute hour day month weekday" });
|
||||
// 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)) {
|
||||
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "CronExpression is required" });
|
||||
}
|
||||
// Validate cron format (5 parts)
|
||||
cronParts = listToArray(cronExpression, " ");
|
||||
if (arrayLen(cronParts) != 5) {
|
||||
apiAbort({ "OK": false, "ERROR": "invalid_cron", "MESSAGE": "Cron expression must have 5 parts: minute hour day month weekday" });
|
||||
}
|
||||
// Calculate next run time from cron
|
||||
nextRunOn = calculateNextRun(cronExpression);
|
||||
}
|
||||
|
||||
// Calculate next run time
|
||||
nextRunOn = calculateNextRun(cronExpression);
|
||||
|
||||
if (taskID > 0) {
|
||||
// UPDATE existing
|
||||
qCheck = queryExecute("
|
||||
|
|
@ -152,8 +174,9 @@ try {
|
|||
ScheduledTaskTitle = :title,
|
||||
ScheduledTaskDetails = :details,
|
||||
ScheduledTaskCategoryID = :categoryID,
|
||||
ScheduledTaskTypeID = :typeID,
|
||||
ScheduledTaskCronExpression = :cron,
|
||||
ScheduledTaskScheduleType = :scheduleType,
|
||||
ScheduledTaskIntervalMinutes = :intervalMinutes,
|
||||
ScheduledTaskIsActive = :isActive,
|
||||
ScheduledTaskNextRunOn = :nextRun
|
||||
WHERE ScheduledTaskID = :id
|
||||
|
|
@ -162,8 +185,9 @@ try {
|
|||
title: { value: taskTitle, cfsqltype: "cf_sql_varchar" },
|
||||
details: { value: taskDetails, cfsqltype: "cf_sql_longvarchar", null: !len(taskDetails) },
|
||||
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" },
|
||||
scheduleType: { value: scheduleType, cfsqltype: "cf_sql_varchar" },
|
||||
intervalMinutes: { value: intervalMinutes, cfsqltype: "cf_sql_integer", null: isNull(intervalMinutes) },
|
||||
isActive: { value: isActive, cfsqltype: "cf_sql_bit" },
|
||||
nextRun: { value: nextRunOn, cfsqltype: "cf_sql_timestamp" },
|
||||
id: { value: taskID, cfsqltype: "cf_sql_integer" }
|
||||
|
|
@ -181,10 +205,11 @@ try {
|
|||
queryExecute("
|
||||
INSERT INTO ScheduledTaskDefinitions (
|
||||
ScheduledTaskBusinessID, ScheduledTaskName, ScheduledTaskTitle,
|
||||
ScheduledTaskDetails, ScheduledTaskCategoryID, ScheduledTaskTypeID,
|
||||
ScheduledTaskCronExpression, ScheduledTaskIsActive, ScheduledTaskNextRunOn
|
||||
ScheduledTaskDetails, ScheduledTaskCategoryID,
|
||||
ScheduledTaskCronExpression, ScheduledTaskScheduleType, ScheduledTaskIntervalMinutes,
|
||||
ScheduledTaskIsActive, ScheduledTaskNextRunOn
|
||||
) 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" },
|
||||
|
|
@ -192,19 +217,65 @@ try {
|
|||
title: { value: taskTitle, cfsqltype: "cf_sql_varchar" },
|
||||
details: { value: taskDetails, cfsqltype: "cf_sql_longvarchar", null: !len(taskDetails) },
|
||||
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" },
|
||||
scheduleType: { value: scheduleType, cfsqltype: "cf_sql_varchar" },
|
||||
intervalMinutes: { value: intervalMinutes, cfsqltype: "cf_sql_integer", null: isNull(intervalMinutes) },
|
||||
isActive: { value: isActive, cfsqltype: "cf_sql_bit" },
|
||||
nextRun: { value: nextRunOn, cfsqltype: "cf_sql_timestamp" }
|
||||
}, { 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({
|
||||
"OK": true,
|
||||
"SCHEDULED_TASK_ID": qNew.newID,
|
||||
"NEXT_RUN": dateTimeFormat(nextRunOn, "yyyy-mm-dd HH:nn:ss"),
|
||||
"MESSAGE": "Scheduled task created"
|
||||
"SCHEDULED_TASK_ID": newScheduledTaskID,
|
||||
"TASK_ID": qTask.taskID,
|
||||
"NEXT_RUN": isNull(actualNextRun) ? "" : dateTimeFormat(actualNextRun, "yyyy-mm-dd HH:nn:ss"),
|
||||
"MESSAGE": "Scheduled task created and first task added"
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ try {
|
|||
ScheduledTaskTitle VARCHAR(255) NOT NULL,
|
||||
ScheduledTaskDetails TEXT NULL,
|
||||
ScheduledTaskCronExpression VARCHAR(100) NOT NULL,
|
||||
ScheduledTaskScheduleType VARCHAR(20) DEFAULT 'cron',
|
||||
ScheduledTaskIntervalMinutes INT NULL,
|
||||
ScheduledTaskIsActive BIT(1) DEFAULT b'1',
|
||||
ScheduledTaskLastRunOn DATETIME NULL,
|
||||
ScheduledTaskNextRunOn DATETIME NULL,
|
||||
|
|
@ -33,9 +35,28 @@ try {
|
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
", [], { 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({
|
||||
"OK": true,
|
||||
"MESSAGE": "ScheduledTaskDefinitions table created/verified"
|
||||
"MESSAGE": "ScheduledTaskDefinitions table created/verified with interval support"
|
||||
});
|
||||
|
||||
} catch (any e) {
|
||||
|
|
|
|||
|
|
@ -97,9 +97,11 @@ try {
|
|||
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("
|
||||
SELECT ScheduledTaskID, ScheduledTaskCronExpression as CronExpression
|
||||
SELECT ScheduledTaskID, ScheduledTaskCronExpression as CronExpression,
|
||||
COALESCE(ScheduledTaskScheduleType, 'cron') as ScheduleType,
|
||||
ScheduledTaskIntervalMinutes as IntervalMinutes
|
||||
FROM ScheduledTaskDefinitions
|
||||
WHERE ScheduledTaskID = :id AND ScheduledTaskBusinessID = :businessID
|
||||
", {
|
||||
|
|
@ -111,10 +113,16 @@ try {
|
|||
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 = "";
|
||||
if (isActive) {
|
||||
nextRunOn = calculateNextRun(qCheck.CronExpression);
|
||||
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);
|
||||
}
|
||||
nextRunUpdate = ", ScheduledTaskNextRunOn = :nextRun";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@ try {
|
|||
BusinessStripeOnboardingComplete,
|
||||
BusinessIsHiring,
|
||||
BusinessHeaderImageExtension,
|
||||
BusinessTaxRate
|
||||
BusinessTaxRate,
|
||||
BusinessBrandColor
|
||||
FROM Businesses
|
||||
WHERE BusinessID = :businessID
|
||||
", { businessID: businessID }, { datasource: "payfrit" });
|
||||
|
|
@ -139,7 +140,8 @@ try {
|
|||
"StripeConnected": (len(q.BusinessStripeAccountID) > 0 && q.BusinessStripeOnboardingComplete == 1),
|
||||
"IsHiring": q.BusinessIsHiring == 1,
|
||||
"TaxRate": taxRate,
|
||||
"TaxRatePercent": taxRate * 100
|
||||
"TaxRatePercent": taxRate * 100,
|
||||
"BrandColor": len(q.BusinessBrandColor) ? q.BusinessBrandColor : ""
|
||||
};
|
||||
|
||||
// Add header image URL if extension exists
|
||||
|
|
|
|||
|
|
@ -39,11 +39,16 @@ brandColor = structKeyExists(data, "BrandColor") && isSimpleValue(data.BrandColo
|
|||
|
||||
// Allow empty to clear, or validate hex format
|
||||
if (len(brandColor) GT 0) {
|
||||
// Must start with # and have 6 hex chars
|
||||
if (left(brandColor, 1) != chr(35) || len(brandColor) != 7 || !reFind("^[0-9A-Fa-f]{6}$", right(brandColor, 6))) {
|
||||
apiAbort({ "OK": false, "ERROR": "invalid_color", "MESSAGE": "Color must be in ##RRGGBB format" });
|
||||
// Strip leading # if present
|
||||
if (left(brandColor, 1) == chr(35)) {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
*/
|
||||
|
||||
// Mode: "test" or "live"
|
||||
stripeMode = "live";
|
||||
stripeMode = "test";
|
||||
|
||||
// Test keys (safe to commit)
|
||||
stripeTestSecretKey = "sk_test_LfbmDduJxTwbVZmvcByYmirw";
|
||||
|
|
|
|||
|
|
@ -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], {
|
||||
"id": "item_" & qItems.ItemID[i],
|
||||
"dbId": qItems.ItemID[i],
|
||||
"name": qItems.ItemName[i],
|
||||
"description": isNull(qItems.ItemDescription[i]) ? "" : qItems.ItemDescription[i],
|
||||
"price": qItems.ItemPrice[i],
|
||||
"imageUrl": javaCast("null", ""),
|
||||
"imageUrl": len(itemImageUrl) ? itemImageUrl : javaCast("null", ""),
|
||||
"photoTaskId": javaCast("null", ""),
|
||||
"modifiers": itemModifiers,
|
||||
"sortOrder": qItems.ItemSortOrder[i]
|
||||
|
|
|
|||
68
api/menu/uploadItemPhoto.cfm
Normal file
68
api/menu/uploadItemPhoto.cfm
Normal 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>
|
||||
|
|
@ -100,19 +100,18 @@ try {
|
|||
lineItems = [];
|
||||
itemsById = {};
|
||||
|
||||
// First pass: create all items
|
||||
// First pass: create all items (use bracket notation to preserve key casing)
|
||||
for (row in qItems) {
|
||||
item = {
|
||||
"LineItemID": val(row.OrderLineItemID),
|
||||
"ItemID": val(row.OrderLineItemItemID),
|
||||
"ParentLineItemID": val(row.OrderLineItemParentOrderLineItemID),
|
||||
"ItemName": row.ItemName ?: "",
|
||||
"Quantity": val(row.OrderLineItemQuantity),
|
||||
"UnitPrice": val(row.OrderLineItemPrice),
|
||||
"Remarks": row.OrderLineItemRemark ?: "",
|
||||
"IsDefault": (val(row.ItemIsCheckedByDefault) == 1),
|
||||
"Modifiers": []
|
||||
};
|
||||
item = structNew("ordered");
|
||||
item["LineItemID"] = val(row.OrderLineItemID);
|
||||
item["ItemID"] = val(row.OrderLineItemItemID);
|
||||
item["ParentLineItemID"] = val(row.OrderLineItemParentOrderLineItemID);
|
||||
item["ItemName"] = row.ItemName ?: "";
|
||||
item["Quantity"] = val(row.OrderLineItemQuantity);
|
||||
item["UnitPrice"] = val(row.OrderLineItemPrice);
|
||||
item["Remarks"] = row.OrderLineItemRemark ?: "";
|
||||
item["IsDefault"] = (val(row.ItemIsCheckedByDefault) == 1);
|
||||
item["Modifiers"] = [];
|
||||
itemsById[row.OrderLineItemID] = item;
|
||||
}
|
||||
|
||||
|
|
@ -122,21 +121,21 @@ try {
|
|||
parentID = row.OrderLineItemParentOrderLineItemID;
|
||||
|
||||
if (parentID > 0 && structKeyExists(itemsById, parentID)) {
|
||||
// This is a modifier - add to parent
|
||||
arrayAppend(itemsById[parentID].Modifiers, item);
|
||||
// This is a modifier - add to parent (use bracket notation)
|
||||
arrayAppend(itemsById[parentID]["Modifiers"], item);
|
||||
} else {
|
||||
// This is a top-level item
|
||||
arrayAppend(lineItems, item);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate subtotal from root line items
|
||||
// Calculate subtotal from root line items (use bracket notation)
|
||||
subtotal = 0;
|
||||
for (item in lineItems) {
|
||||
itemTotal = item.UnitPrice * item.Quantity;
|
||||
itemTotal = item["UnitPrice"] * item["Quantity"];
|
||||
// Add modifier prices
|
||||
for (mod in item.Modifiers) {
|
||||
itemTotal += mod.UnitPrice * mod.Quantity;
|
||||
for (mod in item["Modifiers"]) {
|
||||
itemTotal += mod["UnitPrice"] * mod["Quantity"];
|
||||
}
|
||||
subtotal += itemTotal;
|
||||
}
|
||||
|
|
@ -151,57 +150,68 @@ try {
|
|||
// Calculate total
|
||||
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("
|
||||
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
|
||||
INNER JOIN Users u ON u.UserID = t.TaskClaimedByUserID
|
||||
WHERE t.TaskOrderID = :orderID
|
||||
AND t.TaskClaimedByUserID > 0
|
||||
", { orderID: orderID });
|
||||
|
||||
// Build staff array with avatar URLs
|
||||
// Build staff array with avatar URLs and rating tokens (use ordered structs)
|
||||
staff = [];
|
||||
for (row in qStaff) {
|
||||
arrayAppend(staff, {
|
||||
"UserID": row.UserID,
|
||||
"FirstName": row.UserFirstName,
|
||||
"AvatarUrl": "https://biz.payfrit.com/uploads/users/" & row.UserID & ".jpg"
|
||||
});
|
||||
staffMember = structNew("ordered");
|
||||
staffMember["UserID"] = row.UserID;
|
||||
staffMember["FirstName"] = row.UserFirstName;
|
||||
staffMember["AvatarUrl"] = "https://biz.payfrit.com/uploads/users/" & row.UserID & ".jpg";
|
||||
staffMember["RatingToken"] = row.RatingToken ?: "";
|
||||
arrayAppend(staff, staffMember);
|
||||
}
|
||||
|
||||
// Build response
|
||||
order = {
|
||||
"OrderID": qOrder.OrderID,
|
||||
"BusinessID": qOrder.OrderBusinessID,
|
||||
"BusinessName": qOrder.BusinessName ?: "",
|
||||
"Status": qOrder.OrderStatusID,
|
||||
"StatusText": getStatusText(qOrder.OrderStatusID),
|
||||
"OrderTypeID": qOrder.OrderTypeID ?: 0,
|
||||
"OrderTypeName": getOrderTypeName(qOrder.OrderTypeID ?: 0),
|
||||
"Subtotal": subtotal,
|
||||
"Tax": tax,
|
||||
"Tip": tip,
|
||||
"Total": total,
|
||||
"Notes": qOrder.OrderRemarks,
|
||||
"CreatedOn": dateTimeFormat(qOrder.OrderAddedOn, "yyyy-mm-dd HH:nn:ss"),
|
||||
"SubmittedOn": len(qOrder.OrderSubmittedOn) ? dateTimeFormat(qOrder.OrderSubmittedOn, "yyyy-mm-dd HH:nn:ss") : "",
|
||||
"UpdatedOn": len(qOrder.OrderLastEditedOn) ? dateTimeFormat(qOrder.OrderLastEditedOn, "yyyy-mm-dd HH:nn:ss") : "",
|
||||
"Customer": {
|
||||
"UserID": qOrder.OrderUserID,
|
||||
"FirstName": qOrder.UserFirstName,
|
||||
"LastName": qOrder.UserLastName,
|
||||
"Phone": qOrder.UserContactNumber,
|
||||
"Email": qOrder.UserEmailAddress
|
||||
},
|
||||
"ServicePoint": {
|
||||
"ServicePointID": qOrder.OrderServicePointID,
|
||||
"Name": qOrder.ServicePointName,
|
||||
"TypeID": qOrder.ServicePointTypeID
|
||||
},
|
||||
"LineItems": lineItems,
|
||||
"Staff": staff
|
||||
};
|
||||
// Build response (use ordered structs to preserve key casing)
|
||||
customer = structNew("ordered");
|
||||
customer["UserID"] = qOrder.OrderUserID;
|
||||
customer["FirstName"] = qOrder.UserFirstName;
|
||||
customer["LastName"] = qOrder.UserLastName;
|
||||
customer["Phone"] = qOrder.UserContactNumber;
|
||||
customer["Email"] = qOrder.UserEmailAddress;
|
||||
|
||||
servicePoint = structNew("ordered");
|
||||
servicePoint["ServicePointID"] = qOrder.OrderServicePointID;
|
||||
servicePoint["Name"] = qOrder.ServicePointName;
|
||||
servicePoint["TypeID"] = qOrder.ServicePointTypeID;
|
||||
|
||||
order = structNew("ordered");
|
||||
order["OrderID"] = qOrder.OrderID;
|
||||
order["BusinessID"] = qOrder.OrderBusinessID;
|
||||
order["BusinessName"] = qOrder.BusinessName ?: "";
|
||||
order["Status"] = qOrder.OrderStatusID;
|
||||
order["StatusText"] = getStatusText(qOrder.OrderStatusID);
|
||||
order["OrderTypeID"] = qOrder.OrderTypeID ?: 0;
|
||||
order["OrderTypeName"] = getOrderTypeName(qOrder.OrderTypeID ?: 0);
|
||||
order["Subtotal"] = subtotal;
|
||||
order["Tax"] = tax;
|
||||
order["Tip"] = tip;
|
||||
order["Total"] = total;
|
||||
order["Notes"] = qOrder.OrderRemarks;
|
||||
order["CreatedOn"] = dateTimeFormat(qOrder.OrderAddedOn, "yyyy-mm-dd HH:nn:ss");
|
||||
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") : "";
|
||||
order["Customer"] = customer;
|
||||
order["ServicePoint"] = servicePoint;
|
||||
order["LineItems"] = lineItems;
|
||||
order["Staff"] = staff;
|
||||
|
||||
response["OK"] = true;
|
||||
response["ORDER"] = order;
|
||||
|
|
|
|||
122
api/ratings/createAdminRating.cfm
Normal file
122
api/ratings/createAdminRating.cfm
Normal 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>
|
||||
92
api/ratings/listForAdmin.cfm
Normal file
92
api/ratings/listForAdmin.cfm
Normal 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
59
api/ratings/setup.cfm
Normal 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
144
api/ratings/submit.cfm
Normal 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>
|
||||
|
|
@ -94,7 +94,7 @@
|
|||
</cfif>
|
||||
|
||||
<!--- System prompt for per-image analysis --->
|
||||
<cfset systemPrompt = "You are an expert at extracting structured menu data from restaurant menu images. Extract ALL data visible on THIS image only. Return valid JSON with these keys: business (object with name, address, phone, hours - only if visible), categories (array of category names visible), modifiers (array of modifier templates with name, required boolean, appliesTo (string: 'category', 'item', or 'uncertain'), categoryName (if appliesTo='category'), and options array where each option is an object with 'name' and 'price' keys), items (array with name, description, price, category, and modifiers array). 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 --->
|
||||
<cfset allResults = arrayNew(1)>
|
||||
|
|
@ -190,14 +190,52 @@
|
|||
|
||||
<!--- 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 bizFields = "name,address,addressLine1,city,state,zip,phone,hours">
|
||||
<cfloop array="#allResults#" index="result">
|
||||
<cfif structKeyExists(result, "business") AND isStruct(result.business)>
|
||||
<cfif structKeyExists(result.business, "name") AND len(result.business.name)>
|
||||
<cfset mergedBusiness = result.business>
|
||||
<cfbreak>
|
||||
</cfif>
|
||||
<cfloop list="#bizFields#" index="fieldName">
|
||||
<cfif structKeyExists(result.business, fieldName)>
|
||||
<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>
|
||||
</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>
|
||||
</cfloop>
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ try {
|
|||
// Extract phone number
|
||||
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
|
||||
addressLine1 = structKeyExists(biz, "addressLine1") && isSimpleValue(biz.addressLine1) ? trim(biz.addressLine1) : "";
|
||||
city = structKeyExists(biz, "city") && isSimpleValue(biz.city) ? trim(biz.city) : "";
|
||||
|
|
@ -93,16 +96,22 @@ try {
|
|||
addressId = qNewAddr.id;
|
||||
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
|
||||
queryExecute("
|
||||
INSERT INTO Businesses (BusinessName, BusinessPhone, BusinessUserID, BusinessAddressID, BusinessDeliveryZipCodes, BusinessAddedOn)
|
||||
VALUES (:name, :phone, :userId, :addressId, :deliveryZips, NOW())
|
||||
INSERT INTO Businesses (BusinessName, BusinessPhone, BusinessUserID, BusinessAddressID, BusinessDeliveryZipCodes, BusinessCommunityMealType, BusinessTaxRate, BusinessAddedOn)
|
||||
VALUES (:name, :phone, :userId, :addressId, :deliveryZips, :communityMealType, :taxRate, NOW())
|
||||
", {
|
||||
name: bizName,
|
||||
phone: bizPhone,
|
||||
userId: userId,
|
||||
addressId: addressId,
|
||||
deliveryZips: len(zip) ? zip : ""
|
||||
deliveryZips: len(zip) ? zip : "",
|
||||
communityMealType: communityMealType,
|
||||
taxRate: { value: bizTaxRate, cfsqltype: "cf_sql_decimal" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
qNewBiz = queryExecute("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" });
|
||||
|
|
|
|||
|
|
@ -58,14 +58,20 @@ try {
|
|||
}
|
||||
}
|
||||
|
||||
// Get task type name if TaskTypeID provided
|
||||
// Get task type info if TaskTypeID provided (name + category)
|
||||
taskTypeName = "";
|
||||
taskTypeCategoryID = 0;
|
||||
if (taskTypeID > 0) {
|
||||
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" });
|
||||
if (typeQuery.recordCount && len(trim(typeQuery.tt_TaskTypeName))) {
|
||||
taskTypeName = typeQuery.tt_TaskTypeName;
|
||||
if (typeQuery.recordCount) {
|
||||
if (len(trim(typeQuery.tt_TaskTypeName))) {
|
||||
taskTypeName = typeQuery.tt_TaskTypeName;
|
||||
}
|
||||
if (!isNull(typeQuery.tt_TaskTypeCategoryID) && isNumeric(typeQuery.tt_TaskTypeCategoryID) && typeQuery.tt_TaskTypeCategoryID > 0) {
|
||||
taskTypeCategoryID = typeQuery.tt_TaskTypeCategoryID;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -90,24 +96,32 @@ try {
|
|||
taskDetails &= "Customer is requesting assistance";
|
||||
}
|
||||
|
||||
// Look up or create a "Service" category for this business
|
||||
catQuery = queryExecute("
|
||||
SELECT TaskCategoryID FROM TaskCategories
|
||||
WHERE TaskCategoryBusinessID = :businessID AND TaskCategoryName = 'Service'
|
||||
LIMIT 1
|
||||
", { businessID: { value: businessID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
|
||||
// Determine category: use task type's category if set, otherwise fallback to "Service" category
|
||||
categoryID = 0;
|
||||
|
||||
if (catQuery.recordCount == 0) {
|
||||
// Create the category
|
||||
queryExecute("
|
||||
INSERT INTO TaskCategories (TaskCategoryBusinessID, TaskCategoryName, TaskCategoryColor)
|
||||
VALUES (:businessID, 'Service', '##FF9800')
|
||||
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("
|
||||
SELECT TaskCategoryID FROM TaskCategories
|
||||
WHERE TaskCategoryBusinessID = :businessID AND TaskCategoryName = 'Service'
|
||||
LIMIT 1
|
||||
", { businessID: { value: businessID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
|
||||
|
||||
catResult = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
|
||||
categoryID = catResult.newID;
|
||||
} else {
|
||||
categoryID = catQuery.TaskCategoryID;
|
||||
if (catQuery.recordCount == 0) {
|
||||
// Create the category
|
||||
queryExecute("
|
||||
INSERT INTO TaskCategories (TaskCategoryBusinessID, TaskCategoryName, TaskCategoryColor)
|
||||
VALUES (:businessID, 'Service', '##FF9800')
|
||||
", { businessID: { value: businessID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
|
||||
|
||||
catResult = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
|
||||
categoryID = catResult.newID;
|
||||
} else {
|
||||
categoryID = catQuery.TaskCategoryID;
|
||||
}
|
||||
}
|
||||
|
||||
// Insert task
|
||||
|
|
|
|||
|
|
@ -26,11 +26,18 @@
|
|||
</cftry>
|
||||
</cffunction>
|
||||
|
||||
<cffunction name="generateToken" access="public" returntype="string" output="false">
|
||||
<cfreturn lcase(replace(createUUID(), "-", "", "all"))>
|
||||
</cffunction>
|
||||
|
||||
<cfset data = readJsonBody()>
|
||||
<cfset TaskID = val( structKeyExists(data,"TaskID") ? data.TaskID : 0 )>
|
||||
<!--- 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) )>
|
||||
|
||||
<!--- Optional: Worker rating of customer (when required or voluntary) --->
|
||||
<cfset workerRating = structKeyExists(data,"workerRating") ? data.workerRating : {}>
|
||||
|
||||
<cfif TaskID LTE 0>
|
||||
<cfset apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "TaskID is required." })>
|
||||
</cfif>
|
||||
|
|
@ -38,9 +45,11 @@
|
|||
<cftry>
|
||||
<!--- Verify task exists --->
|
||||
<cfset qTask = queryExecute("
|
||||
SELECT TaskID, TaskClaimedByUserID, TaskCompletedOn, TaskOrderID, TaskTypeID
|
||||
FROM Tasks
|
||||
WHERE TaskID = ?
|
||||
SELECT t.TaskID, t.TaskClaimedByUserID, t.TaskCompletedOn, t.TaskOrderID, t.TaskTypeID, t.TaskBusinessID,
|
||||
o.OrderUserID, o.OrderServicePointID
|
||||
FROM Tasks t
|
||||
LEFT JOIN Orders o ON o.OrderID = t.TaskOrderID
|
||||
WHERE t.TaskID = ?
|
||||
", [ { value = TaskID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
|
||||
|
||||
<cfif qTask.recordCount EQ 0>
|
||||
|
|
@ -63,6 +72,35 @@
|
|||
<cfset apiAbort({ "OK": false, "ERROR": "already_completed", "MESSAGE": "Task has already been completed." })>
|
||||
</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 --->
|
||||
<cfset queryExecute("
|
||||
UPDATE Tasks
|
||||
|
|
@ -82,12 +120,60 @@
|
|||
<cfset orderUpdated = true>
|
||||
</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({
|
||||
"OK": true,
|
||||
"ERROR": "",
|
||||
"MESSAGE": "Task completed successfully.",
|
||||
"TaskID": TaskID,
|
||||
"OrderUpdated": orderUpdated
|
||||
"OrderUpdated": orderUpdated,
|
||||
"RatingsCreated": ratingsCreated
|
||||
})>
|
||||
|
||||
<cfcatch>
|
||||
|
|
|
|||
107
api/tasks/deleteCategory.cfm
Normal file
107
api/tasks/deleteCategory.cfm
Normal 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>
|
||||
|
|
@ -50,7 +50,8 @@ try {
|
|||
tt_TaskTypeDescription as TaskTypeDescription,
|
||||
tt_TaskTypeIcon as TaskTypeIcon,
|
||||
tt_TaskTypeColor as TaskTypeColor,
|
||||
tt_TaskTypeSortOrder as SortOrder
|
||||
tt_TaskTypeSortOrder as SortOrder,
|
||||
tt_TaskTypeCategoryID as CategoryID
|
||||
FROM tt_TaskTypes
|
||||
WHERE tt_TaskTypeBusinessID = :businessID
|
||||
ORDER BY tt_TaskTypeSortOrder, tt_TaskTypeID
|
||||
|
|
@ -65,7 +66,8 @@ try {
|
|||
"TaskTypeName": row.TaskTypeName,
|
||||
"TaskTypeDescription": isNull(row.TaskTypeDescription) ? "" : row.TaskTypeDescription,
|
||||
"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
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
83
api/tasks/listCategories.cfm
Normal file
83
api/tasks/listCategories.cfm
Normal 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
124
api/tasks/saveCategory.cfm
Normal 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>
|
||||
|
|
@ -78,6 +78,14 @@ try {
|
|||
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)
|
||||
taskTypeID = 0;
|
||||
if (structKeyExists(data, "TaskTypeID") && isNumeric(data.TaskTypeID)) {
|
||||
|
|
@ -104,13 +112,15 @@ try {
|
|||
SET tt_TaskTypeName = :taskTypeName,
|
||||
tt_TaskTypeDescription = :taskTypeDescription,
|
||||
tt_TaskTypeIcon = :taskTypeIcon,
|
||||
tt_TaskTypeColor = :taskTypeColor
|
||||
tt_TaskTypeColor = :taskTypeColor,
|
||||
tt_TaskTypeCategoryID = :categoryID
|
||||
WHERE tt_TaskTypeID = :taskTypeID
|
||||
", {
|
||||
taskTypeName: { value: taskTypeName, cfsqltype: "cf_sql_varchar" },
|
||||
taskTypeDescription: { value: taskTypeDescription, cfsqltype: "cf_sql_varchar", null: !len(taskTypeDescription) },
|
||||
taskTypeIcon: { value: taskTypeIcon, 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" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
|
|
@ -122,14 +132,15 @@ try {
|
|||
} else {
|
||||
// INSERT new task type
|
||||
queryExecute("
|
||||
INSERT INTO tt_TaskTypes (tt_TaskTypeName, tt_TaskTypeDescription, tt_TaskTypeIcon, tt_TaskTypeColor, tt_TaskTypeBusinessID)
|
||||
VALUES (:taskTypeName, :taskTypeDescription, :taskTypeIcon, :taskTypeColor, :businessID)
|
||||
INSERT INTO tt_TaskTypes (tt_TaskTypeName, tt_TaskTypeDescription, tt_TaskTypeIcon, tt_TaskTypeColor, tt_TaskTypeBusinessID, tt_TaskTypeCategoryID)
|
||||
VALUES (:taskTypeName, :taskTypeDescription, :taskTypeIcon, :taskTypeColor, :businessID, :categoryID)
|
||||
", {
|
||||
taskTypeName: { value: taskTypeName, cfsqltype: "cf_sql_varchar" },
|
||||
taskTypeDescription: { value: taskTypeDescription, cfsqltype: "cf_sql_varchar", null: !len(taskTypeDescription) },
|
||||
taskTypeIcon: { value: taskTypeIcon, 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" });
|
||||
|
||||
qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
|
||||
|
|
|
|||
137
api/tasks/seedCategories.cfm
Normal file
137
api/tasks/seedCategories.cfm
Normal 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>
|
||||
|
|
@ -84,7 +84,7 @@
|
|||
}
|
||||
|
||||
.task-bar.flashing {
|
||||
animation: flash 0.5s ease-in-out infinite;
|
||||
animation: flash 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes flash {
|
||||
|
|
@ -264,6 +264,56 @@
|
|||
--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 */
|
||||
.status-indicator {
|
||||
position: fixed;
|
||||
|
|
@ -289,7 +339,10 @@
|
|||
<body>
|
||||
<div class="header">
|
||||
<h1>Payfrit Tasks<span id="businessName"></span></h1>
|
||||
<div class="clock" id="clock">--:--:--</div>
|
||||
<div style="display: flex; align-items: center;">
|
||||
<div class="clock" id="clock">--:--:--</div>
|
||||
<button class="fullscreen-btn" id="fullscreenBtn" onclick="toggleFullscreen()">⛶</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-container" id="taskContainer">
|
||||
|
|
@ -326,6 +379,7 @@
|
|||
</div>
|
||||
|
||||
<div class="status-indicator" id="statusIndicator"></div>
|
||||
<button class="restore-btn" id="restoreBtn" onclick="toggleFullscreen()">✕</button>
|
||||
|
||||
<script src="hud.js"></script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -623,6 +623,21 @@
|
|||
</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>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -911,6 +911,12 @@
|
|||
</button>
|
||||
</div>
|
||||
<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()">
|
||||
<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"/>
|
||||
|
|
@ -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;">
|
||||
<option value="0">All Categories</option>
|
||||
</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>
|
||||
|
||||
|
|
@ -2829,27 +2830,58 @@
|
|||
|
||||
// Upload photo
|
||||
uploadPhoto(itemId) {
|
||||
// Create file input
|
||||
// Find the item and check it has a database ID
|
||||
let targetItem = null;
|
||||
for (const cat of this.menu.categories) {
|
||||
const item = cat.items.find(i => i.id === itemId);
|
||||
if (item) { targetItem = item; 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/*';
|
||||
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
|
||||
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) {
|
||||
const item = cat.items.find(i => i.id === itemId);
|
||||
if (item) {
|
||||
item.imageUrl = reader.result;
|
||||
break;
|
||||
}
|
||||
}
|
||||
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.toast('Photo uploaded', 'success');
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
this.toast('Photo uploaded!', 'success');
|
||||
} else {
|
||||
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();
|
||||
|
|
@ -3553,9 +3585,19 @@
|
|||
}
|
||||
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 = {
|
||||
BusinessID: this.config.businessId,
|
||||
Menu: this.menu
|
||||
Menu: menuClone
|
||||
};
|
||||
console.log('[MenuBuilder] Full payload:', JSON.stringify(payload, null, 2));
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
/* Payfrit Business Portal - Modern Admin UI */
|
||||
|
||||
:root {
|
||||
--primary: #6366f1;
|
||||
--primary-dark: #4f46e5;
|
||||
--primary-light: #818cf8;
|
||||
--primary-hover: #4f46e5;
|
||||
--primary: #00d974;
|
||||
--primary-dark: #00b862;
|
||||
--primary-light: #33ff9f;
|
||||
--primary-hover: #00b862;
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
|
|
@ -396,7 +396,7 @@ body {
|
|||
}
|
||||
|
||||
.stat-icon.orders {
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
background: rgba(0, 217, 116, 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
|
|
@ -572,7 +572,7 @@ body {
|
|||
.form-textarea:focus {
|
||||
outline: none;
|
||||
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 {
|
||||
|
|
@ -681,7 +681,7 @@ body {
|
|||
}
|
||||
|
||||
.status-icon.pending {
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
background: rgba(0, 217, 116, 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
|
|
@ -875,7 +875,7 @@ body {
|
|||
}
|
||||
|
||||
.status-badge.submitted {
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
background: rgba(0, 217, 116, 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1099,6 +1099,16 @@ const Portal = {
|
|||
},
|
||||
|
||||
// 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() {
|
||||
this.toast('Starting Stripe setup...', 'info');
|
||||
|
||||
|
|
|
|||
|
|
@ -835,6 +835,43 @@
|
|||
</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">
|
||||
<button class="btn btn-outline" onclick="startOver()">Start Over</button>
|
||||
<button class="btn btn-success" onclick="saveMenu()">
|
||||
|
|
@ -920,6 +957,29 @@
|
|||
</style>
|
||||
|
||||
<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
|
||||
const config = {
|
||||
businessId: null,
|
||||
|
|
@ -1223,6 +1283,7 @@
|
|||
'saturday': 5, 'sat': 5,
|
||||
'sunday': 6, 'sun': 6
|
||||
};
|
||||
const dayNames = Object.keys(dayMap);
|
||||
|
||||
// Initialize all days as open with defaults
|
||||
for (let i = 0; i < 7; i++) {
|
||||
|
|
@ -1233,28 +1294,85 @@
|
|||
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);
|
||||
const convertTo24Hour = (hour, minute, ampm) => {
|
||||
let h = parseInt(hour);
|
||||
const m = minute ? parseInt(minute) : 0;
|
||||
if (ampm) {
|
||||
ampm = ampm.replace(/\./g, '').toLowerCase();
|
||||
if (ampm === 'pm' && h < 12) h += 12;
|
||||
if (ampm === 'am' && h === 12) h = 0;
|
||||
}
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
if (timeMatch) {
|
||||
const convertTo24Hour = (hour, minute, ampm) => {
|
||||
let h = parseInt(hour);
|
||||
const m = minute ? parseInt(minute) : 0;
|
||||
if (ampm) {
|
||||
ampm = ampm.replace(/\./g, '').toLowerCase();
|
||||
if (ampm === 'pm' && h < 12) h += 12;
|
||||
if (ampm === 'am' && h === 12) h = 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 closeTime = convertTo24Hour(timeMatch[4], timeMatch[5], timeMatch[6]);
|
||||
|
||||
// Apply extracted time to all days
|
||||
for (let i = 0; i < 7; i++) {
|
||||
schedule[i] = { open: openTime, close: closeTime, closed: false };
|
||||
// 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++) {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1400,7 +1518,9 @@
|
|||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||||
<div class="extracted-value editable">
|
||||
<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 class="extracted-value editable">
|
||||
<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>
|
||||
<input type="text" id="bizPhone" value="${biz.phone || ''}" placeholder="Phone number">
|
||||
</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">
|
||||
<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;">
|
||||
|
|
@ -1488,10 +1613,77 @@
|
|||
state: document.getElementById('bizState').value,
|
||||
zip: document.getElementById('bizZip').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
|
||||
};
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
|
|
@ -1906,7 +2098,7 @@
|
|||
<strong>${item.name}</strong>
|
||||
${item.description ? `<br><small style="color:var(--gray-500);">${item.description}</small>` : ''}
|
||||
</td>
|
||||
<td>$${(item.price || 0).toFixed(2)}</td>
|
||||
<td>$${parseFloat(item.price || 0).toFixed(2)}</td>
|
||||
<td>
|
||||
<div class="item-modifiers">
|
||||
${(item.modifiers || []).map(m => `<span class="item-modifier-tag">${m}</span>`).join('')}
|
||||
|
|
@ -2024,6 +2216,10 @@
|
|||
config.extractedData.menuStartTime = menuStartTime;
|
||||
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 originalText = saveBtn.innerHTML;
|
||||
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');
|
||||
|
||||
// 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
|
||||
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
|
||||
setTimeout(() => {
|
||||
window.location.href = 'index.html#menu';
|
||||
|
|
@ -2207,6 +2429,23 @@
|
|||
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) {
|
||||
config.currentStep = step;
|
||||
document.querySelectorAll('.progress-step').forEach(el => {
|
||||
|
|
|
|||
655
verticals/hospitals.html
Normal file
655
verticals/hospitals.html
Normal 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">🍽</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">🍺</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">🛍</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">🛠</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">© 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>
|
||||
Reference in a new issue