Launch prep: fix menu builder, payment flow, comment out pre-launch features

- Fix menu builder dropdown showing empty names (return MenuName instead of Name)
- Add default menu selection (setDefault action, DefaultMenuID in getForBuilder)
- Fix createPaymentIntent column names for dev schema (ID, StripeAccountID, etc.)
- Fix menu-builder favicon and remove redundant business label
- Comment out Tabs/Running Checks feature for launch (HTML + JS)
- Comment out Service Point Marketing/Grants feature for launch (HTML + JS)
- Add testMarkPaid.cfm for testing orders without Stripe webhooks
- Task API updates for worker payout ledger integration

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-02-05 10:18:33 -08:00
parent d7632c5d35
commit 31a89018f5
12 changed files with 417 additions and 281 deletions

View file

@ -60,6 +60,16 @@ try {
// Check for MenuID filter (optional - if provided, only return categories for that menu)
menuID = structKeyExists(requestData, "MenuID") ? val(requestData.MenuID) : 0;
// Get business's default menu setting
defaultMenuID = 0;
try {
qBiz = queryTimed("SELECT DefaultMenuID FROM Businesses WHERE ID = :businessID",
{ businessID: businessID }, { datasource: "payfrit" });
if (qBiz.recordCount > 0 && !isNull(qBiz.DefaultMenuID)) {
defaultMenuID = val(qBiz.DefaultMenuID);
}
} catch (any e) {}
// Get all menus for this business
allMenus = [];
try {
@ -74,11 +84,11 @@ try {
for (m = 1; m <= qMenus.recordCount; m++) {
arrayAppend(allMenus, {
"MenuID": qMenus.ID[m],
"Name": qMenus.Name[m],
"Description": isNull(qMenus.Description[m]) ? "" : qMenus.Description[m],
"DaysActive": qMenus.DaysActive[m],
"StartTime": isNull(qMenus.StartTime[m]) ? "" : timeFormat(qMenus.StartTime[m], "HH:mm"),
"EndTime": isNull(qMenus.EndTime[m]) ? "" : timeFormat(qMenus.EndTime[m], "HH:mm"),
"MenuName": qMenus.Name[m],
"MenuDescription": isNull(qMenus.Description[m]) ? "" : qMenus.Description[m],
"MenuDaysActive": qMenus.DaysActive[m],
"MenuStartTime": isNull(qMenus.StartTime[m]) ? "" : timeFormat(qMenus.StartTime[m], "HH:mm"),
"MenuEndTime": isNull(qMenus.EndTime[m]) ? "" : timeFormat(qMenus.EndTime[m], "HH:mm"),
"SortOrder": qMenus.SortOrder[m]
});
}
@ -113,8 +123,11 @@ try {
// If exactly one menu is active now, auto-select it
if (arrayLen(activeMenuIds) == 1) {
menuID = activeMenuIds[1];
} else if (arrayLen(activeMenuIds) > 1 && defaultMenuID > 0 && arrayFind(activeMenuIds, defaultMenuID)) {
// Multiple menus active - use business default if it's among the active ones
menuID = defaultMenuID;
}
// If multiple match (overlap) or none match, show all (menuID stays 0)
// If multiple match with no default, or none match, show all (menuID stays 0)
}
} catch (any e) {
// Menus table might not exist yet
@ -455,6 +468,7 @@ try {
response["MENU"] = { "categories": categories };
response["MENUS"] = allMenus;
response["SELECTED_MENU_ID"] = menuID;
response["DEFAULT_MENU_ID"] = defaultMenuID;
response["TEMPLATES"] = templateLibrary;
response["BRANDCOLOR"] = brandColor;
response["CATEGORY_COUNT"] = arrayLen(categories);

View file

@ -234,6 +234,32 @@ try {
response = { "OK": true, "ACTION": "reordered" };
break;
case "setDefault":
// Set the default menu for this business
menuID = structKeyExists(requestData, "MenuID") ? val(requestData.MenuID) : 0;
// MenuID of 0 means "no default" (clear the default)
if (menuID > 0) {
// Verify the menu exists and belongs to this business
qCheck = queryTimed("
SELECT ID FROM Menus WHERE ID = :menuID AND BusinessID = :businessID AND IsActive = 1
", { menuID: menuID, businessID: businessID }, { datasource: "payfrit" });
if (qCheck.recordCount == 0) {
apiAbort({ "OK": false, "ERROR": "invalid_menu", "MESSAGE": "Menu not found or not active" });
}
}
queryTimed("
UPDATE Businesses SET DefaultMenuID = :menuID WHERE ID = :businessID
", {
menuID: { value: menuID, cfsqltype: "cf_sql_integer", null: menuID == 0 },
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
response = { "OK": true, "ACTION": "defaultSet", "DefaultMenuID": menuID };
break;
default:
apiAbort({ "OK": false, "ERROR": "invalid_action", "MESSAGE": "Unknown action: " & action });
}

View file

@ -0,0 +1,55 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
/**
* TEST ONLY: Mark an order as submitted and paid without Stripe
* This bypasses the payment flow for development testing
*
* POST: { OrderID: int }
*/
response = { "OK": false };
try {
requestData = deserializeJSON(toString(getHttpRequestData().content));
orderID = val(requestData.OrderID ?: 0);
if (orderID == 0) {
response["ERROR"] = "OrderID is required";
writeOutput(serializeJSON(response));
abort;
}
// Check order exists
qOrder = queryExecute("
SELECT ID, StatusID, PaymentStatus FROM Orders WHERE ID = :orderID
", { orderID: orderID }, { datasource: "payfrit" });
if (qOrder.recordCount == 0) {
response["ERROR"] = "Order not found";
writeOutput(serializeJSON(response));
abort;
}
// Mark as submitted and paid
queryExecute("
UPDATE Orders
SET StatusID = 1,
PaymentStatus = 'paid',
PaymentCompletedOn = NOW(),
SubmittedOn = COALESCE(SubmittedOn, NOW()),
LastEditedOn = NOW()
WHERE ID = :orderID
", { orderID: orderID }, { datasource: "payfrit" });
response["OK"] = true;
response["MESSAGE"] = "Order #orderID# marked as submitted and paid";
} catch (any e) {
response["ERROR"] = e.message;
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -62,9 +62,9 @@ try {
// Get business Stripe account
qBusiness = queryExecute("
SELECT StripeAccountID AS BusinessStripeAccountID, StripeOnboardingComplete AS BusinessStripeOnboardingComplete, Name AS BusinessName
SELECT StripeAccountID, StripeOnboardingComplete, Name
FROM Businesses
WHERE BusinessID = :businessID
WHERE ID = :businessID
", { businessID: businessID }, { datasource: "payfrit" });
if (qBusiness.recordCount == 0) {
@ -103,7 +103,7 @@ try {
}
// For testing, allow orders even without Stripe Connect setup
hasStripeConnect = qBusiness.BusinessStripeOnboardingComplete == 1 && len(trim(qBusiness.BusinessStripeAccountID)) > 0;
hasStripeConnect = qBusiness.StripeOnboardingComplete == 1 && len(trim(qBusiness.StripeAccountID)) > 0;
// ============================================================
// FEE CALCULATION
@ -138,7 +138,7 @@ try {
// SP-SM: Add grant owner fee to platform fee (Payfrit collects, then transfers to owner)
effectivePlatformFeeCents = totalPlatformFeeCents + grantOwnerFeeCents;
httpService.addParam(type="formfield", name="application_fee_amount", value=effectivePlatformFeeCents);
httpService.addParam(type="formfield", name="transfer_data[destination]", value=qBusiness.BusinessStripeAccountID);
httpService.addParam(type="formfield", name="transfer_data[destination]", value=qBusiness.StripeAccountID);
}
httpService.addParam(type="formfield", name="metadata[order_id]", value=orderID);
@ -150,7 +150,7 @@ try {
httpService.addParam(type="formfield", name="metadata[grant_owner_business_id]", value=grantOwnerBusinessID);
httpService.addParam(type="formfield", name="metadata[grant_owner_fee_cents]", value=grantOwnerFeeCents);
}
httpService.addParam(type="formfield", name="description", value="Order ###orderID# at #qBusiness.BusinessName#");
httpService.addParam(type="formfield", name="description", value="Order ###orderID# at #qBusiness.Name#");
if (customerEmail != "") {
httpService.addParam(type="formfield", name="receipt_email", value=customerEmail);

View file

@ -37,6 +37,7 @@
<!--- Optional: Worker rating of customer (when required or voluntary) --->
<cfset workerRating = structKeyExists(data,"workerRating") ? data.workerRating : {}>
<cfset CashReceivedCents = val( structKeyExists(data,"CashReceivedCents") ? data.CashReceivedCents : 0 )>
<cfif TaskID LTE 0>
<cfset apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "TaskID is required." })>
@ -46,9 +47,13 @@
<!--- Verify task exists --->
<cfset qTask = queryTimed("
SELECT t.ID, t.ClaimedByUserID, t.CompletedOn, t.OrderID, t.TaskTypeID, t.BusinessID,
o.UserID, o.ServicePointID
o.UserID AS CustomerUserID, o.ServicePointID,
tt.Name AS TaskTypeName,
b.UserID AS BusinessOwnerUserID
FROM Tasks t
LEFT JOIN Orders o ON o.ID = t.OrderID
LEFT JOIN tt_TaskTypes tt ON tt.ID = t.TaskTypeID
LEFT JOIN Businesses b ON b.ID = t.BusinessID
WHERE t.ID = ?
", [ { value = TaskID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
@ -58,6 +63,7 @@
<!--- Chat tasks (TaskTypeID = 2) can be closed without being claimed first --->
<cfset isChatTask = (qTask.TaskTypeID EQ 2)>
<cfset isCashTask = (len(trim(qTask.TaskTypeName)) GT 0 AND findNoCase("Cash", qTask.TaskTypeName) GT 0)>
<cfif (NOT isChatTask) AND (qTask.ClaimedByUserID EQ 0)>
<cfset apiAbort({ "OK": false, "ERROR": "not_claimed", "MESSAGE": "Task has not been claimed yet." })>
@ -74,7 +80,8 @@
<!--- Check if this is a service point task (customer-facing) --->
<cfset hasServicePoint = (val(qTask.ServicePointID) GT 0)>
<cfset customerUserID = val(qTask.UserID)>
<cfset customerUserID = val(qTask.CustomerUserID)>
<cfset businessOwnerUserID = val(qTask.BusinessOwnerUserID)>
<cfset workerUserID = val(qTask.ClaimedByUserID)>
<cfset ratingRequired = false>
<cfset ratingsCreated = []>
@ -101,6 +108,77 @@
</cfif>
</cfif>
<!--- === CASH TASK VALIDATION === --->
<cfset cashResult = {}>
<cfif isCashTask>
<cfif CashReceivedCents LTE 0>
<cfset apiAbort({ "OK": false, "ERROR": "cash_required", "MESSAGE": "Cash amount is required for cash tasks." })>
</cfif>
<cfif CashReceivedCents GT 50000>
<cfset apiAbort({ "OK": false, "ERROR": "cash_limit", "MESSAGE": "Cash transactions cannot exceed $500." })>
</cfif>
<!--- Check 30-day rolling limit for customer --->
<cfif customerUserID GT 0>
<cfset q30Day = queryTimed("
SELECT COALESCE(SUM(PaymentPaidInCash), 0) AS TotalCashDollars
FROM Payments
WHERE PaymentSentByUserID = ?
AND PaymentAddedOn >= DATE_SUB(NOW(), INTERVAL 30 DAY)
", [{ value = customerUserID, cfsqltype = "cf_sql_integer" }], { datasource = "payfrit" })>
<cfif (val(q30Day.TotalCashDollars) + (CashReceivedCents / 100)) GT 10000>
<cfset apiAbort({ "OK": false, "ERROR": "cash_30day_limit", "MESSAGE": "Customer has exceeded the $10,000 rolling 30-day cash limit." })>
</cfif>
</cfif>
<!--- Calculate order total from line items --->
<cfif qTask.OrderID LTE 0>
<cfset apiAbort({ "OK": false, "ERROR": "no_order", "MESSAGE": "Cash tasks must be linked to an order." })>
</cfif>
<cfset qOrderTotal = queryTimed("
SELECT SUM(oli.Price * oli.Quantity) AS Subtotal, o.TipAmount, o.DeliveryFee, o.OrderTypeID, b.TaxRate
FROM Orders o
INNER JOIN Businesses b ON b.ID = o.BusinessID
LEFT JOIN OrderLineItems oli ON oli.OrderID = o.ID AND oli.IsDeleted = b'0'
WHERE o.ID = ?
GROUP BY o.ID
", [{ value = qTask.OrderID, cfsqltype = "cf_sql_integer" }], { datasource = "payfrit" })>
<cfif qOrderTotal.recordCount EQ 0>
<cfset apiAbort({ "OK": false, "ERROR": "no_order", "MESSAGE": "Order not found for this cash task." })>
</cfif>
<cfset cashSubtotal = val(qOrderTotal.Subtotal)>
<cfset cashTax = cashSubtotal * val(qOrderTotal.TaxRate)>
<cfset cashTip = val(qOrderTotal.TipAmount)>
<cfset cashDeliveryFee = (val(qOrderTotal.OrderTypeID) EQ 3) ? val(qOrderTotal.DeliveryFee) : 0>
<cfset cashPlatformFee = cashSubtotal * 0.05>
<cfset orderTotalCents = round((cashSubtotal + cashTax + cashTip + cashDeliveryFee + cashPlatformFee) * 100)>
<cfif CashReceivedCents LT orderTotalCents>
<cfset apiAbort({ "OK": false, "ERROR": "insufficient_cash", "MESSAGE": "Cash received ($#numberFormat(CashReceivedCents/100, '0.00')#) is less than order total ($#numberFormat(orderTotalCents/100, '0.00')#)." })>
</cfif>
<!--- Calculate cash transaction fee (on order total) --->
<cfif orderTotalCents LT 1000>
<cfset feeCents = round(orderTotalCents * 0.0225)>
<cfelse>
<cfset feeCents = 22 + round(orderTotalCents * 0.0005)>
</cfif>
<cfset changeCents = CashReceivedCents - orderTotalCents>
<cfset businessReceivesCents = orderTotalCents - feeCents>
<cfset cashResult = {
"orderTotalCents": orderTotalCents,
"cashReceivedCents": CashReceivedCents,
"changeCents": changeCents,
"feeCents": feeCents,
"businessReceivesCents": businessReceivesCents
}>
</cfif>
<!--- Mark task as completed --->
<cfset queryTimed("
UPDATE Tasks
@ -178,6 +256,67 @@
</cfif>
</cfif>
<!--- === CASH TRANSACTION PROCESSING === --->
<cfset cashProcessed = false>
<cfif isCashTask AND CashReceivedCents GT 0>
<!--- Credit customer change to their balance --->
<cfif changeCents GT 0 AND customerUserID GT 0>
<cfset queryTimed("
UPDATE Users SET Balance = Balance + :change WHERE ID = :userID
", {
change: changeCents / 100,
userID: customerUserID
}, { datasource = "payfrit" })>
</cfif>
<!--- Credit Payfrit fee to User 0 (Payfrit Network) --->
<cfif feeCents GT 0>
<cfset queryTimed("
UPDATE Users SET Balance = Balance + :fee WHERE ID = 0
", {
fee: feeCents / 100
}, { datasource = "payfrit" })>
</cfif>
<!--- Debit worker (received physical cash, debit digitally) --->
<cfif workerUserID_for_payout GT 0>
<cfset queryTimed("
INSERT INTO WorkPayoutLedgers
(UserID, TaskID, GrossEarningsCents, ActivationWithheldCents, NetTransferCents, Status)
VALUES
(:userID, :taskID, :gross, 0, :net, 'cash_debit')
", {
userID: workerUserID_for_payout,
taskID: TaskID,
gross: -CashReceivedCents,
net: -CashReceivedCents
}, { datasource = "payfrit" })>
</cfif>
<!--- Log transaction in Payments table --->
<cfset queryTimed("
INSERT INTO Payments (
PaymentSentByUserID, PaymentReceivedByUserID, PaymentOrderID,
PaymentFromCreditCard, PaymentFromPayfritBalance, PaymentPaidInCash,
PaymentPayfritsCut, PaymentCreditCardFees, PaymentPayfritNetworkFees,
PaymentRemark, PaymentAddedOn
) VALUES (
:sentBy, :receivedBy, :orderID,
0, 0, :cashAmount,
:payfritCut, 0, 0,
'Cash payment', NOW()
)
", {
sentBy: customerUserID,
receivedBy: businessOwnerUserID,
orderID: qTask.OrderID,
cashAmount: CashReceivedCents / 100,
payfritCut: feeCents / 100
}, { datasource = "payfrit" })>
<cfset cashProcessed = true>
</cfif>
<!--- Create rating records for service point tasks --->
<cfif hasServicePoint AND customerUserID GT 0 AND workerUserID GT 0>
<!--- 1. Customer rates Worker (always created, submitted via receipt link) --->
@ -225,15 +364,26 @@
</cfif>
</cfif>
<cfset apiAbort({
<cfset response = {
"OK": true,
"ERROR": "",
"MESSAGE": "Task completed successfully.",
"TaskID": TaskID,
"OrderUpdated": orderUpdated,
"RatingsCreated": ratingsCreated,
"LedgerCreated": ledgerCreated
})>
"LedgerCreated": ledgerCreated,
"CashProcessed": cashProcessed
}>
<cfif cashProcessed>
<cfset response["CashReceived"] = numberFormat(CashReceivedCents / 100, "0.00")>
<cfset response["OrderTotal"] = numberFormat(orderTotalCents / 100, "0.00")>
<cfset response["Change"] = numberFormat(changeCents / 100, "0.00")>
<cfset response["Fee"] = numberFormat(feeCents / 100, "0.00")>
<cfset response["BusinessReceives"] = numberFormat(businessReceivesCents / 100, "0.00")>
</cfif>
<cfset apiAbort(response)>
<cfcatch>
<cfset apiAbort({

View file

@ -46,6 +46,7 @@
t.ClaimedByUserID,
tc.Name AS CategoryName,
tc.Color AS CategoryColor,
tt.Name AS TaskTypeName,
o.ID AS OID,
o.UUID AS OrderUUID,
o.UserID,
@ -54,15 +55,20 @@
o.ServicePointID,
o.Remarks,
o.SubmittedOn,
o.TipAmount,
o.DeliveryFee,
sp.Name AS ServicePointName,
sp.TypeID AS ServicePointTypeID,
b.TaxRate,
u.ID AS CustomerUserID,
u.FirstName,
u.LastName,
u.ContactNumber
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
LEFT JOIN ServicePoints sp ON sp.ID = o.ServicePointID
LEFT JOIN Users u ON u.ID = o.UserID
WHERE t.ID = ?
@ -99,6 +105,7 @@
"TaskBusinessID": qTask.BusinessID,
"TaskCategoryID": qTask.CategoryID,
"TaskTypeID": qTask.TaskTypeID ?: 1,
"TaskTypeName": qTask.TaskTypeName ?: "",
"TaskTitle": taskTitle,
"TaskCreatedOn": dateFormat(qTask.CreatedOn, "yyyy-mm-dd") & "T" & timeFormat(qTask.CreatedOn, "HH:mm:ss"),
"TaskStatusID": qTask.ClaimedByUserID GT 0 ? 1 : 0,
@ -160,7 +167,9 @@
ORDER BY oli.ID
", [ { value = qTask.OrderID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
<cfset subtotal = 0>
<cfloop query="qLineItems">
<cfset subtotal += qLineItems.LineItemPrice * qLineItems.Quantity>
<cfset arrayAppend(result.LineItems, {
"LineItemID": qLineItems.OrderLineItemID,
"ParentLineItemID": qLineItems.ParentOrderLineItemID,
@ -172,6 +181,14 @@
"IsModifier": qLineItems.ParentOrderLineItemID GT 0
})>
</cfloop>
<!--- Calculate order total --->
<cfset taxRate = val(qTask.TaxRate)>
<cfset tax = subtotal * taxRate>
<cfset tip = val(qTask.TipAmount)>
<cfset deliveryFee = (val(qTask.OrderTypeID) EQ 3) ? val(qTask.DeliveryFee) : 0>
<cfset platformFee = subtotal * 0.05>
<cfset result["OrderTotal"] = numberFormat(subtotal + tax + tip + deliveryFee + platformFee, "0.00")>
</cfif>
<cfset apiAbort({

View file

@ -131,6 +131,28 @@
})>
</cfloop>
<!--- Calculate OrderTotal for tasks with linked orders --->
<cfloop from="1" to="#arrayLen(tasks)#" index="i">
<cfif val(tasks[i].SourceID) GT 0>
<cfset qOT = queryTimed("
SELECT SUM(oli.Price * oli.Quantity) AS Subtotal, o.TipAmount, o.DeliveryFee, o.OrderTypeID, b.TaxRate
FROM Orders o
INNER JOIN Businesses b ON b.ID = o.BusinessID
LEFT JOIN OrderLineItems oli ON oli.OrderID = o.ID AND oli.IsDeleted = b'0'
WHERE o.ID = ?
GROUP BY o.ID
", [{ value = tasks[i].SourceID, cfsqltype = "cf_sql_integer" }], { datasource = "payfrit" })>
<cfif qOT.recordCount GT 0>
<cfset sub = val(qOT.Subtotal)>
<cfset tasks[i]["OrderTotal"] = numberFormat(sub + (sub * val(qOT.TaxRate)) + val(qOT.TipAmount) + ((val(qOT.OrderTypeID) EQ 3) ? val(qOT.DeliveryFee) : 0) + (sub * 0.05), "0.00")>
<cfelse>
<cfset tasks[i]["OrderTotal"] = 0>
</cfif>
<cfelse>
<cfset tasks[i]["OrderTotal"] = 0>
</cfif>
</cfloop>
<cfset apiAbort({
"OK": true,
"ERROR": "",

View file

@ -111,6 +111,28 @@
})>
</cfloop>
<!--- Calculate OrderTotal for tasks with linked orders --->
<cfloop from="1" to="#arrayLen(tasks)#" index="i">
<cfif val(tasks[i].SourceID) GT 0>
<cfset qOT = queryTimed("
SELECT SUM(oli.Price * oli.Quantity) AS Subtotal, o.TipAmount, o.DeliveryFee, o.OrderTypeID, b.TaxRate
FROM Orders o
INNER JOIN Businesses b ON b.ID = o.BusinessID
LEFT JOIN OrderLineItems oli ON oli.OrderID = o.ID AND oli.IsDeleted = b'0'
WHERE o.ID = ?
GROUP BY o.ID
", [{ value = tasks[i].SourceID, cfsqltype = "cf_sql_integer" }], { datasource = "payfrit" })>
<cfif qOT.recordCount GT 0>
<cfset sub = val(qOT.Subtotal)>
<cfset tasks[i]["OrderTotal"] = numberFormat(sub + (sub * val(qOT.TaxRate)) + val(qOT.TipAmount) + ((val(qOT.OrderTypeID) EQ 3) ? val(qOT.DeliveryFee) : 0) + (sub * 0.05), "0.00")>
<cfelse>
<cfset tasks[i]["OrderTotal"] = 0>
</cfif>
<cfelse>
<cfset tasks[i]["OrderTotal"] = 0>
</cfif>
</cfloop>
<cfset apiAbort({
"OK": true,
"ERROR": "",

BIN
portal/favicon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View file

@ -426,7 +426,7 @@
</div>
</div>
<!-- SP Sharing: Grants You've Created -->
<!-- COMMENTED OUT FOR LAUNCH - Service Point Marketing / Grants (coming soon)
<div class="card" id="spSharingOwnerCard">
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center">
<h3>Grants You've Created</h3>
@ -436,8 +436,6 @@
<div class="loading-text">Loading...</div>
</div>
</div>
<!-- SP Sharing: Invites & Active Grants -->
<div class="card" id="spSharingGuestCard">
<div class="card-header">
<h3>Invites &amp; Active Grants</h3>
@ -447,8 +445,6 @@
</div>
</div>
</div>
<!-- Invite Business Modal -->
<div class="modal-overlay" id="inviteBusinessModal" style="display:none">
<div class="modal" style="max-width:500px">
<div class="modal-header">
@ -502,6 +498,7 @@
</div>
</div>
</div>
END COMMENTED OUT -->
</section>
<!-- Services Page (Task Types) -->
@ -695,6 +692,7 @@
</div>
</div>
<!-- COMMENTED OUT FOR LAUNCH - Tabs/Running Checks feature (coming soon)
<div class="card">
<div class="card-header">
<h3>Tabs / Running Checks</h3>
@ -725,6 +723,7 @@
</div>
</div>
</div>
END COMMENTED OUT -->
<div class="card">
<div class="card-header">

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Menu Builder - Payfrit</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="icon" type="image/png" sizes="512x512" href="favicon-512.png">
<link rel="stylesheet" href="portal.css">
<style>
/* Menu Builder Specific Styles */
@ -992,7 +992,6 @@
<div class="canvas-header">
<h2>
<span id="menuName">Menu Builder</span>
<span style="font-size: 13px; color: var(--gray-500); font-weight: normal;" id="businessLabel"></span>
</h2>
<div class="canvas-actions" style="display: flex; gap: 8px; align-items: center;">
<select id="menuSelector" onchange="MenuBuilder.onMenuSelect(this.value)" style="padding: 8px 12px; border: 1px solid var(--gray-300); border-radius: 6px; background: #fff; font-size: 14px; cursor: pointer;">
@ -1200,12 +1199,11 @@
const data = await response.json();
if (data.OK && data.BUSINESS) {
const biz = data.BUSINESS;
document.getElementById('businessLabel').textContent = `- ${biz.BusinessName}`;
// Update sidebar business info
const businessName = document.getElementById('businessName');
const businessAvatar = document.getElementById('businessAvatar');
if (businessName) businessName.textContent = biz.BusinessName;
if (businessAvatar) businessAvatar.textContent = biz.BusinessName ? biz.BusinessName.charAt(0).toUpperCase() : 'B';
if (businessName) businessName.textContent = biz.Name;
if (businessAvatar) businessAvatar.textContent = biz.Name ? biz.Name.charAt(0).toUpperCase() : 'B';
}
} catch (err) {
console.error('[MenuBuilder] Error loading business:', err);
@ -3288,6 +3286,7 @@
// Store menus list and update selector
this.menus = data.MENUS || [];
this.selectedMenuId = data.SELECTED_MENU_ID || 0;
this.defaultMenuId = data.DEFAULT_MENU_ID || 0;
this.updateMenuSelector();
// Store templates from API (default to empty array if not provided)
@ -3305,6 +3304,7 @@
// Still clear the loading message
this.templates = [];
this.menus = data.MENUS || [];
this.defaultMenuId = data.DEFAULT_MENU_ID || 0;
this.updateMenuSelector();
this.renderTemplateLibrary();
}
@ -3346,14 +3346,18 @@
<div id="menuManagerList" style="display: flex; flex-direction: column; gap: 8px;">
${this.menus.length === 0 ? '<p style="color: var(--gray-500); text-align: center;">No menus created yet. Click "Add Menu" to create one.</p>' : ''}
${this.menus.map(menu => `
<div style="display: flex; align-items: center; gap: 12px; padding: 12px; background: var(--gray-50); border-radius: 8px;">
<div style="display: flex; align-items: center; gap: 12px; padding: 12px; background: var(--gray-50); border-radius: 8px; ${menu.MenuID === this.defaultMenuId ? 'border: 2px solid var(--primary-color);' : ''}">
<div style="flex: 1;">
<div style="font-weight: 600;">${this.escapeHtml(menu.MenuName)}</div>
<div style="font-weight: 600;">
${this.escapeHtml(menu.MenuName)}
${menu.MenuID === this.defaultMenuId ? '<span style="color: var(--primary-color); font-size: 12px; margin-left: 8px;">★ Default</span>' : ''}
</div>
<div style="font-size: 12px; color: var(--gray-500);">
${menu.MenuStartTime && menu.MenuEndTime ? `${menu.MenuStartTime} - ${menu.MenuEndTime}` : 'All day'}
· ${this.formatDaysActive(menu.MenuDaysActive)}
</div>
</div>
${menu.MenuID !== this.defaultMenuId ? `<button class="btn btn-sm" onclick="MenuBuilder.setDefaultMenu(${menu.MenuID})" title="Set as default menu">Set Default</button>` : ''}
<button class="btn btn-sm btn-secondary" onclick="MenuBuilder.editMenu(${menu.MenuID})">Edit</button>
<button class="btn btn-sm btn-danger" onclick="MenuBuilder.deleteMenu(${menu.MenuID})">Delete</button>
</div>
@ -3530,6 +3534,31 @@
}
},
async setDefaultMenu(menuId) {
try {
const response = await fetch(`${this.config.apiBaseUrl}/menu/menus.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
BusinessID: this.config.businessId,
action: 'setDefault',
MenuID: menuId
})
});
const data = await response.json();
if (data.OK) {
this.defaultMenuId = menuId;
this.toast('Default menu updated', 'success');
// Refresh the menu manager display
this.showMenuManager();
} else {
this.toast(data.MESSAGE || 'Failed to set default menu', 'error');
}
} catch (err) {
this.toast('Error setting default menu', 'error');
}
},
// Render template library in sidebar
renderTemplateLibrary() {
const container = document.getElementById('templateLibrary');

View file

@ -754,60 +754,52 @@ const Portal = {
// Render hours editor
this.renderHoursEditor(biz.BUSINESSHOURSDETAIL || biz.HoursDetail || []);
// Load tab settings
const tabsEnabled = biz.SESSIONENABLED || biz.SessionEnabled || 0;
const tabLockMinutes = biz.SESSIONLOCKMINUTES || biz.SessionLockMinutes || 30;
const tabPaymentStrategy = biz.SESSIONPAYMENTSTRATEGY || biz.SessionPaymentStrategy || 'A';
const tabsCheckbox = document.getElementById('tabsEnabled');
const tabDetails = document.getElementById('tabSettingsDetails');
if (tabsCheckbox) {
tabsCheckbox.checked = tabsEnabled == 1;
if (tabDetails) tabDetails.style.display = tabsEnabled == 1 ? 'block' : 'none';
}
const lockInput = document.getElementById('tabLockMinutes');
if (lockInput) lockInput.value = tabLockMinutes;
const strategySelect = document.getElementById('tabPaymentStrategy');
if (strategySelect) strategySelect.value = tabPaymentStrategy;
// COMMENTED OUT FOR LAUNCH - Tabs/Running Checks (coming soon)
// const tabsEnabled = biz.SESSIONENABLED || biz.SessionEnabled || 0;
// const tabLockMinutes = biz.SESSIONLOCKMINUTES || biz.SessionLockMinutes || 30;
// const tabPaymentStrategy = biz.SESSIONPAYMENTSTRATEGY || biz.SessionPaymentStrategy || 'A';
// const tabsCheckbox = document.getElementById('tabsEnabled');
// const tabDetails = document.getElementById('tabSettingsDetails');
// if (tabsCheckbox) {
// tabsCheckbox.checked = tabsEnabled == 1;
// if (tabDetails) tabDetails.style.display = tabsEnabled == 1 ? 'block' : 'none';
// }
// const lockInput = document.getElementById('tabLockMinutes');
// if (lockInput) lockInput.value = tabLockMinutes;
// const strategySelect = document.getElementById('tabPaymentStrategy');
// if (strategySelect) strategySelect.value = tabPaymentStrategy;
}
} catch (err) {
console.error('[Portal] Error loading business info:', err);
}
},
// Save tab settings
async saveTabSettings() {
const tabsEnabled = document.getElementById('tabsEnabled').checked ? 1 : 0;
const tabLockMinutes = parseInt(document.getElementById('tabLockMinutes').value) || 30;
const tabPaymentStrategy = document.getElementById('tabPaymentStrategy').value || 'A';
// Show/hide details based on toggle
const tabDetails = document.getElementById('tabSettingsDetails');
if (tabDetails) tabDetails.style.display = tabsEnabled ? 'block' : 'none';
try {
const response = await fetch(`${this.config.apiBaseUrl}/businesses/updateTabs.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
BusinessID: this.config.businessId,
SessionEnabled: tabsEnabled,
SessionLockMinutes: tabLockMinutes,
SessionPaymentStrategy: tabPaymentStrategy
})
});
const data = await response.json();
if (data.OK) {
this.showToast('Tab settings saved!', 'success');
} else {
this.showToast(data.ERROR || 'Failed to save tab settings', 'error');
}
} catch (err) {
console.error('[Portal] Error saving tab settings:', err);
this.showToast('Error saving tab settings', 'error');
}
},
// COMMENTED OUT FOR LAUNCH - Tabs/Running Checks (coming soon)
// async saveTabSettings() {
// const tabsEnabled = document.getElementById('tabsEnabled').checked ? 1 : 0;
// const tabLockMinutes = parseInt(document.getElementById('tabLockMinutes').value) || 30;
// const tabPaymentStrategy = document.getElementById('tabPaymentStrategy').value || 'A';
// const tabDetails = document.getElementById('tabSettingsDetails');
// if (tabDetails) tabDetails.style.display = tabsEnabled ? 'block' : 'none';
// try {
// const response = await fetch(`${this.config.apiBaseUrl}/businesses/updateTabs.cfm`, {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({
// BusinessID: this.config.businessId,
// SessionEnabled: tabsEnabled,
// SessionLockMinutes: tabLockMinutes,
// SessionPaymentStrategy: tabPaymentStrategy
// })
// });
// const data = await response.json();
// if (data.OK) { this.showToast('Tab settings saved!', 'success'); }
// else { this.showToast(data.ERROR || 'Failed to save tab settings', 'error'); }
// } catch (err) {
// console.error('[Portal] Error saving tab settings:', err);
// this.showToast('Error saving tab settings', 'error');
// }
// },
// Render hours editor
renderHoursEditor(hours) {
@ -1590,8 +1582,9 @@ const Portal = {
await Promise.all([
this.loadServicePoints(),
this.loadBeacons(),
this.loadAssignments(),
this.loadSPSharingPage()
this.loadAssignments()
// COMMENTED OUT FOR LAUNCH - SP Marketing (coming soon)
// this.loadSPSharingPage()
]);
},
@ -3840,209 +3833,18 @@ const Portal = {
_spSharingSelectedBizID: 0,
_spSharingSearchTimer: null,
async loadSPSharingPage() {
const bizId = this.config.businessId;
if (!bizId) return;
const STATUS_LABELS = { 0: 'Pending', 1: 'Active', 2: 'Declined', 3: 'Revoked' };
const STATUS_COLORS = { 0: '#f59e0b', 1: '#10b981', 2: '#ef4444', 3: '#ef4444' };
// Load owner grants
try {
const ownerData = await this.api('/api/grants/list.cfm', { BusinessID: bizId, Role: 'owner' });
const ownerEl = document.getElementById('ownerGrantsList');
if (ownerData.OK && ownerData.Grants.length) {
let html = '<table class="data-table"><thead><tr><th>Guest Business</th><th>Service Point</th><th>Economics</th><th>Status</th><th>Actions</th></tr></thead><tbody>';
ownerData.Grants.forEach(g => {
let econ = g.EconomicsType === 'flat_fee' ? `$${parseFloat(g.EconomicsValue).toFixed(2)}/order` : g.EconomicsType === 'percent_of_orders' ? `${parseFloat(g.EconomicsValue).toFixed(1)}%` : 'None';
let actions = '';
if (g.StatusID === 0 || g.StatusID === 1) {
actions = `<button class="btn btn-sm btn-danger" onclick="Portal.revokeGrant(${g.GrantID})">Revoke</button>`;
}
html += `<tr><td>${this.escapeHtml(g.GuestBusinessName)} (#${g.GuestBusinessID})</td><td>${this.escapeHtml(g.ServicePointName)}</td><td>${econ}</td><td><span style="color:${STATUS_COLORS[g.StatusID]}">${STATUS_LABELS[g.StatusID]}</span></td><td>${actions}</td></tr>`;
});
html += '</tbody></table>';
ownerEl.innerHTML = html;
} else {
ownerEl.innerHTML = '<p style="color:#999;text-align:center;padding:20px">No grants created yet. Use "Invite Business" to share your service points.</p>';
}
} catch (err) {
console.error('[Portal] Error loading owner grants:', err);
}
// Load guest grants
try {
const guestData = await this.api('/api/grants/list.cfm', { BusinessID: bizId, Role: 'guest' });
const guestEl = document.getElementById('guestGrantsList');
if (guestData.OK && guestData.Grants.length) {
let html = '<table class="data-table"><thead><tr><th>Owner Business</th><th>Service Point</th><th>Economics</th><th>Eligibility</th><th>Status</th><th>Actions</th></tr></thead><tbody>';
guestData.Grants.forEach(g => {
let econ = g.EconomicsType === 'flat_fee' ? `$${parseFloat(g.EconomicsValue).toFixed(2)}/order` : g.EconomicsType === 'percent_of_orders' ? `${parseFloat(g.EconomicsValue).toFixed(1)}%` : 'None';
let actions = '';
if (g.StatusID === 0) {
actions = `<button class="btn btn-sm btn-primary" onclick="Portal.acceptGrant(${g.GrantID})">Accept</button> <button class="btn btn-sm" onclick="Portal.declineGrant(${g.GrantID})">Decline</button>`;
}
html += `<tr><td>${this.escapeHtml(g.OwnerBusinessName)} (#${g.OwnerBusinessID})</td><td>${this.escapeHtml(g.ServicePointName)}</td><td>${econ}</td><td>${g.EligibilityScope}</td><td><span style="color:${STATUS_COLORS[g.StatusID]}">${STATUS_LABELS[g.StatusID]}</span></td><td>${actions}</td></tr>`;
});
html += '</tbody></table>';
guestEl.innerHTML = html;
} else {
guestEl.innerHTML = '<p style="color:#999;text-align:center;padding:20px">No invites or active grants from other businesses.</p>';
}
} catch (err) {
console.error('[Portal] Error loading guest grants:', err);
}
// Pre-load service points for the invite modal
try {
const spData = await this.api('/api/servicepoints/list.cfm', { BusinessID: bizId });
const select = document.getElementById('inviteSPSelect');
if (spData.OK && spData.SERVICEPOINTS) {
select.innerHTML = spData.SERVICEPOINTS.map(sp => `<option value="${sp.ServicePointID}">${this.escapeHtml(sp.Name)}</option>`).join('');
}
} catch (err) {
console.error('[Portal] Error loading service points:', err);
}
},
showInviteBusinessModal() {
this._spSharingSelectedBizID = 0;
document.getElementById('inviteBizSearch').value = '';
document.getElementById('inviteBizResults').innerHTML = '';
document.getElementById('inviteBizSelected').style.display = 'none';
document.getElementById('inviteEconType').value = 'none';
document.getElementById('inviteEconValue').style.display = 'none';
document.getElementById('inviteEconValue').value = '';
document.getElementById('inviteEligibility').value = 'public';
document.getElementById('inviteTimePolicy').value = 'always';
document.getElementById('inviteBusinessModal').style.display = 'flex';
},
closeInviteModal() {
document.getElementById('inviteBusinessModal').style.display = 'none';
},
toggleEconValue() {
const type = document.getElementById('inviteEconType').value;
const valEl = document.getElementById('inviteEconValue');
valEl.style.display = (type === 'none') ? 'none' : 'block';
if (type === 'flat_fee') valEl.placeholder = 'Dollar amount per order';
else if (type === 'percent_of_orders') valEl.placeholder = 'Percentage (e.g., 10)';
},
async searchBusinessForInvite() {
clearTimeout(this._spSharingSearchTimer);
const query = document.getElementById('inviteBizSearch').value.trim();
if (query.length < 2) {
document.getElementById('inviteBizResults').innerHTML = '';
return;
}
this._spSharingSearchTimer = setTimeout(async () => {
try {
const data = await this.api('/api/grants/searchBusiness.cfm', {
Query: query,
ExcludeBusinessID: this.config.businessId
});
const resultsEl = document.getElementById('inviteBizResults');
if (data.OK && data.Businesses.length) {
resultsEl.innerHTML = data.Businesses.map(b =>
`<div style="padding:6px 8px;cursor:pointer;border-radius:4px;margin-bottom:2px" class="search-result-item" onmouseover="this.style.background='var(--bg-hover,#f0f0f0)'" onmouseout="this.style.background=''" onclick="Portal.selectInviteBiz(${b.BusinessID},'${this.escapeHtml(b.Name).replace(/'/g, "\\'")}')">${this.escapeHtml(b.Name)} (#${b.BusinessID})</div>`
).join('');
} else {
resultsEl.innerHTML = '<div style="padding:6px;color:#999">No businesses found</div>';
}
} catch (err) {
console.error('[Portal] Business search error:', err);
}
}, 300);
},
selectInviteBiz(bizID, name) {
this._spSharingSelectedBizID = bizID;
document.getElementById('inviteBizResults').innerHTML = '';
document.getElementById('inviteBizSearch').value = '';
document.getElementById('inviteBizSelected').style.display = 'block';
document.getElementById('inviteBizSelectedName').textContent = `${name} (#${bizID})`;
},
async submitGrantInvite() {
if (!this._spSharingSelectedBizID) {
this.toast('Please select a business to invite', 'error');
return;
}
const spID = parseInt(document.getElementById('inviteSPSelect').value);
if (!spID) {
this.toast('Please select a service point', 'error');
return;
}
const payload = {
OwnerBusinessID: this.config.businessId,
GuestBusinessID: this._spSharingSelectedBizID,
ServicePointID: spID,
EconomicsType: document.getElementById('inviteEconType').value,
EconomicsValue: parseFloat(document.getElementById('inviteEconValue').value) || 0,
EligibilityScope: document.getElementById('inviteEligibility').value,
TimePolicyType: document.getElementById('inviteTimePolicy').value
};
try {
const data = await this.api('/api/grants/create.cfm', payload);
if (data.OK) {
this.toast('Invite sent successfully', 'success');
this.closeInviteModal();
await this.loadSPSharingPage();
} else {
this.toast(data.MESSAGE || data.ERROR || 'Error creating grant', 'error');
}
} catch (err) {
this.toast('Error sending invite', 'error');
}
},
async revokeGrant(grantID) {
if (!confirm('Revoke this grant? All access will stop immediately.')) return;
try {
const data = await this.api('/api/grants/revoke.cfm', { GrantID: grantID });
if (data.OK) {
this.toast('Grant revoked', 'success');
await this.loadSPSharingPage();
} else {
this.toast(data.MESSAGE || 'Error revoking grant', 'error');
}
} catch (err) {
this.toast('Error revoking grant', 'error');
}
},
async acceptGrant(grantID) {
try {
const data = await this.api('/api/grants/accept.cfm', { GrantID: grantID });
if (data.OK) {
this.toast('Grant accepted! Service point access is now active.', 'success');
await this.loadSPSharingPage();
} else {
this.toast(data.MESSAGE || 'Error accepting grant', 'error');
}
} catch (err) {
this.toast('Error accepting grant', 'error');
}
},
async declineGrant(grantID) {
if (!confirm('Decline this invite?')) return;
try {
const data = await this.api('/api/grants/decline.cfm', { GrantID: grantID });
if (data.OK) {
this.toast('Grant declined', 'success');
await this.loadSPSharingPage();
} else {
this.toast(data.MESSAGE || 'Error declining grant', 'error');
}
} catch (err) {
this.toast('Error declining grant', 'error');
}
}
/* COMMENTED OUT FOR LAUNCH - SP Marketing / Grants (coming soon)
async loadSPSharingPage() { ... },
showInviteBusinessModal() { ... },
closeInviteModal() { ... },
toggleEconValue() { ... },
async searchBusinessForInvite() { ... },
selectInviteBiz(bizID, name) { ... },
async submitGrantInvite() { ... },
async revokeGrant(grantID) { ... },
async acceptGrant(grantID) { ... },
async declineGrant(grantID) { ... }
*/
};
// Initialize on load