Resolve merge conflict in myBusinesses.cfm - keep ActiveTaskCount

This commit is contained in:
John Mizerek 2026-02-11 22:33:44 -08:00
parent e4422996b2
commit 0d04ae8463
18 changed files with 4674 additions and 5327 deletions

View file

@ -108,12 +108,15 @@ try {
}
try{logPerf(0);}catch(any e){}
writeOutput(serializeJSON({
resp = {
"OK": true,
"UUID": userUUID,
"MESSAGE": smsMessage,
"DEV_OTP": otp
}));
"MESSAGE": smsMessage
};
if (isDev) {
resp["DEV_OTP"] = otp;
}
writeOutput(serializeJSON(resp));
} catch (any e) {
apiAbort({

View file

@ -150,12 +150,15 @@ try {
}
}
writeOutput(serializeJSON({
resp = {
"OK": true,
"UUID": userUUID,
"MESSAGE": smsMessage,
"DEV_OTP": otp
}));
"MESSAGE": smsMessage
};
if (isDev) {
resp["DEV_OTP"] = otp;
}
writeOutput(serializeJSON(resp));
} catch (any e) {
apiAbort({

View file

@ -2,8 +2,9 @@
// Save menu data from the builder UI (OPTIMIZED)
// Input: BusinessID, Menu (JSON structure)
// Output: { OK: true }
// VERSION: 2026-02-08-fix1
response = { "OK": false };
response = { "OK": false, "VERSION": "2026-02-08-fix2", "DEBUG": [] };
// Track which templates we've already saved options for (to avoid duplicate saves)
savedTemplates = {};
@ -95,18 +96,21 @@ try {
throw("Menu categories are required");
}
// Check if new schema is active
newSchemaActive = false;
// Check if Categories table has data (must match getForBuilder logic!)
// If Categories table has data, use legacy schema (CategoryID in Items)
// Otherwise use unified schema (ParentItemID in Items)
hasCategoriesData = false;
try {
qCheck = queryTimed("
SELECT 1 FROM Items
WHERE BusinessID = :businessID AND BusinessID > 0
qCatCheck = queryTimed("
SELECT 1 FROM Categories
WHERE BusinessID = :businessID
LIMIT 1
", { businessID: businessID });
newSchemaActive = (qCheck.recordCount > 0);
", { businessID: businessID }, { datasource: "payfrit" });
hasCategoriesData = (qCatCheck.recordCount > 0);
} catch (any e) {
newSchemaActive = false;
hasCategoriesData = false;
}
newSchemaActive = !hasCategoriesData;
// Wrap everything in a transaction for speed and consistency
transaction {
@ -114,6 +118,13 @@ try {
for (cat in menu.categories) {
categoryID = 0;
categoryDbId = structKeyExists(cat, "dbId") ? val(cat.dbId) : 0;
catItemCount = structKeyExists(cat, "items") && isArray(cat.items) ? arrayLen(cat.items) : 0;
// Debug: log each category being processed
arrayAppend(response.DEBUG, "Category: " & cat.name & " (dbId=" & categoryDbId & ") with " & catItemCount & " items");
// Initialize menuId param - use 0 for "no menu" (nullable in DB)
categoryMenuId = structKeyExists(cat, "menuId") ? val(cat.menuId) : 0;
if (newSchemaActive) {
if (categoryDbId > 0) {
@ -151,31 +162,27 @@ try {
categoryID = result.newID;
}
} else {
// Get menu ID from category if provided
categoryMenuId = structKeyExists(cat, "menuId") ? val(cat.menuId) : 0;
categoryMenuIdParam = categoryMenuId > 0 ? categoryMenuId : javaCast("null", "");
if (categoryDbId > 0) {
categoryID = categoryDbId;
queryTimed("
UPDATE Categories
SET Name = :name,
SortOrder = :sortOrder,
MenuID = :menuId
WHERE CategoryID = :categoryID
MenuID = NULLIF(:menuId, 0)
WHERE ID = :categoryID
", {
categoryID: categoryID,
name: cat.name,
sortOrder: catSortOrder,
menuId: categoryMenuIdParam
menuId: categoryMenuId
});
} else {
queryTimed("
INSERT INTO Categories (BusinessID, MenuID, Name, SortOrder, AddedOn)
VALUES (:businessID, :menuId, :name, :sortOrder, NOW())
VALUES (:businessID, NULLIF(:menuId, 0), :name, :sortOrder, NOW())
", {
businessID: businessID,
menuId: categoryMenuIdParam,
menuId: categoryMenuId,
name: cat.name,
sortOrder: catSortOrder
});
@ -185,6 +192,9 @@ try {
}
}
// Debug: log final categoryID for this category
arrayAppend(response.DEBUG, " -> CategoryID resolved to: " & categoryID);
// Process items
if (structKeyExists(cat, "items") && isArray(cat.items)) {
itemSortOrder = 0;
@ -192,6 +202,9 @@ try {
itemID = 0;
itemDbId = structKeyExists(item, "dbId") ? val(item.dbId) : 0;
// Debug: log each item being processed
arrayAppend(response.DEBUG, " Item: " & item.name & " (dbId=" & itemDbId & ") -> CategoryID=" & categoryID);
if (itemDbId > 0) {
itemID = itemDbId;

View file

@ -4,6 +4,13 @@
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cffunction name="apiAbort" access="public" returntype="void" output="true">
<cfargument name="payload" type="struct" required="true">
<cfcontent type="application/json; charset=utf-8">
<cfoutput>#serializeJSON(arguments.payload)#</cfoutput>
<cfabort>
</cffunction>
<cftry>
<cfset headersDir = expandPath("/uploads/headers")>
<cfscript>
@ -35,20 +42,29 @@ if (bizId LTE 0) {
<cffile action="UPLOAD" filefield="header" destination="#headersDir#/" nameconflict="MAKEUNIQUE" mode="755" result="uploadResult">
<!--- Get image info and detect actual format --->
<cfimage source="#headersDir#/#uploadResult.ServerFile#" action="info" structName="imageInfo">
<!--- Use the actual detected format, not the client-provided extension --->
<cfset actualFormat = lCase(imageInfo.source_file)>
<cfset actualExt = "">
<cfif findNoCase("jpeg", actualFormat) OR findNoCase("jpg", actualFormat)>
<cftry>
<cfimage source="#headersDir#/#uploadResult.ServerFile#" action="info" structName="imageInfo">
<!--- Use the actual detected format from source_file if available --->
<cfif structKeyExists(imageInfo, "source_file")>
<cfset actualFormat = lCase(imageInfo.source_file)>
<cfif findNoCase("jpeg", actualFormat) OR findNoCase("jpg", actualFormat)>
<cfset actualExt = "jpg">
<cfelseif findNoCase("png", actualFormat)>
<cfelseif findNoCase("png", actualFormat)>
<cfset actualExt = "png">
<cfelseif findNoCase("gif", actualFormat)>
<cfelseif findNoCase("gif", actualFormat)>
<cfset actualExt = "gif">
<cfelseif findNoCase("webp", actualFormat)>
<cfelseif findNoCase("webp", actualFormat)>
<cfset actualExt = "webp">
<cfelse>
</cfif>
</cfif>
<cfcatch>
<!--- cfimage failed - will use fallback detection below --->
</cfcatch>
</cftry>
<!--- Fallback: if cfimage didn't give us format, detect from file --->
<cfif NOT len(actualExt)>
<!--- Fallback: detect by reading first bytes (magic numbers) --->
<cffile action="readbinary" file="#headersDir#/#uploadResult.ServerFile#" variable="fileBytes">
<cfset firstBytes = left(binaryEncode(fileBytes, "hex"), 16)>
@ -105,8 +121,15 @@ if (bizId LTE 0) {
</cftry>
</cfif>
<!--- Verify uploaded file still exists before rename --->
<cfset sourceFile = "#headersDir#/#uploadResult.ServerFile#">
<cfif NOT fileExists(sourceFile)>
<cfoutput>#serializeJSON({ "OK": false, "ERROR": "upload_failed", "MESSAGE": "Uploaded file not found - upload may have failed", "DEBUG_SOURCE": sourceFile })#</cfoutput>
<cfabort>
</cfif>
<!--- Rename to BusinessID.ext --->
<cffile action="RENAME" source="#headersDir#/#uploadResult.ServerFile#" destination="#destFile#" mode="755">
<cffile action="RENAME" source="#sourceFile#" destination="#destFile#" mode="755">
<!--- Update database --->
<cfquery datasource="payfrit">
@ -116,13 +139,15 @@ if (bizId LTE 0) {
</cfquery>
<!--- Return success with image URL --->
<cfset imgWidth = isDefined("imageInfo.width") ? imageInfo.width : 0>
<cfset imgHeight = isDefined("imageInfo.height") ? imageInfo.height : 0>
<cfoutput>#serializeJSON({
"OK": true,
"ERROR": "",
"MESSAGE": "Header uploaded successfully",
"HEADERURL": "/uploads/headers/#bizId#.#actualExt#",
"WIDTH": imageInfo.width,
"HEIGHT": imageInfo.height
"WIDTH": imgWidth,
"HEIGHT": imgHeight
})#</cfoutput>
<cfcatch type="any">

View file

@ -122,16 +122,22 @@ img = fixOrientation(img);
// Create thumbnail (64x64 square for list view - 2x for retina)
thumb = createSquareThumb(img, 128);
imageWrite(thumb, "#itemsDir#/#itemId#_thumb.jpg", 0.85);
thumbPath = "#itemsDir#/#itemId#_thumb.jpg";
imageWrite(thumb, thumbPath, 0.85);
fileSetAccessMode(thumbPath, "644");
// Create medium size (400px max for detail view)
medium = imageCopy(img, 0, 0, imageGetWidth(img), imageGetHeight(img));
medium = resizeToFit(medium, 400);
imageWrite(medium, "#itemsDir#/#itemId#_medium.jpg", 0.85);
mediumPath = "#itemsDir#/#itemId#_medium.jpg";
imageWrite(medium, mediumPath, 0.85);
fileSetAccessMode(mediumPath, "644");
// Save full size (max 1200px to keep file sizes reasonable)
img = resizeToFit(img, 1200);
imageWrite(img, "#itemsDir#/#itemId#.jpg", 0.90);
fullPath = "#itemsDir#/#itemId#.jpg";
imageWrite(img, fullPath, 0.90);
fileSetAccessMode(fullPath, "644");
// Delete the original uploaded file
try { fileDelete(uploadedFile); } catch (any e) {}

View file

@ -58,21 +58,15 @@ try {
}
}
// Get task type info if TaskTypeID provided (name + category)
// Get task type name if TaskTypeID provided
taskTypeName = "";
taskTypeCategoryID = 0;
if (taskTypeID > 0) {
typeQuery = queryExecute("
SELECT Name, TaskCategoryID FROM tt_TaskTypes WHERE ID = :typeID
SELECT Name FROM tt_TaskTypes WHERE ID = :typeID
", { typeID: { value: taskTypeID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
if (typeQuery.recordCount) {
if (len(trim(typeQuery.Name))) {
if (typeQuery.recordCount && len(trim(typeQuery.Name))) {
taskTypeName = typeQuery.Name;
}
if (!isNull(typeQuery.TaskCategoryID) && isNumeric(typeQuery.TaskCategoryID) && typeQuery.TaskCategoryID > 0) {
taskTypeCategoryID = typeQuery.TaskCategoryID;
}
}
}
// Create task title and details - use task type name if available
@ -96,40 +90,12 @@ try {
taskDetails &= "Customer is requesting assistance";
}
// Determine category: use task type's category if set, otherwise fallback to "Service" category
categoryID = 0;
if (taskTypeCategoryID > 0) {
// Use the task type's assigned category
categoryID = taskTypeCategoryID;
} else {
// Fallback: look up or create a "Service" category for this business
catQuery = queryExecute("
SELECT ID FROM TaskCategories
WHERE BusinessID = :businessID AND Name = 'Service'
LIMIT 1
", { businessID: { value: businessID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
if (catQuery.recordCount == 0) {
// Create the category
queryExecute("
INSERT INTO TaskCategories (BusinessID, Name, Color)
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.ID;
}
}
// Insert task
// Insert task (no CategoryID - using TaskTypeID only)
queryExecute("
INSERT INTO Tasks (
BusinessID,
ServicePointID,
UserID,
CategoryID,
OrderID,
TaskTypeID,
Title,
@ -138,8 +104,8 @@ try {
CreatedOn
) VALUES (
:businessID,
:servicePointID,
:userID,
:categoryID,
:orderID,
:taskTypeID,
:title,
@ -149,8 +115,8 @@ try {
)
", {
businessID: { value: businessID, cfsqltype: "cf_sql_integer" },
servicePointID: { value: servicePointID, cfsqltype: "cf_sql_integer" },
userID: { value: userID > 0 ? userID : javaCast("null", ""), cfsqltype: "cf_sql_integer", null: userID == 0 },
categoryID: { value: categoryID, cfsqltype: "cf_sql_integer" },
orderID: { value: orderID > 0 ? orderID : javaCast("null", ""), cfsqltype: "cf_sql_integer", null: orderID == 0 },
taskTypeID: { value: taskTypeID, cfsqltype: "cf_sql_integer" },
title: { value: taskTitle, cfsqltype: "cf_sql_varchar" },

View file

@ -29,55 +29,25 @@ try {
userID = val(jsonData.UserID ?: 0);
message = jsonData.Message ?: "";
// Get task type info for display (including category)
// Get task type name for display
taskTypeQuery = queryTimed("
SELECT Name, Color, Icon, TaskCategoryID
FROM tt_TaskTypes
WHERE ID = :taskTypeID
SELECT Name FROM tt_TaskTypes WHERE ID = :taskTypeID
", { taskTypeID: taskTypeID }, { datasource: "payfrit" });
taskTitle = message;
categoryID = 0;
if (taskTypeQuery.recordCount) {
if (len(trim(taskTypeQuery.Name))) {
if (taskTypeQuery.recordCount && len(trim(taskTypeQuery.Name))) {
taskTitle = taskTypeQuery.Name;
}
if (!isNull(taskTypeQuery.TaskCategoryID) && isNumeric(taskTypeQuery.TaskCategoryID) && taskTypeQuery.TaskCategoryID > 0) {
categoryID = taskTypeQuery.TaskCategoryID;
}
}
// If no category from task type, look up or create default "Service" category
if (categoryID == 0) {
catQuery = queryTimed("
SELECT ID FROM TaskCategories
WHERE BusinessID = :businessID AND Name = 'Service'
LIMIT 1
", { businessID: businessID }, { datasource: "payfrit" });
if (catQuery.recordCount > 0) {
categoryID = catQuery.ID;
} else {
// Create the Service category
queryTimed("
INSERT INTO TaskCategories (BusinessID, Name, Color)
VALUES (:businessID, 'Service', '##FF9800')
", { businessID: businessID }, { datasource: "payfrit" });
catResult = queryTimed("SELECT LAST_INSERT_ID() as newID", {}, { datasource: "payfrit" });
categoryID = catResult.newID;
}
}
// Use message as details
taskDetails = message;
// Insert service bell task with ServicePointID and UserID
// Insert service bell task (no CategoryID - using TaskTypeID only)
queryTimed("
INSERT INTO Tasks (
BusinessID,
ServicePointID,
TaskTypeID,
CategoryID,
OrderID,
UserID,
Title,
@ -88,7 +58,6 @@ try {
:businessID,
:servicePointID,
:taskTypeID,
:categoryID,
:orderID,
:userID,
:taskTitle,
@ -100,7 +69,6 @@ try {
businessID: businessID,
servicePointID: servicePointID,
taskTypeID: taskTypeID,
categoryID: categoryID,
orderID: orderID,
userID: userID,
taskTitle: taskTitle,

View file

@ -34,20 +34,18 @@
</cfif>
<cftry>
<!--- Get the task and linked order details --->
<!--- Get the task and linked order details (no categories - using task types only) --->
<cfset qTask = queryExecute("
SELECT
t.ID AS TaskID,
t.BusinessID,
t.CategoryID,
t.OrderID,
t.TaskTypeID,
t.CreatedOn,
t.ClaimedByUserID,
t.ServicePointID AS TaskServicePointID,
tc.Name AS CategoryName,
tc.Color AS CategoryColor,
tt.Name AS TaskTypeName,
tt.Color AS TaskTypeColor,
o.ID AS OID,
o.UUID AS OrderUUID,
o.UserID AS OrderUserID,
@ -68,7 +66,6 @@
u.ContactNumber,
u.ImageExtension AS CustomerImageExtension
FROM Tasks t
LEFT JOIN TaskCategories tc ON tc.ID = t.CategoryID
LEFT JOIN tt_TaskTypes tt ON tt.ID = t.TaskTypeID
LEFT JOIN Orders o ON o.ID = t.OrderID
LEFT JOIN Businesses b ON b.ID = t.BusinessID
@ -107,14 +104,12 @@
<cfset result = {
"TaskID": qTask.TaskID,
"TaskBusinessID": qTask.BusinessID,
"TaskCategoryID": qTask.CategoryID,
"TaskTypeID": qTask.TaskTypeID ?: 1,
"TaskTypeName": qTask.TaskTypeName ?: "",
"TaskTypeColor": len(trim(qTask.TaskTypeColor)) ? qTask.TaskTypeColor : "##9C27B0",
"TaskTitle": taskTitle,
"TaskCreatedOn": dateFormat(qTask.CreatedOn, "yyyy-mm-dd") & "T" & timeFormat(qTask.CreatedOn, "HH:mm:ss"),
"TaskStatusID": qTask.ClaimedByUserID GT 0 ? 1 : 0,
"TaskCategoryName": len(trim(qTask.CategoryName)) ? qTask.CategoryName : "General",
"TaskCategoryColor": len(trim(qTask.CategoryColor)) ? qTask.CategoryColor : "##888888",
"OrderID": qTask.OrderID ?: 0,
"OrderRemarks": qTask.Remarks ?: "",
"OrderSubmittedOn": isDate(qTask.SubmittedOn) ? (dateFormat(qTask.SubmittedOn, "yyyy-mm-dd") & "T" & timeFormat(qTask.SubmittedOn, "HH:mm:ss")) : "",

View file

@ -68,7 +68,6 @@
SELECT
t.ID,
t.BusinessID,
t.CategoryID,
t.OrderID,
t.TaskTypeID,
t.Title,
@ -78,8 +77,6 @@
t.ClaimedOn,
t.CompletedOn,
t.UserID,
tc.Name AS CategoryName,
tc.Color AS CategoryColor,
tt.Name AS TaskTypeName,
tt.Icon AS TaskTypeIcon,
tt.Color AS TaskTypeColor,
@ -89,7 +86,6 @@
u.FirstName AS CustomerFirstName,
u.LastName AS CustomerLastName
FROM Tasks t
LEFT JOIN TaskCategories tc ON tc.ID = t.CategoryID
LEFT JOIN tt_TaskTypes tt ON tt.ID = t.TaskTypeID
LEFT JOIN Businesses b ON b.ID = t.BusinessID
LEFT JOIN Orders o ON o.ID = t.OrderID
@ -129,7 +125,6 @@
"TaskID": qTasks.ID,
"BusinessID": qTasks.BusinessID,
"BusinessName": qTasks.BusinessName ?: "",
"TaskCategoryID": qTasks.CategoryID,
"TaskTypeID": qTasks.TaskTypeID,
"Title": taskTitle,
"Details": taskDetails,
@ -139,8 +134,6 @@
"StatusID": (isNull(qTasks.CompletedOn) OR len(trim(qTasks.CompletedOn)) EQ 0) ? 1 : 3,
"SourceType": "order",
"SourceID": qTasks.OrderID,
"CategoryName": len(trim(qTasks.CategoryName)) ? qTasks.CategoryName : "General",
"CategoryColor": len(trim(qTasks.CategoryColor)) ? qTasks.CategoryColor : "##888888",
"TaskTypeName": len(trim(qTasks.TaskTypeName)) ? qTasks.TaskTypeName : "",
"TaskTypeIcon": len(trim(qTasks.TaskTypeIcon)) ? qTasks.TaskTypeIcon : "notifications",
"TaskTypeColor": len(trim(qTasks.TaskTypeColor)) ? qTasks.TaskTypeColor : "##9C27B0",

View file

@ -28,7 +28,7 @@
<cfset data = readJsonBody()>
<cfset BusinessID = val( structKeyExists(data,"BusinessID") ? data.BusinessID : 0 )>
<cfset CategoryID = val( structKeyExists(data,"CategoryID") ? data.CategoryID : 0 )>
<cfset TaskTypeID = val( structKeyExists(data,"TaskTypeID") ? data.TaskTypeID : 0 )>
<cfif BusinessID LTE 0>
<cfset apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required." })>
@ -46,10 +46,10 @@
<cfset whereClauses = ["t.BusinessID = ?", "t.ClaimedByUserID = 0", "t.CompletedOn IS NULL"]>
<cfset params = [ { value = BusinessID, cfsqltype = "cf_sql_integer" } ]>
<!--- Filter by category if provided --->
<cfif CategoryID GT 0>
<cfset arrayAppend(whereClauses, "t.CategoryID = ?")>
<cfset arrayAppend(params, { value = CategoryID, cfsqltype = "cf_sql_integer" })>
<!--- Filter by task type if provided --->
<cfif TaskTypeID GT 0>
<cfset arrayAppend(whereClauses, "t.TaskTypeID = ?")>
<cfset arrayAppend(params, { value = TaskTypeID, cfsqltype = "cf_sql_integer" })>
</cfif>
<cfset whereSQL = arrayToList(whereClauses, " AND ")>
@ -58,7 +58,6 @@
SELECT
t.ID,
t.BusinessID,
t.CategoryID,
t.OrderID,
t.TaskTypeID,
t.Title,
@ -66,8 +65,6 @@
t.CreatedOn,
t.ClaimedByUserID,
t.UserID,
tc.Name AS CategoryName,
tc.Color AS CategoryColor,
tt.Name AS TaskTypeName,
tt.Icon AS TaskTypeIcon,
tt.Color AS TaskTypeColor,
@ -76,7 +73,6 @@
u.FirstName AS CustomerFirstName,
u.LastName AS CustomerLastName
FROM Tasks t
LEFT JOIN TaskCategories tc ON tc.ID = t.CategoryID
LEFT JOIN tt_TaskTypes tt ON tt.ID = t.TaskTypeID
LEFT JOIN Orders o ON o.ID = t.OrderID
LEFT JOIN ServicePoints sp ON sp.ID = COALESCE(t.ServicePointID, o.ServicePointID)
@ -111,7 +107,6 @@
<cfset arrayAppend(tasks, {
"TaskID": qTasks.ID,
"BusinessID": qTasks.BusinessID,
"TaskCategoryID": qTasks.CategoryID,
"TaskTypeID": qTasks.TaskTypeID,
"Title": taskTitle,
"Details": taskDetails,
@ -119,8 +114,6 @@
"StatusID": qTasks.ClaimedByUserID GT 0 ? 1 : 0,
"SourceType": "order",
"SourceID": qTasks.OrderID,
"CategoryName": len(trim(qTasks.CategoryName)) ? qTasks.CategoryName : "General",
"CategoryColor": len(trim(qTasks.CategoryColor)) ? qTasks.CategoryColor : "##888888",
"TaskTypeName": len(trim(qTasks.TaskTypeName)) ? qTasks.TaskTypeName : "",
"TaskTypeIcon": len(trim(qTasks.TaskTypeIcon)) ? qTasks.TaskTypeIcon : "notifications",
"TaskTypeColor": len(trim(qTasks.TaskTypeColor)) ? qTasks.TaskTypeColor : "##9C27B0",

View file

@ -41,13 +41,14 @@
MIN(e.StatusID) AS StatusID,
MAX(e.IsActive) AS IsActive,
b.Name AS Name,
(SELECT COUNT(*) FROM Tasks t WHERE t.BusinessID = e.BusinessID AND t.ClaimedByUserID = 0 AND t.CompletedOn IS NULL) AS PendingTaskCount
(SELECT COUNT(*) FROM Tasks t WHERE t.BusinessID = e.BusinessID AND t.ClaimedByUserID = 0 AND t.CompletedOn IS NULL) AS PendingTaskCount,
(SELECT COUNT(*) FROM Tasks t WHERE t.BusinessID = e.BusinessID AND t.ClaimedByUserID = ? AND t.CompletedOn IS NULL) AS ActiveTaskCount
FROM Employees e
INNER JOIN Businesses b ON b.ID = e.BusinessID
WHERE e.UserID = ? AND e.IsActive = b'1'
GROUP BY e.BusinessID, b.Name
ORDER BY b.Name ASC
", [ { value = UserID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
", [ { value = UserID, cfsqltype = "cf_sql_integer" }, { value = UserID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
<cfset businesses = []>
@ -59,7 +60,8 @@
"Address": "",
"City": "",
"StatusID": qBusinesses.StatusID,
"PendingTaskCount": qBusinesses.PendingTaskCount
"PendingTaskCount": qBusinesses.PendingTaskCount,
"ActiveTaskCount": qBusinesses.ActiveTaskCount
})>
</cfloop>

View file

@ -225,13 +225,16 @@ const HUD = {
// Get elapsed seconds since task creation
getElapsedSeconds(createdOn) {
if (!createdOn) return 0;
const created = new Date(createdOn);
if (isNaN(created.getTime())) return 0;
const now = new Date();
return Math.floor((now - created) / 1000);
return Math.max(0, Math.floor((now - created) / 1000));
},
// Format elapsed time as mm:ss
formatElapsed(seconds) {
if (isNaN(seconds) || seconds < 0) seconds = 0;
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${String(secs).padStart(2, '0')}`;

View file

@ -4,772 +4,14 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - Payfrit Business Portal</title>
<link rel="stylesheet" href="portal.css">
<style>
body {
background: var(--bg-primary);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-container {
width: 100%;
max-width: 420px;
padding: 20px;
}
.login-card {
background: var(--bg-card);
border-radius: 16px;
padding: 40px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
}
.login-header {
text-align: center;
margin-bottom: 32px;
}
.login-logo {
width: 64px;
height: 64px;
background: var(--primary);
color: #000;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
font-weight: 700;
margin: 0 auto 16px;
}
.login-header h1 {
font-size: 24px;
margin: 0 0 8px;
color: var(--text-primary);
}
.login-header p {
color: var(--text-muted);
margin: 0;
font-size: 14px;
}
.login-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
}
.form-group input {
padding: 14px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 15px;
transition: all 0.2s;
}
.form-group input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(0, 255, 136, 0.1);
}
.form-group input::placeholder {
color: var(--text-muted);
}
.login-btn {
padding: 14px 24px;
background: var(--primary);
color: #000;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
margin-top: 8px;
}
.login-btn:hover {
background: var(--primary-hover);
transform: translateY(-1px);
}
.login-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.login-error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
display: none;
}
.login-error.show {
display: block;
}
.login-success {
background: rgba(0, 255, 136, 0.1);
border: 1px solid rgba(0, 255, 136, 0.3);
color: var(--primary);
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
display: none;
}
.login-success.show {
display: block;
}
.login-footer {
text-align: center;
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid var(--border-color);
}
.login-footer a {
color: var(--primary);
text-decoration: none;
font-size: 14px;
}
.login-footer a:hover {
text-decoration: underline;
}
.business-select {
padding: 14px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 15px;
width: 100%;
cursor: pointer;
}
.business-select:focus {
outline: none;
border-color: var(--primary);
}
.login-card {
visibility: hidden;
}
.login-card.ready {
visibility: visible;
}
.step {
display: none;
}
.step.active {
display: block;
}
.otp-input {
text-align: center;
font-size: 24px;
font-weight: 600;
letter-spacing: 8px;
padding: 14px 16px;
}
.switch-link {
color: var(--text-muted);
text-decoration: none;
font-size: 13px;
cursor: pointer;
}
.switch-link:hover {
color: var(--primary);
text-decoration: underline;
}
.resend-link {
color: var(--text-muted);
font-size: 13px;
cursor: pointer;
background: none;
border: none;
padding: 0;
text-decoration: none;
}
.resend-link:hover {
color: var(--primary);
text-decoration: underline;
}
.resend-link:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
<script>
// Redirect to signup.html - single auth entry point
// Existing users verified by phone get their existing account
// New users go through full setup flow
window.location.replace('signup.html');
</script>
</head>
<body>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<div class="login-logo">P</div>
<h1>Business Portal</h1>
<p id="loginSubtitle">Sign in with your email</p>
</div>
<!-- Step 1: OTP - Enter Email -->
<div class="step" id="step-otp-email">
<form class="login-form" id="otpEmailForm">
<div class="login-error" id="otpEmailError"></div>
<div class="form-group">
<label for="otpEmail">Email Address</label>
<input type="email" id="otpEmail" name="email" placeholder="Enter your email" required>
</div>
<button type="submit" class="login-btn" id="sendCodeBtn">Send Login Code</button>
</form>
<div class="login-footer">
<a href="#" class="switch-link" onclick="PortalLogin.showStep('password'); return false;">Sign in with password instead</a>
</div>
</div>
<!-- Step 2: OTP - Enter Code -->
<div class="step" id="step-otp-code">
<form class="login-form" id="otpCodeForm">
<div class="login-error" id="otpCodeError"></div>
<div class="login-success" id="otpCodeSuccess">A 6-digit code has been sent to your email.</div>
<div class="form-group">
<label for="otpCode">Enter 6-Digit Code</label>
<input type="text" id="otpCode" name="code" class="otp-input" placeholder="000000" maxlength="6" pattern="[0-9]{6}" inputmode="numeric" autocomplete="one-time-code" required>
</div>
<button type="submit" class="login-btn" id="verifyCodeBtn">Verify Code</button>
</form>
<div class="login-footer" style="display: flex; justify-content: space-between; align-items: center;">
<a href="#" class="switch-link" onclick="PortalLogin.showStep('otp-email'); return false;">Change email</a>
<button class="resend-link" id="resendBtn" onclick="PortalLogin.resendCode()">Resend code</button>
</div>
</div>
<!-- Step 3: Password Login (fallback) -->
<div class="step" id="step-password">
<form class="login-form" id="loginForm">
<div class="login-error" id="loginError"></div>
<div class="form-group">
<label for="username">Email or Phone</label>
<input type="text" id="username" name="username" placeholder="Enter your email or phone" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" placeholder="Enter your password" required>
</div>
<button type="submit" class="login-btn" id="loginBtn">Sign In</button>
</form>
<div class="login-footer">
<a href="#" class="switch-link" onclick="PortalLogin.showStep('otp-email'); return false;">Sign in with email code instead</a>
</div>
</div>
<!-- Step 4: Select Business -->
<div class="step" id="step-business">
<div class="login-form">
<div class="form-group">
<label for="businessSelect">Select Business</label>
<select id="businessSelect" class="business-select" onchange="PortalLogin.selectBusiness()">
<option value="">Choose a business...</option>
</select>
</div>
</div>
</div>
</div>
</div>
<script>
const BASE_PATH = (() => {
const path = window.location.pathname;
const portalIndex = path.indexOf('/portal/');
if (portalIndex > 0) {
return path.substring(0, portalIndex);
}
return '';
})();
const PortalLogin = {
userToken: null,
userId: null,
businesses: [],
otpEmail: null,
resendCooldown: false,
init() {
const card = document.querySelector('.login-card');
const savedToken = localStorage.getItem('payfrit_portal_token');
const savedBusiness = localStorage.getItem('payfrit_portal_business');
const savedUserId = localStorage.getItem('payfrit_portal_userid');
if (savedToken && savedBusiness) {
this.verifyAndRedirect(savedToken, savedBusiness);
return;
}
if (savedToken && savedUserId) {
this.userToken = savedToken;
this.userId = parseInt(savedUserId);
this.showStep('business');
card.classList.add('ready');
this.loadBusinessesAndShow();
return;
}
// Default: show OTP email step
this.showStep('otp-email');
card.classList.add('ready');
// OTP email form
document.getElementById('otpEmailForm').addEventListener('submit', (e) => {
e.preventDefault();
this.sendOTP();
});
// OTP code form
document.getElementById('otpCodeForm').addEventListener('submit', (e) => {
e.preventDefault();
this.verifyOTP();
});
// Password login form
document.getElementById('loginForm').addEventListener('submit', (e) => {
e.preventDefault();
this.login();
});
},
async loadBusinessesAndShow() {
await this.loadBusinesses();
if (this.businesses.length === 0) {
this.startNewRestaurant();
} else if (this.businesses.length === 1) {
this.selectBusinessById(this.businesses[0].BusinessID);
} else {
this.showStep('business');
}
},
async verifyAndRedirect(token, businessId) {
localStorage.setItem('payfrit_portal_business', businessId);
window.location.href = BASE_PATH + '/portal/index.html';
},
// --- OTP Flow ---
async sendOTP() {
const email = document.getElementById('otpEmail').value.trim();
const errorEl = document.getElementById('otpEmailError');
const btn = document.getElementById('sendCodeBtn');
if (!email) return;
errorEl.classList.remove('show');
btn.disabled = true;
btn.textContent = 'Sending...';
try {
const response = await fetch(BASE_PATH + '/api/auth/sendLoginOTP.cfm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Email: email })
});
const text = await response.text();
let data;
try {
data = JSON.parse(text);
} catch (parseErr) {
console.error('[OTP] JSON parse error:', parseErr);
errorEl.textContent = 'Server error. Please try again.';
errorEl.classList.add('show');
btn.disabled = false;
btn.textContent = 'Send Login Code';
return;
}
if (data.OK) {
this.otpEmail = email;
// On dev, show the code for testing
if (data.DEV_OTP) {
console.log('[OTP] Dev code:', data.DEV_OTP);
document.getElementById('otpCodeSuccess').textContent =
'Dev mode - your code is: ' + data.DEV_OTP;
} else {
document.getElementById('otpCodeSuccess').textContent =
'A 6-digit code has been sent to ' + email;
}
document.getElementById('otpCodeSuccess').classList.add('show');
this.showStep('otp-code');
document.getElementById('otpCode').focus();
} else {
errorEl.textContent = data.MESSAGE || data.ERROR || 'Could not send code. Please try again.';
errorEl.classList.add('show');
}
} catch (err) {
console.error('[OTP] Send error:', err);
errorEl.textContent = 'Connection error. Please try again.';
errorEl.classList.add('show');
}
btn.disabled = false;
btn.textContent = 'Send Login Code';
},
async verifyOTP() {
const code = document.getElementById('otpCode').value.trim();
const errorEl = document.getElementById('otpCodeError');
const btn = document.getElementById('verifyCodeBtn');
if (!code || code.length !== 6) {
errorEl.textContent = 'Please enter the 6-digit code.';
errorEl.classList.add('show');
return;
}
errorEl.classList.remove('show');
btn.disabled = true;
btn.textContent = 'Verifying...';
try {
const response = await fetch(BASE_PATH + '/api/auth/verifyEmailOTP.cfm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Email: this.otpEmail, Code: code })
});
const text = await response.text();
let data;
try {
data = JSON.parse(text);
} catch (parseErr) {
console.error('[OTP] Verify parse error:', parseErr);
errorEl.textContent = 'Server error. Please try again.';
errorEl.classList.add('show');
btn.disabled = false;
btn.textContent = 'Verify Code';
return;
}
if (data.OK && data.TOKEN) {
// Normalize key casing
const token = data.TOKEN || data.Token;
const userId = data.USERID || data.UserID;
this.userToken = token;
this.userId = userId;
localStorage.setItem('payfrit_portal_token', token);
localStorage.setItem('payfrit_portal_userid', userId);
const firstName = data.FIRSTNAME || data.FirstName || '';
if (firstName) localStorage.setItem('payfrit_portal_firstname', firstName);
await this.loadBusinesses();
const card = document.querySelector('.login-card');
if (this.businesses.length === 0) {
this.startNewRestaurant();
} else if (this.businesses.length === 1) {
this.selectBusinessById(this.businesses[0].BusinessID);
} else {
this.showStep('business');
card.classList.add('ready');
}
} else {
const friendlyErrors = {
'invalid_code': 'Invalid or expired code. Please try again.',
'expired': 'Code has expired. Please request a new one.',
'user_not_found': 'No account found with that email.'
};
errorEl.textContent = friendlyErrors[data.ERROR] || data.MESSAGE || 'Invalid code. Please try again.';
errorEl.classList.add('show');
document.getElementById('otpCode').value = '';
document.getElementById('otpCode').focus();
}
} catch (err) {
console.error('[OTP] Verify error:', err);
errorEl.textContent = 'Connection error. Please try again.';
errorEl.classList.add('show');
}
btn.disabled = false;
btn.textContent = 'Verify Code';
},
async resendCode() {
if (this.resendCooldown) return;
const btn = document.getElementById('resendBtn');
this.resendCooldown = true;
btn.disabled = true;
// Re-send OTP
try {
const response = await fetch(BASE_PATH + '/api/auth/sendLoginOTP.cfm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Email: this.otpEmail })
});
const text = await response.text();
let data;
try { data = JSON.parse(text); } catch (e) { data = {}; }
const successEl = document.getElementById('otpCodeSuccess');
if (data.OK) {
if (data.DEV_OTP) {
successEl.textContent = 'Dev mode - new code: ' + data.DEV_OTP;
} else {
successEl.textContent = 'A new code has been sent to ' + this.otpEmail;
}
successEl.classList.add('show');
}
} catch (err) {
console.error('[OTP] Resend error:', err);
}
// 30-second cooldown
let seconds = 30;
btn.textContent = 'Resend (' + seconds + 's)';
const interval = setInterval(() => {
seconds--;
if (seconds <= 0) {
clearInterval(interval);
btn.textContent = 'Resend code';
btn.disabled = false;
this.resendCooldown = false;
} else {
btn.textContent = 'Resend (' + seconds + 's)';
}
}, 1000);
},
// --- Password Login (fallback) ---
async login() {
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
const errorEl = document.getElementById('loginError');
const btn = document.getElementById('loginBtn');
errorEl.classList.remove('show');
btn.disabled = true;
btn.textContent = 'Signing in...';
try {
const response = await fetch(BASE_PATH + '/api/auth/login.cfm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const text = await response.text();
let data;
try {
data = JSON.parse(text);
} catch (parseErr) {
console.error("[Login] JSON parse error:", parseErr);
errorEl.textContent = "Server error - check browser console (F12)";
errorEl.classList.add("show");
btn.disabled = false;
btn.textContent = "Sign In";
return;
}
if (data.OK && (data.Token || data.TOKEN)) {
const token = data.Token || data.TOKEN;
const userId = data.UserID || data.USERID;
this.userToken = token;
this.userId = userId;
localStorage.setItem('payfrit_portal_token', token);
localStorage.setItem('payfrit_portal_userid', userId);
const firstName = data.FirstName || data.FIRSTNAME || '';
if (firstName) localStorage.setItem('payfrit_portal_firstname', firstName);
await this.loadBusinesses();
const card = document.querySelector('.login-card');
if (this.businesses.length === 0) {
this.startNewRestaurant();
} else if (this.businesses.length === 1) {
this.selectBusinessById(this.businesses[0].BusinessID);
} else {
this.showStep('business');
card.classList.add('ready');
}
} else {
const friendlyErrors = {
'bad_credentials': 'Incorrect email/phone or password. Please try again.',
'not_found': 'No account found with that email or phone number.',
'account_disabled': 'This account has been disabled. Please contact support.'
};
errorEl.textContent = friendlyErrors[data.ERROR] || data.MESSAGE || 'Invalid credentials';
errorEl.classList.add('show');
}
} catch (err) {
console.error('[Login] Error:', err);
errorEl.textContent = 'Connection error. Please try again.';
errorEl.classList.add('show');
}
btn.disabled = false;
btn.textContent = 'Sign In';
},
// --- Business Selection ---
async loadBusinesses() {
try {
console.log('[Login] Loading businesses for UserID:', this.userId);
const bizResponse = await fetch(BASE_PATH + '/api/portal/myBusinesses.cfm', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Token': this.userToken
},
body: JSON.stringify({ UserID: this.userId })
});
const bizText = await bizResponse.text();
let bizData;
try {
bizData = JSON.parse(bizText);
} catch (parseErr) {
console.error("[Login] Businesses JSON parse error:", parseErr);
return;
}
if (bizData.OK && bizData.BUSINESSES) {
this.businesses = bizData.BUSINESSES;
console.log('[Login] Loaded', this.businesses.length, 'businesses');
this.populateBusinessSelect();
} else {
console.error('[Login] Businesses API error:', bizData.ERROR || bizData.MESSAGE);
}
} catch (err) {
console.error('[Login] Error loading businesses:', err);
}
},
populateBusinessSelect() {
const select = document.getElementById('businessSelect');
if (this.businesses.length === 0) {
select.innerHTML = '<option value="">No businesses yet</option>';
const wizardOption = document.createElement('option');
wizardOption.value = 'NEW_WIZARD';
wizardOption.textContent = 'Create New Business';
select.appendChild(wizardOption);
} else {
select.innerHTML = '<option value="">Choose a business...</option>';
this.businesses.forEach(biz => {
const option = document.createElement('option');
option.value = biz.BusinessID;
option.textContent = biz.Name;
select.appendChild(option);
});
const wizardOption = document.createElement('option');
wizardOption.value = 'NEW_WIZARD';
wizardOption.textContent = 'New Business Wizard';
select.appendChild(wizardOption);
}
},
showStep(step) {
document.querySelectorAll('.step').forEach(s => s.classList.remove('active'));
const el = document.getElementById('step-' + step);
if (el) el.classList.add('active');
// Update subtitle
const subtitle = document.getElementById('loginSubtitle');
if (step === 'otp-email') subtitle.textContent = 'Sign in with your email';
else if (step === 'otp-code') subtitle.textContent = 'Enter your verification code';
else if (step === 'password') subtitle.textContent = 'Sign in with your password';
else if (step === 'business') subtitle.textContent = 'Select your business';
// Clear errors when switching
document.querySelectorAll('.login-error').forEach(e => e.classList.remove('show'));
},
selectBusiness() {
const businessId = document.getElementById('businessSelect').value;
if (businessId === 'NEW_WIZARD') {
this.startNewRestaurant();
} else if (businessId) {
this.selectBusinessById(businessId);
}
},
selectBusinessById(businessId) {
localStorage.setItem('payfrit_portal_business', businessId);
window.location.href = BASE_PATH + '/portal/index.html';
},
startNewRestaurant() {
localStorage.removeItem('payfrit_portal_business');
window.location.href = BASE_PATH + '/portal/setup-wizard.html';
},
logout() {
localStorage.removeItem('payfrit_portal_token');
localStorage.removeItem('payfrit_portal_userid');
localStorage.removeItem('payfrit_portal_business');
localStorage.removeItem('payfrit_portal_firstname');
window.location.href = BASE_PATH + '/portal/login.html';
}
};
document.addEventListener('DOMContentLoaded', () => PortalLogin.init());
</script>
<p>Redirecting...</p>
</body>
</html>

View file

@ -817,14 +817,19 @@
</head>
<body>
<div class="app">
<!-- Sidebar (without Payfrit header) -->
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="business-info" style="padding: 12px 16px; border-bottom: 1px solid var(--gray-200);">
<div class="business-info" style="padding: 12px 16px; border-bottom: 1px solid var(--gray-200); display: flex; align-items: center; gap: 12px;">
<div class="business-avatar" id="businessAvatar">B</div>
<div class="business-details">
<div class="business-details" style="flex: 1;">
<div class="business-name" id="businessName">Loading...</div>
<div class="business-status online">Online</div>
</div>
<button class="sidebar-toggle" id="sidebarToggle" style="background: none; border: none; cursor: pointer; padding: 4px;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12h18M3 6h18M3 18h18"/>
</svg>
</button>
</div>
<nav class="sidebar-nav">
@ -870,6 +875,14 @@
</svg>
<span>Services</span>
</a>
<a href="index.html#service-points" class="nav-item">
<svg class="nav-icon" width="20" height="20" 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>
<span>Service Points</span>
</a>
<a href="index.html#admin-tasks" class="nav-item">
<svg class="nav-icon" width="20" height="20" 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"/>
@ -888,6 +901,37 @@
<!-- Main Content -->
<main class="main-content">
<header class="top-bar">
<div class="page-title">
<h1>Menu Builder</h1>
</div>
<div class="top-bar-actions">
<div class="user-menu" style="position: relative;">
<button class="user-btn" id="userBtn" onclick="var dd=document.getElementById('userDropdown'); dd.style.display = dd.style.display === 'none' ? 'block' : 'none';">
<span class="user-avatar" id="userAvatar">B</span>
</button>
<div class="user-dropdown" id="userDropdown" style="display:none; position:absolute; right:0; top:100%; margin-top:8px; background:var(--bg-card); border:1px solid var(--border-color); border-radius:8px; min-width:200px; box-shadow:0 4px 12px rgba(0,0,0,0.3); z-index:1000; overflow:hidden;">
<a href="index.html#settings" style="display:flex; align-items:center; gap:10px; padding:12px 16px; color:var(--text-primary); text-decoration:none; transition:background 0.15s;" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background=''">
<svg width="18" height="18" 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-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
Settings
</a>
<a href="#" onclick="MenuBuilder.switchBusiness(); document.getElementById('userDropdown').classList.remove('show'); return false;" style="display:flex; align-items:center; gap:10px; padding:12px 16px; color:var(--text-primary); text-decoration:none; transition:background 0.15s;" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background=''">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 3h5v5M8 3H3v5M3 16v5h5M21 16v5h-5M7 12h10"/></svg>
Switch Business
</a>
<a href="#" onclick="MenuBuilder.addNewBusiness(); document.getElementById('userDropdown').classList.remove('show'); return false;" style="display:flex; align-items:center; gap:10px; padding:12px 16px; color:var(--text-primary); text-decoration:none; transition:background 0.15s;" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background=''">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
Add New Business
</a>
<div style="border-top: 1px solid var(--border-color);"></div>
<a href="#" onclick="MenuBuilder.logout(); return false;" style="display:flex; align-items:center; gap:10px; padding:12px 16px; color:#ef4444; text-decoration:none; transition:background 0.15s;" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background=''">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9"/></svg>
Logout
</a>
</div>
</div>
</div>
</header>
<div class="builder-wrapper">
<!-- Toolbar -->
<div class="builder-toolbar">
@ -1235,7 +1279,7 @@
const savedBusiness = localStorage.getItem('payfrit_portal_business');
if (!token || !savedBusiness) {
window.location.href = BASE_PATH + '/portal/login.html';
window.location.href = BASE_PATH + '/portal/signup.html';
return;
}
@ -1279,7 +1323,18 @@
logout() {
localStorage.removeItem('payfrit_portal_token');
localStorage.removeItem('payfrit_portal_business');
window.location.href = BASE_PATH + '/portal/login.html';
window.location.href = BASE_PATH + '/portal/signup.html';
},
// Switch to a different business
switchBusiness() {
localStorage.removeItem('payfrit_portal_business');
window.location.href = BASE_PATH + '/portal/index.html';
},
// Add a new business
addNewBusiness() {
window.location.href = BASE_PATH + '/portal/setup.html';
},
// Load business info
@ -1293,11 +1348,15 @@
const data = await response.json();
if (data.OK && data.BUSINESS) {
const biz = data.BUSINESS;
const initial = biz.Name ? biz.Name.charAt(0).toUpperCase() : 'B';
// Update sidebar business info
const businessName = document.getElementById('businessName');
const businessAvatar = document.getElementById('businessAvatar');
if (businessName) businessName.textContent = biz.Name;
if (businessAvatar) businessAvatar.textContent = biz.Name ? biz.Name.charAt(0).toUpperCase() : 'B';
if (businessAvatar) businessAvatar.textContent = initial;
// Update top bar user avatar
const userAvatar = document.getElementById('userAvatar');
if (userAvatar) userAvatar.textContent = initial;
}
} catch (err) {
console.error('[MenuBuilder] Error loading business:', err);
@ -1441,8 +1500,15 @@
}
});
document.addEventListener('click', () => {
document.addEventListener('click', (e) => {
this.hideContextMenu();
// Close user dropdown if clicking outside
const userDropdown = document.getElementById('userDropdown');
const userBtn = document.getElementById('userBtn');
if (userDropdown && !userDropdown.contains(e.target) && !userBtn.contains(e.target)) {
userDropdown.classList.remove('show');
userDropdown.style.display = 'none';
}
});
},

View file

@ -56,21 +56,21 @@ const Portal = {
const savedBusiness = localStorage.getItem('payfrit_portal_business');
const userId = localStorage.getItem('payfrit_portal_userid');
if (!token || !savedBusiness) {
// Not logged in - redirect to login
if (!token) {
// No token - redirect to signup
localStorage.removeItem('payfrit_portal_token');
localStorage.removeItem('payfrit_portal_userid');
localStorage.removeItem('payfrit_portal_business');
window.location.href = BASE_PATH + '/portal/login.html';
window.location.href = BASE_PATH + '/portal/signup.html';
return;
}
// Use saved business ID from localStorage
this.config.businessId = parseInt(savedBusiness) || null;
// Use saved business ID from localStorage (might be null)
this.config.businessId = savedBusiness ? parseInt(savedBusiness) : null;
this.config.userId = parseInt(userId) || 1;
this.config.token = token;
// Verify user has access to this business
// Verify user has access to this business (or get their businesses if none selected)
try {
const response = await fetch(`${this.config.apiBaseUrl}/portal/myBusinesses.cfm`, {
method: 'POST',
@ -82,18 +82,30 @@ const Portal = {
});
const data = await response.json();
if (data.OK && data.BUSINESSES) {
const hasAccess = data.BUSINESSES.some(b => b.BusinessID === this.config.businessId);
if (!hasAccess && data.BUSINESSES.length > 0) {
// User doesn't have access to requested business, use their first business
if (data.OK && data.BUSINESSES && data.BUSINESSES.length > 0) {
if (!this.config.businessId) {
// No business selected
if (data.BUSINESSES.length === 1) {
// Only one business - auto-select it
this.config.businessId = data.BUSINESSES[0].BusinessID;
localStorage.setItem('payfrit_portal_business', this.config.businessId);
} else if (!hasAccess) {
// User has no businesses
this.toast('No businesses associated with your account', 'error');
this.logout();
} else {
// Multiple businesses - show chooser
this.showBusinessChooser(data.BUSINESSES);
return;
}
} else {
const hasAccess = data.BUSINESSES.some(b => b.BusinessID === this.config.businessId);
if (!hasAccess) {
// User doesn't have access to requested business - show chooser
this.showBusinessChooser(data.BUSINESSES);
return;
}
}
} else {
// User has no businesses - go to setup wizard
window.location.href = BASE_PATH + '/portal/setup-wizard.html';
return;
}
} catch (err) {
console.error('[Portal] Auth verification error:', err);
@ -133,14 +145,69 @@ const Portal = {
localStorage.removeItem('payfrit_portal_token');
localStorage.removeItem('payfrit_portal_userid');
localStorage.removeItem('payfrit_portal_business');
window.location.href = BASE_PATH + '/portal/login.html';
window.location.href = BASE_PATH + '/portal/signup.html';
},
// Switch to a different business (go back to login to select)
switchBusiness() {
// Clear current business selection but keep token
localStorage.removeItem('payfrit_portal_business');
window.location.href = BASE_PATH + '/portal/login.html';
// Switch to a different business - show chooser modal
async switchBusiness() {
const token = localStorage.getItem('payfrit_portal_token');
const userId = localStorage.getItem('payfrit_portal_userid');
try {
const response = await fetch(`${this.config.apiBaseUrl}/portal/myBusinesses.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-User-Token': token },
body: JSON.stringify({ UserID: userId })
});
const data = await response.json();
if (data.OK && data.BUSINESSES && data.BUSINESSES.length > 0) {
this.showBusinessChooser(data.BUSINESSES);
} else {
window.location.href = BASE_PATH + '/portal/setup-wizard.html';
}
} catch (err) {
console.error('[Portal] Failed to load businesses:', err);
}
},
showBusinessChooser(businesses) {
// Remove existing modal if any
const existing = document.getElementById('businessChooserModal');
if (existing) existing.remove();
const modal = document.createElement('div');
modal.id = 'businessChooserModal';
modal.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:9999';
const content = document.createElement('div');
content.style.cssText = 'background:white;color:#333;border-radius:12px;padding:24px;max-width:400px;width:90%;max-height:80vh;overflow-y:auto';
content.innerHTML = `
<h2 style="margin:0 0 16px;font-size:18px;color:#333">Select Business</h2>
<div id="businessList"></div>
<button id="closeChooser" style="margin-top:16px;padding:8px 16px;background:#f0f0f0;color:#333;border:none;border-radius:6px;cursor:pointer;width:100%">Cancel</button>
`;
const list = content.querySelector('#businessList');
businesses.forEach(biz => {
const item = document.createElement('div');
item.style.cssText = 'padding:12px;border:1px solid #e0e0e0;border-radius:8px;margin-bottom:8px;cursor:pointer;transition:background 0.2s;color:#333;background:white';
item.innerHTML = `<strong>${biz.BusinessName || biz.Name}</strong>`;
item.onmouseover = () => item.style.background = '#f5f5f5';
item.onmouseout = () => item.style.background = 'white';
item.onclick = () => {
localStorage.setItem('payfrit_portal_business', biz.BusinessID);
window.location.reload();
};
list.appendChild(item);
});
content.querySelector('#closeChooser').onclick = () => modal.remove();
modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
modal.appendChild(content);
document.body.appendChild(modal);
},
// Add a new business (go to setup wizard)

View file

@ -718,7 +718,7 @@
<div class="business-status online">Online</div>
</div>
</div>
<a href="login.html" class="nav-item logout" onclick="logout()">
<a href="signup.html" class="nav-item logout" onclick="logout()">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16,17 21,12 16,7"/>
@ -1077,7 +1077,7 @@
// Check if user is logged in
const userId = localStorage.getItem('payfrit_portal_userid');
if (!userId) {
window.location.href = 'login.html';
window.location.href = 'signup.html';
return;
}
config.userId = userId;
@ -2525,7 +2525,7 @@
localStorage.removeItem('payfrit_portal_token');
localStorage.removeItem('payfrit_portal_userid');
localStorage.removeItem('payfrit_portal_business');
window.location.href = 'login.html';
window.location.href = 'signup.html';
}
</script>
</body>

View file

@ -314,8 +314,7 @@
</form>
<div class="signup-footer">
<span style="color: var(--text-muted); font-size: 14px;">Already have an account? </span>
<a href="login.html">Sign in with password</a>
<span style="color: var(--text-muted); font-size: 14px;">New or returning? Verify your phone to continue.</span>
</div>
</div>
@ -806,8 +805,11 @@
console.log('[Signup] myBusinesses response:', data);
if (data.OK && data.BUSINESSES && data.BUSINESSES.length > 0) {
// Has businesses - go to login for business selection
window.location.href = BASE_PATH + '/portal/login.html';
// Has businesses - go to portal (chooser will show if multiple)
if (data.BUSINESSES.length === 1) {
localStorage.setItem('payfrit_portal_business', data.BUSINESSES[0].BusinessID);
}
window.location.href = BASE_PATH + '/portal/index.html';
} else {
// No businesses - go to wizard
this.redirectToWizard();

View file

@ -437,7 +437,7 @@
const savedBusiness = localStorage.getItem('payfrit_portal_business');
if (!token || !savedBusiness) {
window.location.href = BASE_PATH + '/portal/login.html';
window.location.href = BASE_PATH + '/portal/signup.html';
return;
}