Resolve merge conflict in myBusinesses.cfm - keep ActiveTaskCount
This commit is contained in:
parent
e4422996b2
commit
0d04ae8463
18 changed files with 4674 additions and 5327 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)>
|
||||
<cfset actualExt = "jpg">
|
||||
<cfelseif findNoCase("png", actualFormat)>
|
||||
<cfset actualExt = "png">
|
||||
<cfelseif findNoCase("gif", actualFormat)>
|
||||
<cfset actualExt = "gif">
|
||||
<cfelseif findNoCase("webp", actualFormat)>
|
||||
<cfset actualExt = "webp">
|
||||
<cfelse>
|
||||
<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)>
|
||||
<cfset actualExt = "png">
|
||||
<cfelseif findNoCase("gif", actualFormat)>
|
||||
<cfset actualExt = "gif">
|
||||
<cfelseif findNoCase("webp", actualFormat)>
|
||||
<cfset actualExt = "webp">
|
||||
</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">
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
|
|
|
|||
|
|
@ -58,20 +58,14 @@ 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))) {
|
||||
taskTypeName = typeQuery.Name;
|
||||
}
|
||||
if (!isNull(typeQuery.TaskCategoryID) && isNumeric(typeQuery.TaskCategoryID) && typeQuery.TaskCategoryID > 0) {
|
||||
taskTypeCategoryID = typeQuery.TaskCategoryID;
|
||||
}
|
||||
if (typeQuery.recordCount && len(trim(typeQuery.Name))) {
|
||||
taskTypeName = typeQuery.Name;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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))) {
|
||||
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;
|
||||
}
|
||||
if (taskTypeQuery.recordCount && len(trim(taskTypeQuery.Name))) {
|
||||
taskTitle = taskTypeQuery.Name;
|
||||
}
|
||||
|
||||
// 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,
|
||||
|
|
|
|||
|
|
@ -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")) : "",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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')}`;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
113
portal/portal.js
113
portal/portal.js
|
|
@ -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
|
||||
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();
|
||||
return;
|
||||
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 {
|
||||
// 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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Reference in a new issue