Add takeaway/pickup order support

- getOrCreateCart: only require ServicePointID for dine-in (OrderTypeID=1)
- get.cfm + items.cfm: return OrderTypes from Businesses table
- saveOrderTypes.cfm: new endpoint to save business order type config
- KDS: add PICKUP/DELIVERY badges on order cards
- Portal: add Order Types toggle card in settings (Dine-In always on, Takeaway toggle)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-03-08 10:45:06 -07:00
parent b0fa48ab64
commit 6cdbff129f
8 changed files with 114 additions and 8 deletions

View file

@ -53,7 +53,8 @@ try {
TabMaxAuthAmount, TabMaxAuthAmount,
TabAutoIncreaseThreshold, TabAutoIncreaseThreshold,
TabMaxMembers, TabMaxMembers,
TabApprovalRequired TabApprovalRequired,
OrderTypes
FROM Businesses FROM Businesses
WHERE ID = :businessID WHERE ID = :businessID
", { businessID: businessID }, { datasource: "payfrit" }); ", { businessID: businessID }, { datasource: "payfrit" });
@ -160,7 +161,8 @@ try {
"TabMaxAuthAmount": isNumeric(q.TabMaxAuthAmount) ? q.TabMaxAuthAmount : 1000.00, "TabMaxAuthAmount": isNumeric(q.TabMaxAuthAmount) ? q.TabMaxAuthAmount : 1000.00,
"TabAutoIncreaseThreshold": isNumeric(q.TabAutoIncreaseThreshold) ? q.TabAutoIncreaseThreshold : 0.80, "TabAutoIncreaseThreshold": isNumeric(q.TabAutoIncreaseThreshold) ? q.TabAutoIncreaseThreshold : 0.80,
"TabMaxMembers": isNumeric(q.TabMaxMembers) ? q.TabMaxMembers : 10, "TabMaxMembers": isNumeric(q.TabMaxMembers) ? q.TabMaxMembers : 10,
"TabApprovalRequired": isNumeric(q.TabApprovalRequired) ? q.TabApprovalRequired : 1 "TabApprovalRequired": isNumeric(q.TabApprovalRequired) ? q.TabApprovalRequired : 1,
"OrderTypes": len(q.OrderTypes) ? q.OrderTypes : "1"
}; };
// Add header image URL if extension exists // Add header image URL if extension exists

View file

@ -0,0 +1,51 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* Save Business Order Types
* POST JSON: { "BusinessID": 37, "OrderTypes": "1,2" }
* OrderTypes is a comma-separated list: 1=Dine-In, 2=Takeaway, 3=Delivery
*/
response = { "OK": false };
try {
requestBody = toString(getHttpRequestData().content);
if (!len(requestBody)) {
throw(message="No request body provided");
}
data = deserializeJSON(requestBody);
businessId = structKeyExists(data, "BusinessID") ? val(data.BusinessID) : 0;
if (businessId == 0) {
throw(message="BusinessID is required");
}
orderTypes = structKeyExists(data, "OrderTypes") && isSimpleValue(data.OrderTypes) ? trim(data.OrderTypes) : "1";
// Validate: only allow digits 1-3 separated by commas
if (!reFind("^[1-3](,[1-3])*$", orderTypes)) {
throw(message="OrderTypes must be a comma-separated list of 1, 2, or 3");
}
queryTimed("
UPDATE Businesses SET OrderTypes = :orderTypes
WHERE ID = :bizId
", {
orderTypes: { value: orderTypes, cfsqltype: "cf_sql_varchar" },
bizId: { value: businessId, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
response.OK = true;
response.OrderTypes = orderTypes;
} catch (any e) {
response.ERROR = e.message;
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -558,7 +558,7 @@
<cfset brandColor = ""> <cfset brandColor = "">
<cfset headerImageUrl = ""> <cfset headerImageUrl = "">
<cfset qBrand = queryTimed( <cfset qBrand = queryTimed(
"SELECT BrandColor AS BusinessBrandColor, BrandColorLight AS BusinessBrandColorLight, TaxRate, PayfritFee, HeaderImageExtension, SessionEnabled FROM Businesses WHERE ID = ?", "SELECT BrandColor AS BusinessBrandColor, BrandColorLight AS BusinessBrandColorLight, TaxRate, PayfritFee, HeaderImageExtension, SessionEnabled, OrderTypes FROM Businesses WHERE ID = ?",
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ], [ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" } { datasource = "payfrit" }
)> )>
@ -598,7 +598,8 @@
"PAYFRITFEE": val(businessPayfritFee), "PAYFRITFEE": val(businessPayfritFee),
"SESSIONENABLED": val(qBrand.SessionEnabled), "SESSIONENABLED": val(qBrand.SessionEnabled),
"Menus": menuList, "Menus": menuList,
"SelectedMenuID": requestedMenuID "SelectedMenuID": requestedMenuID,
"ORDERTYPES": len(qBrand.OrderTypes) ? qBrand.OrderTypes : "1"
})> })>
<cfcatch> <cfcatch>

View file

@ -175,12 +175,12 @@
})> })>
</cfif> </cfif>
<!--- Require ServicePointID for now (delivery/takeaway not yet supported) ---> <!--- Require ServicePointID only for dine-in orders --->
<cfif ServicePointID LTE 0> <cfif OrderTypeID EQ 1 AND ServicePointID LTE 0>
<cfset apiAbort({ <cfset apiAbort({
"OK": false, "OK": false,
"ERROR": "missing_service_point", "ERROR": "missing_service_point",
"MESSAGE": "ServicePointID is required. Please scan a table beacon.", "MESSAGE": "ServicePointID is required for dine-in. Please scan a table beacon.",
"DETAIL": "" "DETAIL": ""
})> })>
</cfif> </cfif>

View file

@ -80,6 +80,10 @@
.station-btn svg { width: 24px; height: 24px; opacity: 0.5; } .station-btn svg { width: 24px; height: 24px; opacity: 0.5; }
.station-name-display { font-size: 11px; color: #555; margin-left: 8px; text-transform: uppercase; letter-spacing: 1px; } .station-name-display { font-size: 11px; color: #555; margin-left: 8px; text-transform: uppercase; letter-spacing: 1px; }
/* Order type badges */
.badge-pickup { display: inline-block; font-size: 10px; font-weight: 700; padding: 2px 6px; border-radius: 4px; background: #1a2a00; color: #a3e635; border: 1px solid #a3e63533; vertical-align: middle; margin-left: 6px; letter-spacing: 1px; }
.badge-delivery { display: inline-block; font-size: 10px; font-weight: 700; padding: 2px 6px; border-radius: 4px; background: #2a1a00; color: #fb923c; border: 1px solid #fb923c33; vertical-align: middle; margin-left: 6px; letter-spacing: 1px; }
/* Expand toggle */ /* Expand toggle */
.expand-toggle { background: none; border: 1px solid #333; color: #555; padding: 4px 10px; border-radius: 4px; font-size: 11px; cursor: pointer; margin-bottom: 10px; width: 100%; transition: all 0.2s; } .expand-toggle { background: none; border: 1px solid #333; color: #555; padding: 4px 10px; border-radius: 4px; font-size: 11px; cursor: pointer; margin-bottom: 10px; width: 100%; transition: all 0.2s; }
.expand-toggle:hover { border-color: #555; color: #888; } .expand-toggle:hover { border-color: #555; color: #888; }

View file

@ -334,7 +334,7 @@ function renderOrder(order) {
return ` return `
<div class="order-card ${statusClass}"> <div class="order-card ${statusClass}">
<div class="order-header"> <div class="order-header">
<div class="order-number">#${order.OrderID}</div> <div class="order-number">#${order.OrderID}${order.OrderTypeID === 2 ? ' <span class="badge-pickup">PICKUP</span>' : ''}${order.OrderTypeID === 3 ? ' <span class="badge-delivery">DELIVERY</span>' : ''}</div>
<div class="order-time"> <div class="order-time">
<div class="elapsed-time ${timeClass}">${formatElapsedTime(elapsedTime)}</div> <div class="elapsed-time ${timeClass}">${formatElapsedTime(elapsedTime)}</div>
<div class="submit-time">${formatSubmitTime(order.SubmittedOn)}</div> <div class="submit-time">${formatSubmitTime(order.SubmittedOn)}</div>

View file

@ -689,6 +689,31 @@
</div> </div>
</div> </div>
<div class="card">
<div class="card-header">
<h3>Order Types</h3>
</div>
<div class="card-body">
<p style="color: #666; font-size: 13px; margin-bottom: 16px;">Choose which order types your business supports. Dine-in is always enabled.</p>
<div style="display: flex; flex-direction: column; gap: 12px;">
<div style="display: flex; align-items: center; gap: 12px;">
<label class="toggle">
<input type="checkbox" id="orderTypeDineIn" checked disabled>
<span class="toggle-slider"></span>
</label>
<span>Dine-In</span>
</div>
<div style="display: flex; align-items: center; gap: 12px;">
<label class="toggle">
<input type="checkbox" id="orderTypeTakeaway" onchange="Portal.saveOrderTypes()">
<span class="toggle-slider"></span>
</label>
<span>Takeaway / Pickup</span>
</div>
</div>
</div>
</div>
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3>Tabs / Running Checks</h3> <h3>Tabs / Running Checks</h3>

View file

@ -864,6 +864,11 @@ const Portal = {
if (thresholdInput) thresholdInput.value = biz.TABAUTOINCREASETHRESHOLD || biz.TabAutoIncreaseThreshold || 0.80; if (thresholdInput) thresholdInput.value = biz.TABAUTOINCREASETHRESHOLD || biz.TabAutoIncreaseThreshold || 0.80;
const approvalCheckbox = document.getElementById('tabApprovalRequired'); const approvalCheckbox = document.getElementById('tabApprovalRequired');
if (approvalCheckbox) approvalCheckbox.checked = (biz.TABAPPROVALREQUIRED || biz.TabApprovalRequired || 1) == 1; if (approvalCheckbox) approvalCheckbox.checked = (biz.TABAPPROVALREQUIRED || biz.TabApprovalRequired || 1) == 1;
// Order types
const orderTypes = (biz.ORDERTYPES || biz.OrderTypes || '1').split(',');
const takeawayCheckbox = document.getElementById('orderTypeTakeaway');
if (takeawayCheckbox) takeawayCheckbox.checked = orderTypes.includes('2');
} }
} catch (err) { } catch (err) {
console.error('[Portal] Error loading business info:', err); console.error('[Portal] Error loading business info:', err);
@ -901,6 +906,24 @@ const Portal = {
} }
}, },
async saveOrderTypes() {
const types = ['1']; // Dine-in always enabled
if (document.getElementById('orderTypeTakeaway').checked) types.push('2');
try {
const response = await fetch(`${this.config.apiBaseUrl}/businesses/saveOrderTypes.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ BusinessID: this.config.businessId, OrderTypes: types.join(',') })
});
const data = await response.json();
if (data.OK) { this.showToast('Order types saved!', 'success'); }
else { this.showToast(data.ERROR || 'Failed to save order types', 'error'); }
} catch (err) {
console.error('[Portal] Error saving order types:', err);
this.showToast('Error saving order types', 'error');
}
},
// Render hours editor // Render hours editor
renderHoursEditor(hours) { renderHoursEditor(hours) {
const container = document.getElementById('hoursEditor'); const container = document.getElementById('hoursEditor');