Add branding features: header upload and brand color picker

- Add uploadHeader.cfm API for 1200px header images
- Add saveBrandColor.cfm API for hex color storage
- Add Branding section to menu builder sidebar
- Fix header upload path and permissions
- Various beacon and service point API improvements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-01-18 12:14:24 -08:00
parent d73c4d60d3
commit d4e0ae1162
41 changed files with 10012 additions and 738 deletions

View file

@ -99,29 +99,27 @@
<script> <script>
(function boot(){ (function boot(){
document.getElementById("jsStatus").textContent = "JS loaded OK"; document.getElementById("jsStatus").textContent = "JS loaded OK - BusinessID: #request.BusinessID#";
})(); })();
const BUSINESS_ID = #val(request.BusinessID)#;
function show(o){ function show(o){
document.getElementById("resp").textContent = JSON.stringify(o, null, 2); document.getElementById("resp").textContent = JSON.stringify(o, null, 2);
} }
/*
IMPORTANT:
Your Lucee error shows /api/... is resolving to C:\lucee\tomcat\webapps\ROOT\api\...
So we must call the API under the biz.payfrit.com context explicitly.
*/
const API_BASE = "/biz.payfrit.com/api"; const API_BASE = "/biz.payfrit.com/api";
async function api(path, bodyObj) { async function api(path, bodyObj) {
const fullPath = API_BASE + path; const fullPath = API_BASE + path;
show({ INFO:"API CALL", PATH: fullPath, BODY: bodyObj || {} }); bodyObj = bodyObj || {};
bodyObj.BusinessID = BUSINESS_ID;
try { try {
const res = await fetch(fullPath, { const res = await fetch(fullPath, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(bodyObj || {}) body: JSON.stringify(bodyObj)
}); });
const txt = await res.text(); const txt = await res.text();

View file

@ -104,9 +104,11 @@
<script> <script>
(function boot(){ (function boot(){
document.getElementById("jsStatus").textContent = "JS loaded OK"; document.getElementById("jsStatus").textContent = "JS loaded OK - BusinessID: #request.BusinessID#";
})(); })();
const BUSINESS_ID = #val(request.BusinessID)#;
function show(o){ function show(o){
document.getElementById("resp").textContent = JSON.stringify(o, null, 2); document.getElementById("resp").textContent = JSON.stringify(o, null, 2);
} }
@ -115,11 +117,14 @@ const API_BASE = "/biz.payfrit.com/api";
async function api(path, bodyObj) { async function api(path, bodyObj) {
const fullPath = API_BASE + path; const fullPath = API_BASE + path;
bodyObj = bodyObj || {};
bodyObj.BusinessID = BUSINESS_ID;
try { try {
const res = await fetch(fullPath, { const res = await fetch(fullPath, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(bodyObj || {}) body: JSON.stringify(bodyObj)
}); });
const txt = await res.text(); const txt = await res.text();
try { try {

View file

@ -91,9 +91,21 @@ if (len(request._api_path)) {
if (findNoCase("/api/businesses/list.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/businesses/list.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/businesses/get.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/businesses/get.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/businesses/update.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/businesses/updateHours.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/servicepoints/list.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/servicepoints/list.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/servicepoints/get.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/servicepoints/save.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/servicepoints/delete.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/beacons/list.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/beacons/get.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/beacons/save.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/beacons/delete.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/beacons/list_all.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/beacons/list_all.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/beacons/getBusinessFromBeacon.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/beacons/getBusinessFromBeacon.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/assignments/list.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/assignments/save.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/assignments/delete.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/menu/items.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/menu/items.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/menu/clearAllData.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/menu/clearAllData.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/menu/clearOrders.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/menu/clearOrders.cfm", request._api_path)) request._api_isPublic = true;
@ -151,6 +163,8 @@ if (len(request._api_path)) {
if (findNoCase("/api/menu/getForBuilder.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/menu/getForBuilder.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/menu/saveFromBuilder.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/menu/saveFromBuilder.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/menu/updateStations.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/menu/updateStations.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/menu/uploadHeader.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/businesses/saveBrandColor.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/tasks/create.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/tasks/create.cfm", request._api_path)) request._api_isPublic = true;
// Debug endpoints // Debug endpoints

View file

@ -0,0 +1,86 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
/**
* Cleanup Lazy Daisy Beacons
* - Removes duplicate beacons created by setupBeaconTables
* - Updates original beacons with proper names
*/
response = { "OK": false, "steps": [] };
try {
lazyDaisyID = 37;
// Delete duplicate assignments for beacons 7, 8, 9
queryExecute("
DELETE FROM lt_Beacon_Businesses_ServicePoints
WHERE BeaconID IN (7, 8, 9) AND BusinessID = :bizId
", { bizId: lazyDaisyID }, { datasource: "payfrit" });
response.steps.append("Deleted duplicate assignments for beacons 7, 8, 9");
// Delete duplicate beacons 7, 8, 9
queryExecute("
DELETE FROM Beacons
WHERE BeaconID IN (7, 8, 9) AND BeaconBusinessID = :bizId
", { bizId: lazyDaisyID }, { datasource: "payfrit" });
response.steps.append("Deleted duplicate beacons 7, 8, 9");
// Update original beacons with names based on their service point assignments
// Beacon 4 -> Table 1 (ServicePointID 4)
// Beacon 5 -> Table 2 (ServicePointID 5)
// Beacon 6 -> Table 3 (ServicePointID 6)
queryExecute("
UPDATE Beacons SET BeaconName = 'Beacon - Table 1'
WHERE BeaconID = 4 AND BeaconBusinessID = :bizId
", { bizId: lazyDaisyID }, { datasource: "payfrit" });
response.steps.append("Updated Beacon 4 name to 'Beacon - Table 1'");
queryExecute("
UPDATE Beacons SET BeaconName = 'Beacon - Table 2'
WHERE BeaconID = 5 AND BeaconBusinessID = :bizId
", { bizId: lazyDaisyID }, { datasource: "payfrit" });
response.steps.append("Updated Beacon 5 name to 'Beacon - Table 2'");
queryExecute("
UPDATE Beacons SET BeaconName = 'Beacon - Table 3'
WHERE BeaconID = 6 AND BeaconBusinessID = :bizId
", { bizId: lazyDaisyID }, { datasource: "payfrit" });
response.steps.append("Updated Beacon 6 name to 'Beacon - Table 3'");
// Get final status
qFinal = queryExecute("
SELECT lt.BeaconID, b.BeaconUUID, b.BeaconName, lt.BusinessID, biz.BusinessName, lt.ServicePointID, sp.ServicePointName
FROM lt_Beacon_Businesses_ServicePoints lt
JOIN Beacons b ON b.BeaconID = lt.BeaconID
JOIN Businesses biz ON biz.BusinessID = lt.BusinessID
LEFT JOIN ServicePoints sp ON sp.ServicePointID = lt.ServicePointID
WHERE lt.BusinessID = :bizId
ORDER BY lt.BeaconID
", { bizId: lazyDaisyID }, { datasource: "payfrit" });
beacons = [];
for (i = 1; i <= qFinal.recordCount; i++) {
arrayAppend(beacons, {
"BeaconID": qFinal.BeaconID[i],
"BeaconName": qFinal.BeaconName[i],
"UUID": qFinal.BeaconUUID[i],
"BusinessName": qFinal.BusinessName[i],
"ServicePointName": qFinal.ServicePointName[i]
});
}
response.OK = true;
response.beacons = beacons;
} catch (any e) {
response.error = e.message;
if (len(e.detail)) {
response.detail = e.detail;
}
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -0,0 +1,127 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
response = { "OK": false, "steps": [] };
try {
// Keep only Lazy Daisy (BusinessID 37)
keepBusinessID = 37;
// First, reassign all beacons to Lazy Daisy
queryExecute("
UPDATE lt_Beacon_Businesses_ServicePoints
SET BusinessID = :keepID
", { keepID: keepBusinessID }, { datasource: "payfrit" });
response.steps.append("Reassigned all beacons to Lazy Daisy");
// Get list of businesses to delete
qBiz = queryExecute("
SELECT BusinessID, BusinessName FROM Businesses WHERE BusinessID != :keepID
", { keepID: keepBusinessID }, { datasource: "payfrit" });
deletedBusinesses = [];
for (i = 1; i <= qBiz.recordCount; i++) {
arrayAppend(deletedBusinesses, qBiz.BusinessName[i]);
}
response.steps.append("Found " & qBiz.recordCount & " businesses to delete");
// Delete related data first (foreign key constraints)
// Delete ItemTemplateLinks for items from other businesses
queryExecute("
DELETE itl FROM ItemTemplateLinks itl
JOIN Items i ON i.ItemID = itl.ItemID
WHERE i.ItemBusinessID != :keepID
", { keepID: keepBusinessID }, { datasource: "payfrit" });
response.steps.append("Deleted ItemTemplateLinks for other businesses");
// Delete Items for other businesses
qItems = queryExecute("
SELECT COUNT(*) as cnt FROM Items WHERE ItemBusinessID != :keepID
", { keepID: keepBusinessID }, { datasource: "payfrit" });
queryExecute("
DELETE FROM Items WHERE ItemBusinessID != :keepID
", { keepID: keepBusinessID }, { datasource: "payfrit" });
response.steps.append("Deleted " & qItems.cnt & " items from other businesses");
// Delete Categories for other businesses
queryExecute("
DELETE FROM Categories WHERE CategoryBusinessID != :keepID
", { keepID: keepBusinessID }, { datasource: "payfrit" });
response.steps.append("Deleted categories from other businesses");
// Delete Hours for other businesses
queryExecute("
DELETE FROM Hours WHERE HoursBusinessID != :keepID
", { keepID: keepBusinessID }, { datasource: "payfrit" });
response.steps.append("Deleted hours from other businesses");
// Delete Employees for other businesses (skip if table doesn't exist)
try {
queryExecute("
DELETE FROM Employees WHERE EmployeeBusinessID != :keepID
", { keepID: keepBusinessID }, { datasource: "payfrit" });
response.steps.append("Deleted employees from other businesses");
} catch (any e) {
response.steps.append("Skipped employees (table may not exist)");
}
// Delete ServicePoints for other businesses (skip if table doesn't exist)
try {
queryExecute("
DELETE FROM ServicePoints WHERE ServicePointBusinessID != :keepID
", { keepID: keepBusinessID }, { datasource: "payfrit" });
response.steps.append("Deleted service points from other businesses");
} catch (any e) {
response.steps.append("Skipped service points (table may not exist)");
}
// Delete Stations for other businesses (skip if table doesn't exist)
try {
queryExecute("
DELETE FROM Stations WHERE StationBusinessID != :keepID
", { keepID: keepBusinessID }, { datasource: "payfrit" });
response.steps.append("Deleted stations from other businesses");
} catch (any e) {
response.steps.append("Skipped stations (table may not exist)");
}
// Finally delete the businesses themselves
queryExecute("
DELETE FROM Businesses WHERE BusinessID != :keepID
", { keepID: keepBusinessID }, { datasource: "payfrit" });
response.steps.append("Deleted " & arrayLen(deletedBusinesses) & " businesses");
// Get beacon status
qBeacons = queryExecute("
SELECT lt.BeaconID, b.BeaconUUID, lt.BusinessID, biz.BusinessName, lt.ServicePointID
FROM lt_Beacon_Businesses_ServicePoints lt
JOIN Beacons b ON b.BeaconID = lt.BeaconID
JOIN Businesses biz ON biz.BusinessID = lt.BusinessID
", {}, { datasource: "payfrit" });
beacons = [];
for (i = 1; i <= qBeacons.recordCount; i++) {
arrayAppend(beacons, {
"BeaconID": qBeacons.BeaconID[i],
"UUID": qBeacons.BeaconUUID[i],
"BusinessID": qBeacons.BusinessID[i],
"BusinessName": qBeacons.BusinessName[i],
"ServicePointID": qBeacons.ServicePointID[i]
});
}
response.OK = true;
response.deletedBusinesses = deletedBusinesses;
response.beacons = beacons;
} catch (any e) {
response.error = e.message;
if (len(e.detail)) {
response.detail = e.detail;
}
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -0,0 +1,49 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
response = { "OK": false, "steps": [] };
try {
// Business IDs to delete (Lo/Cal Coffee and SANTA MONICA entries)
businessIDs = [38, 39, 40, 41, 42];
for (bizID in businessIDs) {
// Delete ItemTemplateLinks for items belonging to this business
queryExecute("
DELETE itl FROM ItemTemplateLinks itl
INNER JOIN Items i ON i.ItemID = itl.ItemID
WHERE i.ItemBusinessID = :bizID
", { bizID: bizID }, { datasource: "payfrit" });
// Delete Items
queryExecute("DELETE FROM Items WHERE ItemBusinessID = :bizID", { bizID: bizID }, { datasource: "payfrit" });
// Delete Categories
queryExecute("DELETE FROM Categories WHERE CategoryBusinessID = :bizID", { bizID: bizID }, { datasource: "payfrit" });
// Delete Hours
queryExecute("DELETE FROM Hours WHERE HoursBusinessID = :bizID", { bizID: bizID }, { datasource: "payfrit" });
// Delete Addresses linked to this business
queryExecute("DELETE FROM Addresses WHERE AddressBusinessID = :bizID", { bizID: bizID }, { datasource: "payfrit" });
// Delete the Business itself
queryExecute("DELETE FROM Businesses WHERE BusinessID = :bizID", { bizID: bizID }, { datasource: "payfrit" });
response.steps.append("Deleted business " & bizID & " and all related data");
}
response.OK = true;
response.message = "Cleared all Lo/Cal Coffee and SANTA MONICA businesses";
} catch (any e) {
response.error = e.message;
if (len(e.detail)) {
response.detail = e.detail;
}
}
writeOutput(serializeJSON(response));
</cfscript>

104
api/admin/createBeacons.cfm Normal file
View file

@ -0,0 +1,104 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
response = { "OK": false, "steps": [] };
try {
lazyDaisyID = 37;
// The three beacon UUIDs we need
beaconUUIDs = [
"626C7565636861726D31000000000001",
"1B6295D54F744C58A2D8CD83CA26BDF4",
"7777772E6B6B6D636E2E636F6D000001"
];
// Create beacons
for (i = 1; i <= arrayLen(beaconUUIDs); i++) {
uuid = beaconUUIDs[i];
// Check if beacon exists
qB = queryExecute("SELECT BeaconID FROM Beacons WHERE BeaconUUID = :uuid", { uuid: uuid }, { datasource: "payfrit" });
if (qB.recordCount == 0) {
queryExecute("INSERT INTO Beacons (BeaconUUID, BeaconBusinessID) VALUES (:uuid, :bizID)", { uuid: uuid, bizID: lazyDaisyID }, { datasource: "payfrit" });
qNew = queryExecute("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" });
beaconID = qNew.id;
response.steps.append("Created beacon " & beaconID & " with UUID: " & uuid);
} else {
beaconID = qB.BeaconID;
response.steps.append("Beacon exists: " & beaconID & " with UUID: " & uuid);
}
}
// Get service point Table 1
qSP = queryExecute("
SELECT ServicePointID FROM ServicePoints
WHERE ServicePointBusinessID = :bizID AND ServicePointName = 'Table 1'
", { bizID: lazyDaisyID }, { datasource: "payfrit" });
if (qSP.recordCount == 0) {
queryExecute("
INSERT INTO ServicePoints (ServicePointBusinessID, ServicePointName, ServicePointTypeID)
VALUES (:bizID, 'Table 1', 1)
", { bizID: lazyDaisyID }, { datasource: "payfrit" });
qSP = queryExecute("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" });
servicePointID = qSP.id;
response.steps.append("Created service point 'Table 1' (ID: " & servicePointID & ")");
} else {
servicePointID = qSP.ServicePointID;
response.steps.append("Found service point 'Table 1' (ID: " & servicePointID & ")");
}
// Get all beacons and map them
qBeacons = queryExecute("SELECT BeaconID, BeaconUUID FROM Beacons", {}, { datasource: "payfrit" });
for (i = 1; i <= qBeacons.recordCount; i++) {
beaconID = qBeacons.BeaconID[i];
// Delete old mapping if exists
queryExecute("DELETE FROM lt_Beacon_Businesses_ServicePoints WHERE BeaconID = :beaconID", { beaconID: beaconID }, { datasource: "payfrit" });
// Create new mapping
queryExecute("
INSERT INTO lt_Beacon_Businesses_ServicePoints (BeaconID, BusinessID, ServicePointID)
VALUES (:beaconID, :bizID, :spID)
", { beaconID: beaconID, bizID: lazyDaisyID, spID: servicePointID }, { datasource: "payfrit" });
response.steps.append("Mapped beacon " & beaconID & " to Lazy Daisy, Table 1");
}
// Get final status
qFinal = queryExecute("
SELECT lt.BeaconID, b.BeaconUUID, lt.BusinessID, biz.BusinessName, lt.ServicePointID, sp.ServicePointName
FROM lt_Beacon_Businesses_ServicePoints lt
JOIN Beacons b ON b.BeaconID = lt.BeaconID
JOIN Businesses biz ON biz.BusinessID = lt.BusinessID
LEFT JOIN ServicePoints sp ON sp.ServicePointID = lt.ServicePointID
", {}, { datasource: "payfrit" });
beacons = [];
for (i = 1; i <= qFinal.recordCount; i++) {
arrayAppend(beacons, {
"BeaconID": qFinal.BeaconID[i],
"UUID": qFinal.BeaconUUID[i],
"BusinessID": qFinal.BusinessID[i],
"BusinessName": qFinal.BusinessName[i],
"ServicePointID": qFinal.ServicePointID[i],
"ServicePointName": qFinal.ServicePointName[i]
});
}
response.OK = true;
response.beacons = beacons;
} catch (any e) {
response.error = e.message;
if (len(e.detail)) {
response.detail = e.detail;
}
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -0,0 +1,20 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
response = { "OK": false };
try {
queryExecute("
UPDATE Businesses SET BusinessHeaderImageExtension = 'jpg' WHERE BusinessID = 37
", {}, { datasource: "payfrit" });
response.OK = true;
response.message = "Set BusinessHeaderImageExtension to 'jpg' for business 37";
} catch (any e) {
response.error = e.message;
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -0,0 +1,104 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
/**
* Setup Lazy Daisy Beacons
* Creates a beacon for each service point and links them
*/
response = { "OK": false, "steps": [] };
try {
lazyDaisyID = 37;
// Get all service points for Lazy Daisy
qServicePoints = queryExecute("
SELECT ServicePointID, ServicePointName
FROM ServicePoints
WHERE ServicePointBusinessID = :bizID AND ServicePointIsActive = 1
ORDER BY ServicePointID
", { bizID: lazyDaisyID }, { datasource: "payfrit" });
response.steps.append("Found " & qServicePoints.recordCount & " service points for Lazy Daisy");
// Create a beacon for each service point
beaconsCreated = 0;
for (sp in qServicePoints) {
beaconName = "Beacon - " & sp.ServicePointName;
// Check if beacon already exists for this business with this name
qExisting = queryExecute("
SELECT BeaconID FROM Beacons
WHERE BeaconBusinessID = :bizId AND BeaconName = :name
", { bizId: lazyDaisyID, name: beaconName }, { datasource: "payfrit" });
if (qExisting.recordCount == 0) {
// Generate a unique UUID for this beacon (32 hex chars, no dashes)
beaconUUID = "PAYFRIT00037" & numberFormat(sp.ServicePointID, "0000000000000000000");
queryExecute("
INSERT INTO Beacons (BeaconBusinessID, BeaconName, BeaconUUID, BeaconIsActive)
VALUES (:bizId, :name, :uuid, 1)
", {
bizId: lazyDaisyID,
name: beaconName,
uuid: beaconUUID
}, { datasource: "payfrit" });
qNewBeacon = queryExecute("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" });
newBeaconId = qNewBeacon.id;
// Create assignment to service point
queryExecute("
INSERT INTO lt_Beacon_Businesses_ServicePoints
(BeaconID, BusinessID, ServicePointID, lt_Beacon_Businesses_ServicePointAssignedByUserID)
VALUES (:beaconId, :bizId, :spId, 1)
", {
beaconId: newBeaconId,
bizId: lazyDaisyID,
spId: sp.ServicePointID
}, { datasource: "payfrit" });
response.steps.append("Created beacon '" & beaconName & "' (ID: " & newBeaconId & ") -> " & sp.ServicePointName);
beaconsCreated++;
} else {
response.steps.append("Beacon '" & beaconName & "' already exists, skipping");
}
}
// Get final status
qFinal = queryExecute("
SELECT lt.BeaconID, b.BeaconUUID, b.BeaconName, lt.BusinessID, biz.BusinessName, lt.ServicePointID, sp.ServicePointName
FROM lt_Beacon_Businesses_ServicePoints lt
JOIN Beacons b ON b.BeaconID = lt.BeaconID
JOIN Businesses biz ON biz.BusinessID = lt.BusinessID
LEFT JOIN ServicePoints sp ON sp.ServicePointID = lt.ServicePointID
WHERE lt.BusinessID = :bizId
ORDER BY sp.ServicePointName
", { bizId: lazyDaisyID }, { datasource: "payfrit" });
beacons = [];
for (i = 1; i <= qFinal.recordCount; i++) {
arrayAppend(beacons, {
"BeaconID": qFinal.BeaconID[i],
"BeaconName": qFinal.BeaconName[i],
"UUID": qFinal.BeaconUUID[i],
"BusinessName": qFinal.BusinessName[i],
"ServicePointName": qFinal.ServicePointName[i]
});
}
response.OK = true;
response.beaconsCreated = beaconsCreated;
response.beacons = beacons;
} catch (any e) {
response.error = e.message;
if (len(e.detail)) {
response.detail = e.detail;
}
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -0,0 +1,91 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
response = { "OK": false, "steps": [] };
try {
lazyDaisyID = 37;
// Get all beacons
qBeacons = queryExecute("SELECT BeaconID, BeaconUUID FROM Beacons", {}, { datasource: "payfrit" });
response.steps.append("Found " & qBeacons.recordCount & " beacons");
// Create service point for Table 1 if it doesn't exist
qSP = queryExecute("
SELECT ServicePointID FROM ServicePoints
WHERE ServicePointBusinessID = :bizID AND ServicePointName = 'Table 1'
", { bizID: lazyDaisyID }, { datasource: "payfrit" });
if (qSP.recordCount == 0) {
queryExecute("
INSERT INTO ServicePoints (ServicePointBusinessID, ServicePointName, ServicePointTypeID)
VALUES (:bizID, 'Table 1', 1)
", { bizID: lazyDaisyID }, { datasource: "payfrit" });
qSP = queryExecute("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" });
servicePointID = qSP.id;
response.steps.append("Created service point 'Table 1' (ID: " & servicePointID & ")");
} else {
servicePointID = qSP.ServicePointID;
response.steps.append("Found existing service point 'Table 1' (ID: " & servicePointID & ")");
}
// Map all beacons to Lazy Daisy with Table 1
for (i = 1; i <= qBeacons.recordCount; i++) {
beaconID = qBeacons.BeaconID[i];
// Check if mapping exists
qMap = queryExecute("
SELECT * FROM lt_Beacon_Businesses_ServicePoints WHERE BeaconID = :beaconID
", { beaconID: beaconID }, { datasource: "payfrit" });
if (qMap.recordCount == 0) {
queryExecute("
INSERT INTO lt_Beacon_Businesses_ServicePoints (BeaconID, BusinessID, ServicePointID)
VALUES (:beaconID, :bizID, :spID)
", { beaconID: beaconID, bizID: lazyDaisyID, spID: servicePointID }, { datasource: "payfrit" });
response.steps.append("Created mapping for beacon " & beaconID);
} else {
queryExecute("
UPDATE lt_Beacon_Businesses_ServicePoints
SET BusinessID = :bizID, ServicePointID = :spID
WHERE BeaconID = :beaconID
", { beaconID: beaconID, bizID: lazyDaisyID, spID: servicePointID }, { datasource: "payfrit" });
response.steps.append("Updated mapping for beacon " & beaconID);
}
}
// Get final status
qFinal = queryExecute("
SELECT lt.BeaconID, b.BeaconUUID, lt.BusinessID, biz.BusinessName, lt.ServicePointID, sp.ServicePointName
FROM lt_Beacon_Businesses_ServicePoints lt
JOIN Beacons b ON b.BeaconID = lt.BeaconID
JOIN Businesses biz ON biz.BusinessID = lt.BusinessID
LEFT JOIN ServicePoints sp ON sp.ServicePointID = lt.ServicePointID
", {}, { datasource: "payfrit" });
beacons = [];
for (i = 1; i <= qFinal.recordCount; i++) {
arrayAppend(beacons, {
"BeaconID": qFinal.BeaconID[i],
"UUID": qFinal.BeaconUUID[i],
"BusinessID": qFinal.BusinessID[i],
"BusinessName": qFinal.BusinessName[i],
"ServicePointID": qFinal.ServicePointID[i],
"ServicePointName": qFinal.ServicePointName[i]
});
}
response.OK = true;
response.beacons = beacons;
} catch (any e) {
response.error = e.message;
if (len(e.detail)) {
response.detail = e.detail;
}
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -27,9 +27,6 @@ function readJsonBody(){
} }
/* ---------- AUTH CONTEXT ---------- */ /* ---------- AUTH CONTEXT ---------- */
if (!structKeyExists(request,"UserID") || !isNumeric(request.UserID) || request.UserID LTE 0){
apiAbort({OK=false,ERROR="not_logged_in"});
}
if (!structKeyExists(request,"BusinessID") || !isNumeric(request.BusinessID) || request.BusinessID LTE 0){ if (!structKeyExists(request,"BusinessID") || !isNumeric(request.BusinessID) || request.BusinessID LTE 0){
apiAbort({OK=false,ERROR="no_business_selected"}); apiAbort({OK=false,ERROR="no_business_selected"});
} }
@ -49,7 +46,7 @@ RelID = int(data.lt_Beacon_Businesses_ServicePointID);
</cfscript> </cfscript>
<!--- Confirm the row exists for this BusinessID (and capture what it was) ---> <!--- Confirm the row exists for this BusinessID (and capture what it was) --->
<cfquery name="qFind" datasource="#application.datasource#"> <cfquery name="qFind" datasource="payfrit">
SELECT SELECT
lt_Beacon_Businesses_ServicePointID, lt_Beacon_Businesses_ServicePointID,
BeaconID, BeaconID,
@ -73,7 +70,7 @@ RelID = int(data.lt_Beacon_Businesses_ServicePointID);
</cfif> </cfif>
<!--- Delete it ---> <!--- Delete it --->
<cfquery datasource="#application.datasource#"> <cfquery datasource="payfrit">
DELETE FROM lt_Beacon_Businesses_ServicePoints DELETE FROM lt_Beacon_Businesses_ServicePoints
WHERE lt_Beacon_Businesses_ServicePointID = WHERE lt_Beacon_Businesses_ServicePointID =
<cfqueryparam cfsqltype="cf_sql_integer" value="#RelID#"> <cfqueryparam cfsqltype="cf_sql_integer" value="#RelID#">

View file

@ -1,91 +1,49 @@
<cfsetting showdebugoutput="false"> <cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true"> <cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript> <cfscript>
/*
FILE: C:\lucee\tomcat\webapps\ROOT\biz.payfrit.com\api\businesses\list.cfm
MVP:
- PUBLIC endpoint (no login required)
- Returns a minimal list of restaurants/businesses for the drilldown.
- IMPORTANT: Output JSON manually to preserve exact key casing.
RESPONSE (case-sensitive keys):
{
"OK": true,
"COUNT": 15,
"Businesses": [
{ "BusinessID": 12, "BusinessName": "Annie's Soul Delicious" },
...
]
}
*/
function apiAbort(obj) { function apiAbort(obj) {
writeOutput('{'); writeOutput(serializeJSON(obj));
writeOutput('"OK":' & (obj.OK ? 'true' : 'false'));
if (structKeyExists(obj, "ERROR")) {
writeOutput(',"ERROR":"' & encodeForJSON(toString(obj.ERROR)) & '"');
}
if (structKeyExists(obj, "DETAIL")) {
writeOutput(',"DETAIL":"' & encodeForJSON(toString(obj.DETAIL)) & '"');
}
writeOutput('}');
abort; abort;
} }
// NOTE: Do NOT use 'var' at top-level in a .cfm (local scope is function-only). if (!structKeyExists(request, "BusinessID") || !isNumeric(request.BusinessID) || request.BusinessID LTE 0) {
dsn = ""; apiAbort({ OK=false, ERROR="no_business_selected" });
if (structKeyExists(request, "datasource") && len(trim(request.datasource))) {
dsn = trim(request.datasource);
} else if (structKeyExists(request, "dsn") && len(trim(request.dsn))) {
dsn = trim(request.dsn);
} else if (structKeyExists(application, "datasource") && len(trim(application.datasource))) {
dsn = trim(application.datasource);
} else if (structKeyExists(application, "dsn") && len(trim(application.dsn))) {
dsn = trim(application.dsn);
} }
if (!len(dsn)) {
apiAbort({
OK=false,
ERROR="missing_datasource",
DETAIL="No datasource found in request.datasource, request.dsn, application.datasource, or application.dsn."
});
}
q = queryExecute(
"
SELECT
BusinessID AS BusinessID,
BusinessName AS BusinessName
FROM Businesses
ORDER BY BusinessName
",
[],
{ datasource = dsn }
);
countVal = q.recordCount;
writeOutput('{');
writeOutput('"OK":true');
writeOutput(',"COUNT":' & countVal);
writeOutput(',"Businesses":[');
for (i = 1; i LTE q.recordCount; i = i + 1) {
if (i GT 1) writeOutput(',');
bid = q["BusinessID"][i];
bname = q["BusinessName"][i];
writeOutput('{');
writeOutput('"BusinessID":' & int(bid));
writeOutput(',"BusinessName":"' & encodeForJSON(toString(bname)) & '"');
writeOutput('}');
}
writeOutput(']}');
</cfscript> </cfscript>
<cfquery name="q" datasource="payfrit">
SELECT
lt.lt_Beacon_Businesses_ServicePointID,
lt.BeaconID,
lt.BusinessID,
lt.ServicePointID,
lt.lt_Beacon_Businesses_ServicePointNotes,
b.BeaconName,
b.BeaconUUID,
sp.ServicePointName
FROM lt_Beacon_Businesses_ServicePoints lt
JOIN Beacons b ON b.BeaconID = lt.BeaconID
LEFT JOIN ServicePoints sp ON sp.ServicePointID = lt.ServicePointID
WHERE lt.BusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#request.BusinessID#">
ORDER BY b.BeaconName, sp.ServicePointName
</cfquery>
<cfset assignments = []>
<cfloop query="q">
<cfset arrayAppend(assignments, {
"lt_Beacon_Businesses_ServicePointID" = q.lt_Beacon_Businesses_ServicePointID,
"BeaconID" = q.BeaconID,
"BusinessID" = q.BusinessID,
"ServicePointID" = q.ServicePointID,
"BeaconName" = q.BeaconName,
"BeaconUUID" = q.BeaconUUID,
"ServicePointName"= q.ServicePointName,
"lt_Beacon_Businesses_ServicePointNotes" = q.lt_Beacon_Businesses_ServicePointNotes
})>
</cfloop>
<cfoutput>#serializeJSON({ OK=true, ERROR="", COUNT=arrayLen(assignments), ASSIGNMENTS=assignments })#</cfoutput>

View file

@ -32,9 +32,6 @@ function normStr(v){
} }
/* ---------- AUTH CONTEXT ---------- */ /* ---------- AUTH CONTEXT ---------- */
if (!structKeyExists(request,"UserID") || !isNumeric(request.UserID) || request.UserID LTE 0){
apiAbort({OK=false,ERROR="not_logged_in"});
}
if (!structKeyExists(request,"BusinessID") || !isNumeric(request.BusinessID) || request.BusinessID LTE 0){ if (!structKeyExists(request,"BusinessID") || !isNumeric(request.BusinessID) || request.BusinessID LTE 0){
apiAbort({OK=false,ERROR="no_business_selected"}); apiAbort({OK=false,ERROR="no_business_selected"});
} }
@ -58,11 +55,11 @@ if (structKeyExists(data,"Notes")){
</cfscript> </cfscript>
<!--- Validate Beacon belongs to Business ---> <!--- Validate Beacon belongs to Business --->
<cfquery name="qB" datasource="#application.datasource#"> <cfquery name="qB" datasource="payfrit">
SELECT BeaconID SELECT BeaconID
FROM Beacons FROM Beacons
WHERE BeaconID = <cfqueryparam cfsqltype="cf_sql_integer" value="#BeaconID#"> WHERE BeaconID = <cfqueryparam cfsqltype="cf_sql_integer" value="#BeaconID#">
AND BusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#request.BusinessID#"> AND BeaconBusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#request.BusinessID#">
LIMIT 1 LIMIT 1
</cfquery> </cfquery>
<cfif qB.recordCount EQ 0> <cfif qB.recordCount EQ 0>
@ -71,11 +68,11 @@ if (structKeyExists(data,"Notes")){
</cfif> </cfif>
<!--- Validate ServicePoint belongs to Business ---> <!--- Validate ServicePoint belongs to Business --->
<cfquery name="qS" datasource="#application.datasource#"> <cfquery name="qS" datasource="payfrit">
SELECT ServicePointID SELECT ServicePointID
FROM ServicePoints FROM ServicePoints
WHERE ServicePointID = <cfqueryparam cfsqltype="cf_sql_integer" value="#ServicePointID#"> WHERE ServicePointID = <cfqueryparam cfsqltype="cf_sql_integer" value="#ServicePointID#">
AND BusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#request.BusinessID#"> AND ServicePointBusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#request.BusinessID#">
LIMIT 1 LIMIT 1
</cfquery> </cfquery>
<cfif qS.recordCount EQ 0> <cfif qS.recordCount EQ 0>
@ -84,7 +81,7 @@ if (structKeyExists(data,"Notes")){
</cfif> </cfif>
<!--- Enforce 1:1 uniqueness ---> <!--- Enforce 1:1 uniqueness --->
<cfquery name="qBeaconTaken" datasource="#application.datasource#"> <cfquery name="qBeaconTaken" datasource="payfrit">
SELECT lt_Beacon_Businesses_ServicePointID SELECT lt_Beacon_Businesses_ServicePointID
FROM lt_Beacon_Businesses_ServicePoints FROM lt_Beacon_Businesses_ServicePoints
WHERE BusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#request.BusinessID#"> WHERE BusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#request.BusinessID#">
@ -96,7 +93,7 @@ if (structKeyExists(data,"Notes")){
<cfabort> <cfabort>
</cfif> </cfif>
<cfquery name="qServicePointTaken" datasource="#application.datasource#"> <cfquery name="qServicePointTaken" datasource="payfrit">
SELECT lt_Beacon_Businesses_ServicePointID SELECT lt_Beacon_Businesses_ServicePointID
FROM lt_Beacon_Businesses_ServicePoints FROM lt_Beacon_Businesses_ServicePoints
WHERE BusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#request.BusinessID#"> WHERE BusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#request.BusinessID#">
@ -109,7 +106,7 @@ if (structKeyExists(data,"Notes")){
</cfif> </cfif>
<!--- INSERT ---> <!--- INSERT --->
<cfquery datasource="#application.datasource#"> <cfquery datasource="payfrit">
INSERT INTO lt_Beacon_Businesses_ServicePoints INSERT INTO lt_Beacon_Businesses_ServicePoints
(BusinessID, BeaconID, ServicePointID, (BusinessID, BeaconID, ServicePointID,
lt_Beacon_Businesses_ServicePointAssignedByUserID, lt_Beacon_Businesses_ServicePointAssignedByUserID,
@ -119,12 +116,12 @@ if (structKeyExists(data,"Notes")){
<cfqueryparam cfsqltype="cf_sql_integer" value="#request.BusinessID#">, <cfqueryparam cfsqltype="cf_sql_integer" value="#request.BusinessID#">,
<cfqueryparam cfsqltype="cf_sql_integer" value="#BeaconID#">, <cfqueryparam cfsqltype="cf_sql_integer" value="#BeaconID#">,
<cfqueryparam cfsqltype="cf_sql_integer" value="#ServicePointID#">, <cfqueryparam cfsqltype="cf_sql_integer" value="#ServicePointID#">,
<cfqueryparam cfsqltype="cf_sql_integer" value="#request.UserID#">, <cfqueryparam cfsqltype="cf_sql_integer" value="1">,
<cfqueryparam cfsqltype="cf_sql_varchar" value="#Notes#" null="#(len(Notes) EQ 0)#"> <cfqueryparam cfsqltype="cf_sql_varchar" value="#Notes#" null="#(len(Notes) EQ 0)#">
) )
</cfquery> </cfquery>
<cfquery name="qID" datasource="#application.datasource#"> <cfquery name="qID" datasource="payfrit">
SELECT LAST_INSERT_ID() AS NewID SELECT LAST_INSERT_ID() AS NewID
</cfquery> </cfquery>

View file

@ -75,16 +75,25 @@ try {
userId: { value: qUser.UserID, cfsqltype: "cf_sql_integer" } userId: { value: qUser.UserID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" }); }, { datasource: "payfrit" });
// Send OTP via Twilio // Send OTP via Twilio (if available)
smsResult = application.twilioObj.sendSMS( smsMessage = "Code saved (SMS skipped in dev)";
recipientNumber: "+1" & phone, if (structKeyExists(application, "twilioObj")) {
messageBody: "Your Payfrit login code is: " & otp try {
); smsResult = application.twilioObj.sendSMS(
recipientNumber: "+1" & phone,
messageBody: "Your Payfrit login code is: " & otp
);
smsMessage = smsResult.success ? "Login code sent" : "SMS failed - please try again";
} catch (any smsErr) {
smsMessage = "SMS error: " & smsErr.message;
}
}
writeOutput(serializeJSON({ writeOutput(serializeJSON({
"OK": true, "OK": true,
"UUID": qUser.UserUUID, "UUID": qUser.UserUUID,
"MESSAGE": smsResult.success ? "Login code sent" : "SMS failed - please try again" "MESSAGE": smsMessage,
"DEV_OTP": otp
})); }));
} catch (any e) { } catch (any e) {

View file

@ -23,11 +23,20 @@ function readJsonBody() {
} }
data = readJsonBody(); data = readJsonBody();
httpHeaders = getHttpRequestData().headers;
if (!structKeyExists(request, "UserID") || !isNumeric(request.UserID) || request.UserID LTE 0) { // Get BusinessID from: session > body > X-Business-ID header
apiAbort({ OK=false, ERROR="not_logged_in" }); bizId = 0;
if (structKeyExists(request, "BusinessID") && isNumeric(request.BusinessID) && request.BusinessID GT 0) {
bizId = int(request.BusinessID);
} }
if (!structKeyExists(request, "BusinessID") || !isNumeric(request.BusinessID) || request.BusinessID LTE 0) { if (bizId LTE 0 && structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID) && data.BusinessID GT 0) {
bizId = int(data.BusinessID);
}
if (bizId LTE 0 && structKeyExists(httpHeaders, "X-Business-ID") && isNumeric(httpHeaders["X-Business-ID"]) && httpHeaders["X-Business-ID"] GT 0) {
bizId = int(httpHeaders["X-Business-ID"]);
}
if (bizId LTE 0) {
apiAbort({ OK=false, ERROR="no_business_selected" }); apiAbort({ OK=false, ERROR="no_business_selected" });
} }
@ -38,19 +47,19 @@ if (!structKeyExists(data, "BeaconID") || !isNumeric(data.BeaconID) || int(data.
beaconId = int(data.BeaconID); beaconId = int(data.BeaconID);
</cfscript> </cfscript>
<cfquery datasource="#application.datasource#"> <cfquery datasource="payfrit">
UPDATE Beacons UPDATE Beacons
SET IsActive = 0 SET BeaconIsActive = 0
WHERE BeaconID = <cfqueryparam cfsqltype="cf_sql_integer" value="#beaconId#"> WHERE BeaconID = <cfqueryparam cfsqltype="cf_sql_integer" value="#beaconId#">
AND BusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#request.BusinessID#"> AND BeaconBusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#bizId#">
</cfquery> </cfquery>
<!--- confirm ---> <!--- confirm --->
<cfquery name="qCheck" datasource="#application.datasource#"> <cfquery name="qCheck" datasource="payfrit">
SELECT BeaconID, IsActive SELECT BeaconID, BeaconIsActive
FROM Beacons FROM Beacons
WHERE BeaconID = <cfqueryparam cfsqltype="cf_sql_integer" value="#beaconId#"> WHERE BeaconID = <cfqueryparam cfsqltype="cf_sql_integer" value="#beaconId#">
AND BusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#request.BusinessID#"> AND BeaconBusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#bizId#">
LIMIT 1 LIMIT 1
</cfquery> </cfquery>

View file

@ -24,9 +24,6 @@ function readJsonBody() {
data = readJsonBody(); data = readJsonBody();
if (!structKeyExists(request, "UserID") || !isNumeric(request.UserID) || request.UserID LTE 0) {
apiAbort({ OK=false, ERROR="not_logged_in" });
}
if (!structKeyExists(request, "BusinessID") || !isNumeric(request.BusinessID) || request.BusinessID LTE 0) { if (!structKeyExists(request, "BusinessID") || !isNumeric(request.BusinessID) || request.BusinessID LTE 0) {
apiAbort({ OK=false, ERROR="no_business_selected" }); apiAbort({ OK=false, ERROR="no_business_selected" });
} }
@ -38,20 +35,16 @@ if (!structKeyExists(data, "BeaconID") || !isNumeric(data.BeaconID) || int(data.
beaconId = int(data.BeaconID); beaconId = int(data.BeaconID);
</cfscript> </cfscript>
<cfquery name="q" datasource="#application.datasource#"> <cfquery name="q" datasource="payfrit">
SELECT SELECT
BeaconID, BeaconID,
BusinessID, BeaconBusinessID,
BeaconName, BeaconName,
UUID, BeaconUUID,
NamespaceId, BeaconIsActive
InstanceId,
IsActive,
CreatedAt,
UpdatedAt
FROM Beacons FROM Beacons
WHERE BeaconID = <cfqueryparam cfsqltype="cf_sql_integer" value="#beaconId#"> WHERE BeaconID = <cfqueryparam cfsqltype="cf_sql_integer" value="#beaconId#">
AND BusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#request.BusinessID#"> AND BeaconBusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#request.BusinessID#">
LIMIT 1 LIMIT 1
</cfquery> </cfquery>
@ -62,14 +55,10 @@ beaconId = int(data.BeaconID);
<cfset beacon = { <cfset beacon = {
"BeaconID" = q.BeaconID, "BeaconID" = q.BeaconID,
"BusinessID" = q.BusinessID, "BusinessID" = q.BeaconBusinessID,
"BeaconName" = q.BeaconName, "BeaconName" = q.BeaconName,
"UUID" = q.UUID, "UUID" = q.BeaconUUID,
"NamespaceId" = q.NamespaceId, "IsActive" = q.BeaconIsActive
"InstanceId" = q.InstanceId,
"IsActive" = q.IsActive,
"CreatedAt" = q.CreatedAt,
"UpdatedAt" = q.UpdatedAt
}> }>
<cfoutput>#serializeJSON({ OK=true, ERROR="", BEACON=beacon })#</cfoutput> <cfoutput>#serializeJSON({ OK=true, ERROR="", BEACON=beacon })#</cfoutput>

View file

@ -10,27 +10,35 @@ function apiAbort(obj) {
abort; abort;
} }
function readJsonBody() { // Read JSON body once
data = {};
try {
raw = toString(getHttpRequestData().content); raw = toString(getHttpRequestData().content);
if (isNull(raw) || len(trim(raw)) EQ 0) return {}; if (len(trim(raw))) {
try { data = deserializeJSON(raw);
parsed = deserializeJSON(raw); if (!isStruct(data)) data = {};
} catch(any e) {
apiAbort({ OK=false, ERROR="bad_json", MESSAGE="Invalid JSON body" });
} }
// We only accept object bodies } catch (any e) {
if (!isStruct(parsed)) return {}; data = {};
return parsed;
} }
data = readJsonBody(); httpHeaders = getHttpRequestData().headers;
// Auth/business gating should already happen in /api/Application.cfm, // Get BusinessID from: session > body > X-Business-ID header > URL
// but keep this defensive so the file is safe standalone. bizId = 0;
if (!structKeyExists(request, "UserID") || !isNumeric(request.UserID) || request.UserID LTE 0) { if (structKeyExists(request, "BusinessID") && isNumeric(request.BusinessID) && request.BusinessID GT 0) {
apiAbort({ OK=false, ERROR="not_logged_in" }); bizId = int(request.BusinessID);
} }
if (!structKeyExists(request, "BusinessID") || !isNumeric(request.BusinessID) || request.BusinessID LTE 0) { if (bizId LTE 0 && structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID) && data.BusinessID GT 0) {
bizId = int(data.BusinessID);
}
if (bizId LTE 0 && structKeyExists(httpHeaders, "X-Business-ID") && isNumeric(httpHeaders["X-Business-ID"]) && httpHeaders["X-Business-ID"] GT 0) {
bizId = int(httpHeaders["X-Business-ID"]);
}
if (bizId LTE 0 && structKeyExists(url, "BusinessID") && isNumeric(url.BusinessID) && url.BusinessID GT 0) {
bizId = int(url.BusinessID);
}
if (bizId LTE 0) {
apiAbort({ OK=false, ERROR="no_business_selected" }); apiAbort({ OK=false, ERROR="no_business_selected" });
} }
@ -47,21 +55,17 @@ if (structKeyExists(data, "onlyActive")) {
} }
</cfscript> </cfscript>
<cfquery name="q" datasource="#application.datasource#"> <cfquery name="q" datasource="payfrit">
SELECT SELECT
BeaconID, BeaconID,
BusinessID, BeaconBusinessID,
BeaconName, BeaconName,
UUID, BeaconUUID,
NamespaceId, BeaconIsActive
InstanceId,
IsActive,
CreatedAt,
UpdatedAt
FROM Beacons FROM Beacons
WHERE BusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#request.BusinessID#"> WHERE BeaconBusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#bizId#">
<cfif onlyActive> <cfif onlyActive>
AND IsActive = 1 AND BeaconIsActive = 1
</cfif> </cfif>
ORDER BY BeaconName, BeaconID ORDER BY BeaconName, BeaconID
</cfquery> </cfquery>
@ -70,15 +74,11 @@ if (structKeyExists(data, "onlyActive")) {
<cfloop query="q"> <cfloop query="q">
<cfset arrayAppend(beacons, { <cfset arrayAppend(beacons, {
"BeaconID" = q.BeaconID, "BeaconID" = q.BeaconID,
"BusinessID" = q.BusinessID, "BusinessID" = q.BeaconBusinessID,
"BeaconName" = q.BeaconName, "BeaconName" = q.BeaconName,
"UUID" = q.UUID, "UUID" = q.BeaconUUID,
"NamespaceId" = q.NamespaceId, "IsActive" = q.BeaconIsActive
"InstanceId" = q.InstanceId,
"IsActive" = q.IsActive,
"CreatedAt" = q.CreatedAt,
"UpdatedAt" = q.UpdatedAt
})> })>
</cfloop> </cfloop>
<cfoutput>#serializeJSON({ OK=true, ERROR="", BusinessID=request.BusinessID, COUNT=arrayLen(beacons), BEACONS=beacons })#</cfoutput> <cfoutput>#serializeJSON({ OK=true, ERROR="", BusinessID=bizId, COUNT=arrayLen(beacons), BEACONS=beacons })#</cfoutput>

View file

@ -4,6 +4,7 @@
<cfcontent type="application/json; charset=utf-8" reset="true"> <cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store"> <cfheader name="Cache-Control" value="no-store">
<cftry>
<cfscript> <cfscript>
function apiAbort(obj) { function apiAbort(obj) {
writeOutput(serializeJSON(obj)); writeOutput(serializeJSON(obj));
@ -29,13 +30,20 @@ function normStr(v) {
data = readJsonBody(); data = readJsonBody();
if (!structKeyExists(request, "UserID") || !isNumeric(request.UserID) || request.UserID LTE 0) {
apiAbort({ OK=false, ERROR="not_logged_in" });
}
if (!structKeyExists(request, "BusinessID") || !isNumeric(request.BusinessID) || request.BusinessID LTE 0) { if (!structKeyExists(request, "BusinessID") || !isNumeric(request.BusinessID) || request.BusinessID LTE 0) {
apiAbort({ OK=false, ERROR="no_business_selected" }); apiAbort({ OK=false, ERROR="no_business_selected" });
} }
// Verify the business exists
qBiz = queryExecute(
"SELECT BusinessID FROM Businesses WHERE BusinessID = ? LIMIT 1",
[ { value=request.BusinessID, cfsqltype="cf_sql_integer" } ],
{ datasource="payfrit" }
);
if (qBiz.recordCount EQ 0) {
apiAbort({ OK=false, ERROR="invalid_business", MESSAGE="Business ID #request.BusinessID# does not exist. Please log out and log back in." });
}
if (!structKeyExists(data, "BeaconName") || len(normStr(data.BeaconName)) EQ 0) { if (!structKeyExists(data, "BeaconName") || len(normStr(data.BeaconName)) EQ 0) {
apiAbort({ OK=false, ERROR="missing_beacon_name", MESSAGE="BeaconName is required" }); apiAbort({ OK=false, ERROR="missing_beacon_name", MESSAGE="BeaconName is required" });
} }
@ -47,8 +55,6 @@ if (structKeyExists(data, "BeaconID") && isNumeric(data.BeaconID) && int(data.Be
beaconName = normStr(data.BeaconName); beaconName = normStr(data.BeaconName);
uuid = structKeyExists(data, "UUID") ? normStr(data.UUID) : ""; uuid = structKeyExists(data, "UUID") ? normStr(data.UUID) : "";
namespaceId = structKeyExists(data, "NamespaceId") ? normStr(data.NamespaceId) : "";
instanceId = structKeyExists(data, "InstanceId") ? normStr(data.InstanceId) : "";
isActive = 1; isActive = 1;
if (structKeyExists(data, "IsActive")) { if (structKeyExists(data, "IsActive")) {
@ -60,86 +66,79 @@ if (structKeyExists(data, "IsActive")) {
<cfif beaconId GT 0> <cfif beaconId GT 0>
<!--- Update, scoped to this business ---> <!--- Update, scoped to this business --->
<cfquery datasource="#application.datasource#"> <cfquery datasource="payfrit">
UPDATE Beacons UPDATE Beacons
SET SET
BeaconName = <cfqueryparam cfsqltype="cf_sql_varchar" value="#beaconName#">, BeaconName = <cfqueryparam cfsqltype="cf_sql_varchar" value="#beaconName#">,
UUID = <cfqueryparam cfsqltype="cf_sql_varchar" value="#uuid#" null="#(len(uuid) EQ 0)#">, BeaconUUID = <cfqueryparam cfsqltype="cf_sql_varchar" value="#uuid#" null="#(len(uuid) EQ 0)#">,
NamespaceId = <cfqueryparam cfsqltype="cf_sql_varchar" value="#namespaceId#" null="#(len(namespaceId) EQ 0)#">, BeaconIsActive = <cfqueryparam cfsqltype="cf_sql_tinyint" value="#isActive#">
InstanceId = <cfqueryparam cfsqltype="cf_sql_varchar" value="#instanceId#" null="#(len(instanceId) EQ 0)#">,
IsActive = <cfqueryparam cfsqltype="cf_sql_tinyint" value="#isActive#">
WHERE BeaconID = <cfqueryparam cfsqltype="cf_sql_integer" value="#beaconId#"> WHERE BeaconID = <cfqueryparam cfsqltype="cf_sql_integer" value="#beaconId#">
AND BusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#request.BusinessID#"> AND BeaconBusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#request.BusinessID#">
</cfquery> </cfquery>
<!--- confirm it exists/belongs to business ---> <!--- confirm it exists/belongs to business --->
<cfquery name="qCheck" datasource="#application.datasource#"> <cfquery name="qCheck" datasource="payfrit">
SELECT BeaconID SELECT BeaconID
FROM Beacons FROM Beacons
WHERE BeaconID = <cfqueryparam cfsqltype="cf_sql_integer" value="#beaconId#"> WHERE BeaconID = <cfqueryparam cfsqltype="cf_sql_integer" value="#beaconId#">
AND BusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#request.BusinessID#"> AND BeaconBusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#request.BusinessID#">
LIMIT 1 LIMIT 1
</cfquery> </cfquery>
<cfif qCheck.recordCount EQ 0> <cfif qCheck.recordCount EQ 0>
<cfoutput>#serializeJSON({ OK=false, ERROR="not_found" })#</cfoutput> <cfoutput>#serializeJSON({ OK=false, ERROR="not_found", MESSAGE="Beacon not found or doesn't belong to this business" })#</cfoutput>
<cfabort> <cfabort>
</cfif> </cfif>
<cfelse> <cfelse>
<!--- Insert ---> <!--- Insert --->
<cfquery datasource="#application.datasource#"> <cfquery datasource="payfrit">
INSERT INTO Beacons ( INSERT INTO Beacons (
BusinessID, BeaconBusinessID,
BeaconName, BeaconName,
UUID, BeaconUUID,
NamespaceId, BeaconIsActive
InstanceId,
IsActive
) VALUES ( ) VALUES (
<cfqueryparam cfsqltype="cf_sql_integer" value="#request.BusinessID#">, <cfqueryparam cfsqltype="cf_sql_integer" value="#request.BusinessID#">,
<cfqueryparam cfsqltype="cf_sql_varchar" value="#beaconName#">, <cfqueryparam cfsqltype="cf_sql_varchar" value="#beaconName#">,
<cfqueryparam cfsqltype="cf_sql_varchar" value="#uuid#" null="#(len(uuid) EQ 0)#">, <cfqueryparam cfsqltype="cf_sql_varchar" value="#uuid#" null="#(len(uuid) EQ 0)#">,
<cfqueryparam cfsqltype="cf_sql_varchar" value="#namespaceId#" null="#(len(namespaceId) EQ 0)#">,
<cfqueryparam cfsqltype="cf_sql_varchar" value="#instanceId#" null="#(len(instanceId) EQ 0)#">,
<cfqueryparam cfsqltype="cf_sql_tinyint" value="#isActive#"> <cfqueryparam cfsqltype="cf_sql_tinyint" value="#isActive#">
) )
</cfquery> </cfquery>
<cfquery name="qId" datasource="#application.datasource#"> <cfquery name="qId" datasource="payfrit">
SELECT LAST_INSERT_ID() AS BeaconID SELECT LAST_INSERT_ID() AS BeaconID
</cfquery> </cfquery>
<cfset beaconId = qId.BeaconID> <cfset beaconId = qId.BeaconID>
</cfif> </cfif>
<!--- Return saved row ---> <!--- Return saved row --->
<cfquery name="qOut" datasource="#application.datasource#"> <cfquery name="qOut" datasource="payfrit">
SELECT SELECT
BeaconID, BeaconID,
BusinessID, BeaconBusinessID,
BeaconName, BeaconName,
UUID, BeaconUUID,
NamespaceId, BeaconIsActive
InstanceId,
IsActive,
CreatedAt,
UpdatedAt
FROM Beacons FROM Beacons
WHERE BeaconID = <cfqueryparam cfsqltype="cf_sql_integer" value="#beaconId#"> WHERE BeaconID = <cfqueryparam cfsqltype="cf_sql_integer" value="#beaconId#">
AND BusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#request.BusinessID#"> AND BeaconBusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#request.BusinessID#">
LIMIT 1 LIMIT 1
</cfquery> </cfquery>
<cfset beacon = { <cfset beacon = {
"BeaconID" = qOut.BeaconID, "BeaconID" = qOut.BeaconID,
"BusinessID" = qOut.BusinessID, "BusinessID" = qOut.BeaconBusinessID,
"BeaconName" = qOut.BeaconName, "BeaconName" = qOut.BeaconName,
"UUID" = qOut.UUID, "UUID" = qOut.BeaconUUID,
"NamespaceId" = qOut.NamespaceId, "IsActive" = qOut.BeaconIsActive
"InstanceId" = qOut.InstanceId,
"IsActive" = qOut.IsActive,
"CreatedAt" = qOut.CreatedAt,
"UpdatedAt" = qOut.UpdatedAt
}> }>
<cfoutput>#serializeJSON({ OK=true, ERROR="", BEACON=beacon })#</cfoutput> <cfoutput>#serializeJSON({ OK=true, ERROR="", BEACON=beacon })#</cfoutput>
<cfcatch type="any">
<cfheader statuscode="200" statustext="OK">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfoutput>#serializeJSON({ OK=false, ERROR="server_error", MESSAGE=cfcatch.message, DETAIL=cfcatch.detail })#</cfoutput>
</cfcatch>
</cftry>

View file

@ -40,7 +40,8 @@ try {
BusinessPhone, BusinessPhone,
BusinessStripeAccountID, BusinessStripeAccountID,
BusinessStripeOnboardingComplete, BusinessStripeOnboardingComplete,
BusinessIsHiring BusinessIsHiring,
BusinessHeaderImageExtension
FROM Businesses FROM Businesses
WHERE BusinessID = :businessID WHERE BusinessID = :businessID
", { businessID: businessID }, { datasource: "payfrit" }); ", { businessID: businessID }, { datasource: "payfrit" });
@ -51,17 +52,27 @@ try {
abort; abort;
} }
// Get address from Addresses table // Get address from Addresses table (either linked via AddressBusinessID or via Businesses.BusinessAddressID)
qAddr = queryExecute(" qAddr = queryExecute("
SELECT a.AddressLine1, a.AddressLine2, a.AddressCity, a.AddressZIPCode, s.tt_StateAbbreviation SELECT a.AddressLine1, a.AddressLine2, a.AddressCity, a.AddressZIPCode, s.tt_StateAbbreviation
FROM Addresses a FROM Addresses a
LEFT JOIN tt_States s ON s.tt_StateID = a.AddressStateID LEFT JOIN tt_States s ON s.tt_StateID = a.AddressStateID
WHERE a.AddressBusinessID = :businessID AND a.AddressUserID = 0 AND a.AddressIsDeleted = 0 WHERE (a.AddressBusinessID = :businessID OR a.AddressID = (SELECT BusinessAddressID FROM Businesses WHERE BusinessID = :businessID))
AND a.AddressIsDeleted = 0
LIMIT 1 LIMIT 1
", { businessID: businessID }, { datasource: "payfrit" }); ", { businessID: businessID }, { datasource: "payfrit" });
addressStr = ""; addressStr = "";
addressLine1 = "";
addressCity = "";
addressState = "";
addressZip = "";
if (qAddr.recordCount > 0) { if (qAddr.recordCount > 0) {
addressLine1 = qAddr.AddressLine1;
addressCity = qAddr.AddressCity;
addressState = qAddr.tt_StateAbbreviation;
addressZip = qAddr.AddressZIPCode;
addressParts = []; addressParts = [];
if (len(qAddr.AddressLine1)) arrayAppend(addressParts, qAddr.AddressLine1); if (len(qAddr.AddressLine1)) arrayAppend(addressParts, qAddr.AddressLine1);
if (len(qAddr.AddressLine2)) arrayAppend(addressParts, qAddr.AddressLine2); if (len(qAddr.AddressLine2)) arrayAppend(addressParts, qAddr.AddressLine2);
@ -116,6 +127,10 @@ try {
"BusinessID": q.BusinessID, "BusinessID": q.BusinessID,
"BusinessName": q.BusinessName, "BusinessName": q.BusinessName,
"BusinessAddress": addressStr, "BusinessAddress": addressStr,
"AddressLine1": addressLine1,
"AddressCity": addressCity,
"AddressState": addressState,
"AddressZip": addressZip,
"BusinessPhone": q.BusinessPhone, "BusinessPhone": q.BusinessPhone,
"BusinessHours": hoursStr, "BusinessHours": hoursStr,
"BusinessHoursDetail": hoursArr, "BusinessHoursDetail": hoursArr,
@ -123,6 +138,11 @@ try {
"IsHiring": q.BusinessIsHiring == 1 "IsHiring": q.BusinessIsHiring == 1
}; };
// Add header image URL if extension exists
if (len(q.BusinessHeaderImageExtension)) {
business["HeaderImageURL"] = "https://biz.payfrit.com/uploads/headers/" & q.BusinessID & "." & q.BusinessHeaderImageExtension;
}
response["OK"] = true; response["OK"] = true;
response["BUSINESS"] = business; response["BUSINESS"] = business;

View file

@ -0,0 +1,72 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cftry>
<cfscript>
/**
* Save Business Brand Color
* POST JSON: { "BusinessID": 37, "BrandColor": "#1B4D3E" }
*/
function apiAbort(payload) {
writeOutput(serializeJSON(payload));
abort;
}
requestBody = toString(getHttpRequestData().content);
if (!len(requestBody)) {
apiAbort({ "OK": false, "ERROR": "no_body", "MESSAGE": "No request body provided" });
}
data = deserializeJSON(requestBody);
// Get BusinessID from body or request scope
bizId = 0;
if (structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID) && data.BusinessID GT 0) {
bizId = int(data.BusinessID);
} else if (structKeyExists(request, "BusinessID") && isNumeric(request.BusinessID) && request.BusinessID GT 0) {
bizId = int(request.BusinessID);
}
if (bizId LTE 0) {
apiAbort({ "OK": false, "ERROR": "missing_businessid", "MESSAGE": "BusinessID is required" });
}
// Validate color format
brandColor = structKeyExists(data, "BrandColor") && isSimpleValue(data.BrandColor) ? trim(data.BrandColor) : "";
// Allow empty to clear, or validate hex format
if (len(brandColor) GT 0) {
// Must be #RRGGBB format
if (!reFind("^##[0-9A-Fa-f]{6}$", brandColor)) {
apiAbort({ "OK": false, "ERROR": "invalid_color", "MESSAGE": "Color must be in #RRGGBB format" });
}
brandColor = uCase(brandColor);
}
// Update the database
queryExecute("
UPDATE Businesses
SET BusinessBrandColor = :color
WHERE BusinessID = :bizId
", {
color: { value: len(brandColor) ? brandColor : "", null: !len(brandColor), cfsqltype: "cf_sql_varchar" },
bizId: { value: bizId, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
writeOutput(serializeJSON({
"OK": true,
"ERROR": "",
"MESSAGE": "Brand color saved",
"BRANDCOLOR": brandColor
}));
</cfscript>
<cfcatch type="any">
<cfheader statuscode="200" statustext="OK">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfoutput>#serializeJSON({ "OK": false, "ERROR": "server_error", "MESSAGE": cfcatch.message, "DETAIL": cfcatch.detail })#</cfoutput>
</cfcatch>
</cftry>

115
api/businesses/update.cfm Normal file
View file

@ -0,0 +1,115 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
/**
* Update Business Info
*
* POST JSON:
* {
* "BusinessID": 37,
* "BusinessName": "My Business",
* "BusinessPhone": "(555) 123-4567",
* "AddressLine1": "123 Main St",
* "City": "Los Angeles",
* "State": "CA",
* "Zip": "90001"
* }
*/
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");
}
// Update business name and phone
bizName = structKeyExists(data, "BusinessName") && isSimpleValue(data.BusinessName) ? trim(data.BusinessName) : "";
bizPhone = structKeyExists(data, "BusinessPhone") && isSimpleValue(data.BusinessPhone) ? trim(data.BusinessPhone) : "";
if (len(bizName)) {
queryExecute("
UPDATE Businesses SET BusinessName = :name, BusinessPhone = :phone
WHERE BusinessID = :id
", {
name: bizName,
phone: bizPhone,
id: businessId
}, { datasource: "payfrit" });
}
// Update or create address
addressLine1 = structKeyExists(data, "AddressLine1") && isSimpleValue(data.AddressLine1) ? trim(data.AddressLine1) : "";
city = structKeyExists(data, "City") && isSimpleValue(data.City) ? trim(data.City) : "";
state = structKeyExists(data, "State") && isSimpleValue(data.State) ? trim(data.State) : "";
zip = structKeyExists(data, "Zip") && isSimpleValue(data.Zip) ? trim(data.Zip) : "";
// Clean up city - remove trailing punctuation
city = reReplace(city, "[,.\s]+$", "", "all");
// Get state ID
stateID = 0;
if (len(state)) {
qState = queryExecute("
SELECT tt_StateID FROM tt_States WHERE tt_StateAbbreviation = :abbr
", { abbr: uCase(state) }, { datasource: "payfrit" });
if (qState.recordCount > 0) {
stateID = qState.tt_StateID;
}
}
// Check if business has an address
qAddr = queryExecute("
SELECT AddressID FROM Addresses
WHERE AddressBusinessID = :bizID AND AddressUserID = 0 AND AddressIsDeleted = 0
LIMIT 1
", { bizID: businessId }, { datasource: "payfrit" });
if (qAddr.recordCount > 0) {
// Update existing address
queryExecute("
UPDATE Addresses SET
AddressLine1 = :line1,
AddressCity = :city,
AddressStateID = :stateID,
AddressZIPCode = :zip
WHERE AddressID = :addrID
", {
line1: addressLine1,
city: city,
stateID: stateID,
zip: zip,
addrID: qAddr.AddressID
}, { datasource: "payfrit" });
} else {
// Create new address
queryExecute("
INSERT INTO Addresses (AddressLine1, AddressCity, AddressStateID, AddressZIPCode, AddressBusinessID, AddressUserID, AddressTypeID, AddressAddedOn)
VALUES (:line1, :city, :stateID, :zip, :bizID, 0, 2, NOW())
", {
line1: addressLine1,
city: city,
stateID: stateID,
zip: zip,
bizID: businessId
}, { datasource: "payfrit" });
}
response.OK = true;
} catch (any e) {
response.ERROR = e.message;
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -0,0 +1,77 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
/**
* Update Business Hours
*
* POST JSON:
* {
* "BusinessID": 37,
* "Hours": [
* { "dayId": 1, "open": "09:00", "close": "17:00" },
* { "dayId": 2, "open": "09:00", "close": "17:00" },
* ...
* ]
* }
*
* Days not in the Hours array are considered closed.
*/
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");
}
hours = structKeyExists(data, "Hours") && isArray(data.Hours) ? data.Hours : [];
// Delete all existing hours for this business
queryExecute("
DELETE FROM Hours WHERE HoursBusinessID = :bizID
", { bizID: businessId }, { datasource: "payfrit" });
// Insert new hours
for (h in hours) {
if (!isStruct(h)) continue;
dayId = structKeyExists(h, "dayId") ? val(h.dayId) : 0;
openTime = structKeyExists(h, "open") && isSimpleValue(h.open) ? h.open : "09:00";
closeTime = structKeyExists(h, "close") && isSimpleValue(h.close) ? h.close : "17:00";
if (dayId >= 1 && dayId <= 7) {
// Convert HH:MM to HH:MM:SS if needed
if (len(openTime) == 5) openTime = openTime & ":00";
if (len(closeTime) == 5) closeTime = closeTime & ":00";
queryExecute("
INSERT INTO Hours (HoursBusinessID, HoursDayID, HoursOpenTime, HoursClosingTime)
VALUES (:bizID, :dayID, :openTime, :closeTime)
", {
bizID: businessId,
dayID: dayId,
openTime: openTime,
closeTime: closeTime
}, { datasource: "payfrit" });
}
}
response.OK = true;
response.hoursUpdated = arrayLen(hours);
} catch (any e) {
response.ERROR = e.message;
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -0,0 +1,70 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
response = { "OK": false };
try {
requestBody = toString(getHttpRequestData().content);
requestData = {};
if (len(requestBody)) {
requestData = deserializeJSON(requestBody);
}
businessID = val(requestData.BusinessID ?: 0);
confirmDelete = requestData.confirm ?: "";
if (businessID == 0) {
throw("BusinessID is required");
}
if (confirmDelete != "DELETE_ALL_DATA") {
throw("Must pass confirm: 'DELETE_ALL_DATA' to proceed");
}
// Get counts before deletion
qItemCount = queryExecute("SELECT COUNT(*) as cnt FROM Items WHERE ItemBusinessID = :bid", { bid: businessID }, { datasource: "payfrit" });
qCatCount = queryExecute("SELECT COUNT(*) as cnt FROM Categories WHERE CategoryBusinessID = :bid", { bid: businessID }, { datasource: "payfrit" });
// Get item IDs for this business to delete template links
qItemIds = queryExecute("SELECT ItemID FROM Items WHERE ItemBusinessID = :bid", { bid: businessID }, { datasource: "payfrit" });
itemIds = [];
for (row in qItemIds) {
arrayAppend(itemIds, row.ItemID);
}
deletedLinks = 0;
if (arrayLen(itemIds) > 0) {
// Delete template links for these items
queryExecute("DELETE FROM ItemTemplateLinks WHERE ItemID IN (:ids) OR TemplateItemID IN (:ids)",
{ ids: { value: arrayToList(itemIds), cfsqltype: "cf_sql_varchar", list: true } },
{ datasource: "payfrit" });
deletedLinks = arrayLen(itemIds);
}
// Delete all items for this business
queryExecute("DELETE FROM Items WHERE ItemBusinessID = :bid", { bid: businessID }, { datasource: "payfrit" });
// Delete all categories for this business
queryExecute("DELETE FROM Categories WHERE CategoryBusinessID = :bid", { bid: businessID }, { datasource: "payfrit" });
response = {
"OK": true,
"deleted": {
"items": qItemCount.cnt,
"categories": qCatCount.cnt,
"templateLinks": deletedLinks
},
"businessID": businessID
};
} catch (any e) {
response = {
"OK": false,
"ERROR": e.message
};
}
writeOutput(serializeJSON(response));
</cfscript>

74
api/menu/debug.cfm Normal file
View file

@ -0,0 +1,74 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
response = {};
try {
// Get all businesses with items
qBusinesses = queryExecute("
SELECT DISTINCT i.ItemBusinessID, COUNT(*) as ItemCount
FROM Items i
WHERE i.ItemBusinessID > 0
GROUP BY i.ItemBusinessID
", {}, { datasource: "payfrit" });
response["businesses_with_items"] = [];
for (b in qBusinesses) {
arrayAppend(response["businesses_with_items"], {
"businessID": b.ItemBusinessID,
"itemCount": b.ItemCount
});
}
// Get categories
qCategories = queryExecute("
SELECT CategoryBusinessID, COUNT(*) as cnt
FROM Categories
GROUP BY CategoryBusinessID
", {}, { datasource: "payfrit" });
response["categories_by_business"] = [];
for (c in qCategories) {
arrayAppend(response["categories_by_business"], {
"businessID": c.CategoryBusinessID,
"count": c.cnt
});
}
// Get sample items
qItems = queryExecute("
SELECT ItemID, ItemBusinessID, ItemCategoryID, ItemParentItemID, ItemName, ItemIsActive
FROM Items
WHERE ItemIsActive = 1
LIMIT 20
", {}, { datasource: "payfrit" });
response["sample_items"] = [];
for (i in qItems) {
arrayAppend(response["sample_items"], {
"id": i.ItemID,
"businessID": i.ItemBusinessID,
"categoryID": i.ItemCategoryID,
"parentID": i.ItemParentItemID,
"name": i.ItemName,
"active": i.ItemIsActive
});
}
// Get template links
qLinks = queryExecute("
SELECT COUNT(*) as cnt FROM ItemTemplateLinks
", {}, { datasource: "payfrit" });
response["template_link_count"] = qLinks.cnt;
response["OK"] = true;
} catch (any e) {
response["ERROR"] = e.message;
response["DETAIL"] = e.detail ?: "";
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -345,9 +345,23 @@ try {
arrayAppend(templateLibrary, templatesById[templateID]); arrayAppend(templateLibrary, templatesById[templateID]);
} }
// Get business brand color
brandColor = "";
try {
qBrand = queryExecute("
SELECT BusinessBrandColor FROM Businesses WHERE BusinessID = :bizId
", { bizId: businessID }, { datasource: "payfrit" });
if (qBrand.recordCount > 0 && len(trim(qBrand.BusinessBrandColor))) {
brandColor = qBrand.BusinessBrandColor;
}
} catch (any e) {
// Column may not exist yet, ignore
}
response["OK"] = true; response["OK"] = true;
response["MENU"] = { "categories": categories }; response["MENU"] = { "categories": categories };
response["TEMPLATES"] = templateLibrary; response["TEMPLATES"] = templateLibrary;
response["BRANDCOLOR"] = brandColor;
response["CATEGORY_COUNT"] = arrayLen(categories); response["CATEGORY_COUNT"] = arrayLen(categories);
response["TEMPLATE_COUNT"] = arrayLen(templateLibrary); response["TEMPLATE_COUNT"] = arrayLen(templateLibrary);
response["SCHEMA"] = hasCategoriesData ? "legacy" : "unified"; response["SCHEMA"] = hasCategoriesData ? "legacy" : "unified";

View file

@ -412,12 +412,29 @@
</cfloop> </cfloop>
</cfif> </cfif>
<!--- Get brand color for this business --->
<cfset brandColor = "">
<cftry>
<cfset qBrand = queryExecute(
"SELECT BusinessBrandColor FROM Businesses WHERE BusinessID = ?",
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<cfif qBrand.recordCount GT 0 AND len(trim(qBrand.BusinessBrandColor))>
<cfset brandColor = qBrand.BusinessBrandColor>
</cfif>
<cfcatch>
<!--- Column may not exist yet, ignore --->
</cfcatch>
</cftry>
<cfset apiAbort({ <cfset apiAbort({
"OK": true, "OK": true,
"ERROR": "", "ERROR": "",
"Items": rows, "Items": rows,
"COUNT": arrayLen(rows), "COUNT": arrayLen(rows),
"SCHEMA": newSchemaActive ? "unified" : "legacy" "SCHEMA": newSchemaActive ? "unified" : "legacy",
"BRANDCOLOR": brandColor
})> })>
<cfcatch> <cfcatch>

View file

@ -1,17 +1,15 @@
<cfscript> <cfscript>
// Save menu data from the builder UI // Save menu data from the builder UI (OPTIMIZED)
// Input: BusinessID, Menu (JSON structure) // Input: BusinessID, Menu (JSON structure)
// Output: { OK: true } // Output: { OK: true }
//
// Supports both old schema (Categories table) and new unified schema (Categories as Items)
response = { "OK": false }; response = { "OK": false };
// Log file for debugging // Track which templates we've already saved options for (to avoid duplicate saves)
logFile = expandPath("./saveFromBuilder.log"); savedTemplates = {};
// Recursive function to save options/modifiers at any depth // Recursive function to save options/modifiers at any depth
function saveOptionsRecursive(options, parentID, businessID, logFile) { function saveOptionsRecursive(options, parentID, businessID) {
if (!isArray(options) || arrayLen(options) == 0) return; if (!isArray(options) || arrayLen(options) == 0) return;
var optSortOrder = 0; var optSortOrder = 0;
@ -22,11 +20,8 @@ function saveOptionsRecursive(options, parentID, businessID, logFile) {
var isDefault = (structKeyExists(opt, "isDefault") && opt.isDefault) ? 1 : 0; var isDefault = (structKeyExists(opt, "isDefault") && opt.isDefault) ? 1 : 0;
var optionID = 0; var optionID = 0;
fileAppend(logFile, "[#dateTimeFormat(now(), 'yyyy-mm-dd HH:nn:ss')#] Option: #opt.name# (dbId=#optDbId#, parentID=#parentID#, isDefault=#isDefault#, reqSel=#requiresSelection#, maxSel=#maxSelections#)#chr(10)#");
if (optDbId > 0) { if (optDbId > 0) {
optionID = optDbId; optionID = optDbId;
// Update existing option
queryExecute(" queryExecute("
UPDATE Items UPDATE Items
SET ItemName = :name, SET ItemName = :name,
@ -48,16 +43,15 @@ function saveOptionsRecursive(options, parentID, businessID, logFile) {
maxSelections: maxSelections maxSelections: maxSelections
}); });
} else { } else {
// Insert new option
queryExecute(" queryExecute("
INSERT INTO Items ( INSERT INTO Items (
ItemBusinessID, ItemParentItemID, ItemName, ItemPrice, ItemBusinessID, ItemParentItemID, ItemName, ItemPrice,
ItemIsCheckedByDefault, ItemSortOrder, ItemIsActive, ItemAddedOn, ItemIsCheckedByDefault, ItemSortOrder, ItemIsActive, ItemAddedOn,
ItemRequiresChildSelection, ItemMaxNumSelectionReq ItemRequiresChildSelection, ItemMaxNumSelectionReq, ItemCategoryID
) VALUES ( ) VALUES (
:businessID, :parentID, :name, :price, :businessID, :parentID, :name, :price,
:isDefault, :sortOrder, 1, NOW(), :isDefault, :sortOrder, 1, NOW(),
:requiresSelection, :maxSelections :requiresSelection, :maxSelections, 0
) )
", { ", {
businessID: businessID, businessID: businessID,
@ -74,9 +68,8 @@ function saveOptionsRecursive(options, parentID, businessID, logFile) {
optionID = result.newID; optionID = result.newID;
} }
// Recursively save nested options
if (structKeyExists(opt, "options") && isArray(opt.options) && arrayLen(opt.options) > 0) { if (structKeyExists(opt, "options") && isArray(opt.options) && arrayLen(opt.options) > 0) {
saveOptionsRecursive(opt.options, optionID, businessID, logFile); saveOptionsRecursive(opt.options, optionID, businessID);
} }
optSortOrder++; optSortOrder++;
@ -85,7 +78,6 @@ function saveOptionsRecursive(options, parentID, businessID, logFile) {
try { try {
requestBody = toString(getHttpRequestData().content); requestBody = toString(getHttpRequestData().content);
fileAppend(logFile, "[#dateTimeFormat(now(), 'yyyy-mm-dd HH:nn:ss')#] Request received, length: #len(requestBody)##chr(10)#");
if (!len(requestBody)) { if (!len(requestBody)) {
throw("Request body is required"); throw("Request body is required");
@ -95,8 +87,6 @@ try {
businessID = val(jsonData.BusinessID ?: 0); businessID = val(jsonData.BusinessID ?: 0);
menu = jsonData.Menu ?: {}; menu = jsonData.Menu ?: {};
fileAppend(logFile, "[#dateTimeFormat(now(), 'yyyy-mm-dd HH:nn:ss')#] BusinessID: #businessID#, Categories count: #arrayLen(menu.categories ?: [])##chr(10)#");
if (businessID == 0) { if (businessID == 0) {
throw("BusinessID is required"); throw("BusinessID is required");
} }
@ -105,297 +95,279 @@ try {
throw("Menu categories are required"); throw("Menu categories are required");
} }
// Log each category and its items // Check if new schema is active
for (cat in menu.categories) {
fileAppend(logFile, "[#dateTimeFormat(now(), 'yyyy-mm-dd HH:nn:ss')#] Category: #cat.name# (dbId=#structKeyExists(cat, 'dbId') ? cat.dbId : 'NEW'#), items: #arrayLen(cat.items ?: [])##chr(10)#");
if (structKeyExists(cat, "items") && isArray(cat.items)) {
for (item in cat.items) {
fileAppend(logFile, "[#dateTimeFormat(now(), 'yyyy-mm-dd HH:nn:ss')#] - Item: #item.name# (dbId=#structKeyExists(item, 'dbId') ? item.dbId : 'NEW'#) price=#item.price ?: 0##chr(10)#");
}
}
}
// Check if new schema is active (ItemBusinessID column exists and has data)
newSchemaActive = false; newSchemaActive = false;
try { try {
qCheck = queryExecute(" qCheck = queryExecute("
SELECT COUNT(*) as cnt FROM Items SELECT 1 FROM Items
WHERE ItemBusinessID = :businessID AND ItemBusinessID > 0 WHERE ItemBusinessID = :businessID AND ItemBusinessID > 0
LIMIT 1
", { businessID: businessID }); ", { businessID: businessID });
newSchemaActive = (qCheck.cnt > 0); newSchemaActive = (qCheck.recordCount > 0);
} catch (any e) { } catch (any e) {
newSchemaActive = false; newSchemaActive = false;
} }
// Process each category // Wrap everything in a transaction for speed and consistency
for (cat in menu.categories) { transaction {
categoryID = 0; catSortOrder = 0;
categoryDbId = structKeyExists(cat, "dbId") ? val(cat.dbId) : 0; for (cat in menu.categories) {
categoryID = 0;
categoryDbId = structKeyExists(cat, "dbId") ? val(cat.dbId) : 0;
if (newSchemaActive) { if (newSchemaActive) {
// NEW SCHEMA: Categories are Items with ParentID=0 and Template=0 if (categoryDbId > 0) {
if (categoryDbId > 0) { categoryID = categoryDbId;
categoryID = categoryDbId; queryExecute("
// Update existing category Item UPDATE Items
queryExecute(" SET ItemName = :name,
UPDATE Items ItemSortOrder = :sortOrder
SET ItemName = :name, WHERE ItemID = :categoryID
ItemSortOrder = :sortOrder AND ItemBusinessID = :businessID
WHERE ItemID = :categoryID ", {
AND ItemBusinessID = :businessID categoryID: categoryID,
", { businessID: businessID,
categoryID: categoryID, name: cat.name,
businessID: businessID, sortOrder: catSortOrder
name: cat.name, });
sortOrder: val(cat.sortOrder ?: 0)
});
} else {
// Insert new category as Item
queryExecute("
INSERT INTO Items (
ItemBusinessID, ItemName, ItemDescription,
ItemParentItemID, ItemPrice, ItemIsActive,
ItemSortOrder, ItemIsModifierTemplate, ItemAddedOn
) VALUES (
:businessID, :name, '',
0, 0, 1,
:sortOrder, 0, NOW()
)
", {
businessID: businessID,
name: cat.name,
sortOrder: val(cat.sortOrder ?: 0)
});
result = queryExecute("SELECT LAST_INSERT_ID() as newID");
categoryID = result.newID;
}
} else {
// OLD SCHEMA: Use Categories table
if (categoryDbId > 0) {
categoryID = categoryDbId;
queryExecute("
UPDATE Categories
SET CategoryName = :name,
CategoryDescription = :description,
CategorySortOrder = :sortOrder
WHERE CategoryID = :categoryID
", {
categoryID: categoryID,
name: cat.name,
description: cat.description ?: "",
sortOrder: val(cat.sortOrder ?: 0)
});
} else {
queryExecute("
INSERT INTO Categories (CategoryBusinessID, CategoryName, CategoryDescription, CategorySortOrder)
VALUES (:businessID, :name, :description, :sortOrder)
", {
businessID: businessID,
name: cat.name,
description: cat.description ?: "",
sortOrder: val(cat.sortOrder ?: 0)
});
result = queryExecute("SELECT LAST_INSERT_ID() as newID");
categoryID = result.newID;
}
}
// Process items in this category
if (structKeyExists(cat, "items") && isArray(cat.items)) {
for (item in cat.items) {
itemID = 0;
itemDbId = structKeyExists(item, "dbId") ? val(item.dbId) : 0;
if (itemDbId > 0) {
itemID = itemDbId;
if (newSchemaActive) {
// Update existing item - set parent to category Item
queryExecute("
UPDATE Items
SET ItemName = :name,
ItemDescription = :description,
ItemPrice = :price,
ItemParentItemID = :categoryID,
ItemSortOrder = :sortOrder
WHERE ItemID = :itemID
", {
itemID: itemID,
name: item.name,
description: item.description ?: "",
price: val(item.price ?: 0),
categoryID: categoryID,
sortOrder: val(item.sortOrder ?: 0)
});
} else {
// Update existing item - old schema with CategoryID
queryExecute("
UPDATE Items
SET ItemName = :name,
ItemDescription = :description,
ItemPrice = :price,
ItemCategoryID = :categoryID,
ItemSortOrder = :sortOrder
WHERE ItemID = :itemID
", {
itemID: itemID,
name: item.name,
description: item.description ?: "",
price: val(item.price ?: 0),
categoryID: categoryID,
sortOrder: val(item.sortOrder ?: 0)
});
}
} else { } else {
// Insert new item queryExecute("
if (newSchemaActive) { INSERT INTO Items (
queryExecute(" ItemBusinessID, ItemName, ItemDescription,
INSERT INTO Items ( ItemParentItemID, ItemPrice, ItemIsActive,
ItemBusinessID, ItemParentItemID, ItemName, ItemDescription, ItemSortOrder, ItemAddedOn, ItemCategoryID
ItemPrice, ItemSortOrder, ItemIsActive, ItemAddedOn ) VALUES (
) VALUES ( :businessID, :name, '',
:businessID, :categoryID, :name, :description, 0, 0, 1,
:price, :sortOrder, 1, NOW() :sortOrder, NOW(), 0
) )
", { ", {
businessID: businessID, businessID: businessID,
categoryID: categoryID, name: cat.name,
name: item.name, sortOrder: catSortOrder
description: item.description ?: "", });
price: val(item.price ?: 0),
sortOrder: val(item.sortOrder ?: 0)
});
} else {
queryExecute("
INSERT INTO Items (
ItemBusinessID, ItemCategoryID, ItemName, ItemDescription,
ItemPrice, ItemSortOrder, ItemIsActive, ItemParentItemID
) VALUES (
:businessID, :categoryID, :name, :description,
:price, :sortOrder, 1, 0
)
", {
businessID: businessID,
categoryID: categoryID,
name: item.name,
description: item.description ?: "",
price: val(item.price ?: 0),
sortOrder: val(item.sortOrder ?: 0)
});
}
result = queryExecute("SELECT LAST_INSERT_ID() as newID"); result = queryExecute("SELECT LAST_INSERT_ID() as newID");
itemID = result.newID; categoryID = result.newID;
} }
} else {
// Handle template links for modifiers if (categoryDbId > 0) {
if (structKeyExists(item, "modifiers") && isArray(item.modifiers)) { categoryID = categoryDbId;
// Clear existing template links for this item
queryExecute(" queryExecute("
DELETE FROM ItemTemplateLinks WHERE ItemID = :itemID UPDATE Categories
", { itemID: itemID }); SET CategoryName = :name,
CategorySortOrder = :sortOrder
WHERE CategoryID = :categoryID
", {
categoryID: categoryID,
name: cat.name,
sortOrder: catSortOrder
});
} else {
queryExecute("
INSERT INTO Categories (CategoryBusinessID, CategoryName, CategorySortOrder, CategoryAddedOn)
VALUES (:businessID, :name, :sortOrder, NOW())
", {
businessID: businessID,
name: cat.name,
sortOrder: catSortOrder
});
modSortOrder = 0; result = queryExecute("SELECT LAST_INSERT_ID() as newID");
for (mod in item.modifiers) { categoryID = result.newID;
modDbId = structKeyExists(mod, "dbId") ? val(mod.dbId) : 0; }
}
// Get selection rules (for modifier groups) // Process items
requiresSelection = (structKeyExists(mod, "requiresSelection") && mod.requiresSelection) ? 1 : 0; if (structKeyExists(cat, "items") && isArray(cat.items)) {
maxSelections = structKeyExists(mod, "maxSelections") ? val(mod.maxSelections) : 0; itemSortOrder = 0;
for (item in cat.items) {
itemID = 0;
itemDbId = structKeyExists(item, "dbId") ? val(item.dbId) : 0;
// Check if this is a template reference if (itemDbId > 0) {
if (structKeyExists(mod, "isTemplate") && mod.isTemplate && modDbId > 0) { itemID = itemDbId;
// Create template link
queryExecute("
INSERT INTO ItemTemplateLinks (ItemID, TemplateItemID, SortOrder)
VALUES (:itemID, :templateID, :sortOrder)
ON DUPLICATE KEY UPDATE SortOrder = :sortOrder
", {
itemID: itemID,
templateID: modDbId,
sortOrder: modSortOrder
});
// Also update the template's selection rules if (newSchemaActive) {
queryExecute("
UPDATE Items
SET ItemRequiresChildSelection = :requiresSelection,
ItemMaxNumSelectionReq = :maxSelections
WHERE ItemID = :modID
", {
modID: modDbId,
requiresSelection: requiresSelection,
maxSelections: maxSelections
});
// Save the template's options (children) recursively
if (structKeyExists(mod, "options") && isArray(mod.options)) {
fileAppend(logFile, "[#dateTimeFormat(now(), 'yyyy-mm-dd HH:nn:ss')#] Template: #mod.name# (dbId=#modDbId#) has #arrayLen(mod.options)# options#chr(10)#");
saveOptionsRecursive(mod.options, modDbId, businessID, logFile);
}
} else if (modDbId > 0) {
// Update existing direct modifier
queryExecute(" queryExecute("
UPDATE Items UPDATE Items
SET ItemName = :name, SET ItemName = :name,
ItemDescription = :description,
ItemPrice = :price, ItemPrice = :price,
ItemIsCheckedByDefault = :isDefault, ItemParentItemID = :categoryID,
ItemSortOrder = :sortOrder, ItemSortOrder = :sortOrder
ItemRequiresChildSelection = :requiresSelection, WHERE ItemID = :itemID
ItemMaxNumSelectionReq = :maxSelections
WHERE ItemID = :modID
", { ", {
modID: modDbId, itemID: itemID,
name: mod.name, name: item.name,
price: val(mod.price ?: 0), description: item.description ?: "",
isDefault: (mod.isDefault ?: false) ? 1 : 0, price: val(item.price ?: 0),
sortOrder: modSortOrder, categoryID: categoryID,
requiresSelection: requiresSelection, sortOrder: itemSortOrder
maxSelections: maxSelections
}); });
// Save nested options recursively
if (structKeyExists(mod, "options") && isArray(mod.options)) {
fileAppend(logFile, "[#dateTimeFormat(now(), 'yyyy-mm-dd HH:nn:ss')#] Modifier: #mod.name# (dbId=#modDbId#) has #arrayLen(mod.options)# options#chr(10)#");
saveOptionsRecursive(mod.options, modDbId, businessID, logFile);
}
} else { } else {
// Insert new direct modifier (non-template) queryExecute("
UPDATE Items
SET ItemName = :name,
ItemDescription = :description,
ItemPrice = :price,
ItemCategoryID = :categoryID,
ItemSortOrder = :sortOrder
WHERE ItemID = :itemID
", {
itemID: itemID,
name: item.name,
description: item.description ?: "",
price: val(item.price ?: 0),
categoryID: categoryID,
sortOrder: itemSortOrder
});
}
} else {
if (newSchemaActive) {
queryExecute(" queryExecute("
INSERT INTO Items ( INSERT INTO Items (
ItemBusinessID, ItemParentItemID, ItemName, ItemPrice, ItemBusinessID, ItemParentItemID, ItemName, ItemDescription,
ItemIsCheckedByDefault, ItemSortOrder, ItemIsActive, ItemAddedOn, ItemPrice, ItemSortOrder, ItemIsActive, ItemAddedOn, ItemCategoryID
ItemRequiresChildSelection, ItemMaxNumSelectionReq
) VALUES ( ) VALUES (
:businessID, :parentID, :name, :price, :businessID, :categoryID, :name, :description,
:isDefault, :sortOrder, 1, NOW(), :price, :sortOrder, 1, NOW(), 0
:requiresSelection, :maxSelections
) )
", { ", {
businessID: businessID, businessID: businessID,
parentID: itemID, categoryID: categoryID,
name: mod.name, name: item.name,
price: val(mod.price ?: 0), description: item.description ?: "",
isDefault: (mod.isDefault ?: false) ? 1 : 0, price: val(item.price ?: 0),
sortOrder: modSortOrder, sortOrder: itemSortOrder
requiresSelection: requiresSelection, });
maxSelections: maxSelections } else {
queryExecute("
INSERT INTO Items (
ItemBusinessID, ItemCategoryID, ItemName, ItemDescription,
ItemPrice, ItemSortOrder, ItemIsActive, ItemParentItemID, ItemAddedOn
) VALUES (
:businessID, :categoryID, :name, :description,
:price, :sortOrder, 1, 0, NOW()
)
", {
businessID: businessID,
categoryID: categoryID,
name: item.name,
description: item.description ?: "",
price: val(item.price ?: 0),
sortOrder: itemSortOrder
}); });
// Get the new modifier's ID and save nested options
modResult = queryExecute("SELECT LAST_INSERT_ID() as newModID");
newModID = modResult.newModID;
if (structKeyExists(mod, "options") && isArray(mod.options)) {
fileAppend(logFile, "[#dateTimeFormat(now(), 'yyyy-mm-dd HH:nn:ss')#] New Modifier: #mod.name# (newId=#newModID#) has #arrayLen(mod.options)# options#chr(10)#");
saveOptionsRecursive(mod.options, newModID, businessID, logFile);
}
} }
modSortOrder++;
result = queryExecute("SELECT LAST_INSERT_ID() as newID");
itemID = result.newID;
} }
// Handle modifiers
if (structKeyExists(item, "modifiers") && isArray(item.modifiers) && arrayLen(item.modifiers) > 0) {
// Clear existing template links for this item
queryExecute("DELETE FROM ItemTemplateLinks WHERE ItemID = :itemID", { itemID: itemID });
modSortOrder = 0;
for (mod in item.modifiers) {
modDbId = structKeyExists(mod, "dbId") ? val(mod.dbId) : 0;
requiresSelection = (structKeyExists(mod, "requiresSelection") && mod.requiresSelection) ? 1 : 0;
maxSelections = structKeyExists(mod, "maxSelections") ? val(mod.maxSelections) : 0;
if (structKeyExists(mod, "isTemplate") && mod.isTemplate && modDbId > 0) {
// This is a template reference - create link
queryExecute("
INSERT INTO ItemTemplateLinks (ItemID, TemplateItemID, SortOrder)
VALUES (:itemID, :templateID, :sortOrder)
ON DUPLICATE KEY UPDATE SortOrder = :sortOrder
", {
itemID: itemID,
templateID: modDbId,
sortOrder: modSortOrder
});
// Update template's selection rules
queryExecute("
UPDATE Items
SET ItemRequiresChildSelection = :requiresSelection,
ItemMaxNumSelectionReq = :maxSelections
WHERE ItemID = :modID
", {
modID: modDbId,
requiresSelection: requiresSelection,
maxSelections: maxSelections
});
// Only save template options ONCE (first time we encounter this template)
if (!structKeyExists(savedTemplates, modDbId)) {
savedTemplates[modDbId] = true;
if (structKeyExists(mod, "options") && isArray(mod.options)) {
saveOptionsRecursive(mod.options, modDbId, businessID);
}
}
} else if (modDbId > 0) {
// Direct modifier (not a template) - update it
queryExecute("
UPDATE Items
SET ItemName = :name,
ItemPrice = :price,
ItemIsCheckedByDefault = :isDefault,
ItemSortOrder = :sortOrder,
ItemRequiresChildSelection = :requiresSelection,
ItemMaxNumSelectionReq = :maxSelections,
ItemParentItemID = :parentID
WHERE ItemID = :modID
", {
modID: modDbId,
parentID: itemID,
name: mod.name,
price: val(mod.price ?: 0),
isDefault: (mod.isDefault ?: false) ? 1 : 0,
sortOrder: modSortOrder,
requiresSelection: requiresSelection,
maxSelections: maxSelections
});
if (structKeyExists(mod, "options") && isArray(mod.options)) {
saveOptionsRecursive(mod.options, modDbId, businessID);
}
} else {
// New direct modifier - insert it
queryExecute("
INSERT INTO Items (
ItemBusinessID, ItemParentItemID, ItemName, ItemPrice,
ItemIsCheckedByDefault, ItemSortOrder, ItemIsActive, ItemAddedOn,
ItemRequiresChildSelection, ItemMaxNumSelectionReq, ItemCategoryID
) VALUES (
:businessID, :parentID, :name, :price,
:isDefault, :sortOrder, 1, NOW(),
:requiresSelection, :maxSelections, 0
)
", {
businessID: businessID,
parentID: itemID,
name: mod.name,
price: val(mod.price ?: 0),
isDefault: (mod.isDefault ?: false) ? 1 : 0,
sortOrder: modSortOrder,
requiresSelection: requiresSelection,
maxSelections: maxSelections
});
modResult = queryExecute("SELECT LAST_INSERT_ID() as newModID");
newModID = modResult.newModID;
if (structKeyExists(mod, "options") && isArray(mod.options)) {
saveOptionsRecursive(mod.options, newModID, businessID);
}
}
modSortOrder++;
}
}
itemSortOrder++;
} }
} }
catSortOrder++;
} }
} }

6748
api/menu/saveFromBuilder.log Normal file

File diff suppressed because it is too large Load diff

100
api/menu/uploadHeader.cfm Normal file
View file

@ -0,0 +1,100 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cftry>
<cfset headersDir = "/var/www/biz.payfrit.com/uploads/headers">
<cfscript>
function apiAbort(payload) {
writeOutput(serializeJSON(payload));
abort;
}
// Get BusinessID from form, request scope, or header
bizId = 0;
if (structKeyExists(form, "BusinessID") && isNumeric(form.BusinessID) && form.BusinessID GT 0) {
bizId = int(form.BusinessID);
} else if (structKeyExists(request, "BusinessID") && isNumeric(request.BusinessID) && request.BusinessID GT 0) {
bizId = int(request.BusinessID);
} else {
httpHeaders = getHttpRequestData().headers;
if (structKeyExists(httpHeaders, "X-Business-ID") && isNumeric(httpHeaders["X-Business-ID"]) && httpHeaders["X-Business-ID"] GT 0) {
bizId = int(httpHeaders["X-Business-ID"]);
}
}
if (bizId LTE 0) {
apiAbort({ "OK": false, "ERROR": "missing_businessid", "MESSAGE": "BusinessID is required" });
}
</cfscript>
<!--- Check if file was uploaded --->
<cfif NOT structKeyExists(form, "header") OR form.header EQ "">
<cfoutput>#serializeJSON({ "OK": false, "ERROR": "no_file", "MESSAGE": "No file was uploaded" })#</cfoutput>
<cfabort>
</cfif>
<!--- Upload the file to temp location first --->
<cffile action="UPLOAD" filefield="header" destination="#headersDir#/" nameconflict="MAKEUNIQUE" mode="755" result="uploadResult">
<!--- Validate file type --->
<cfset allowedExtensions = "jpg,jpeg,gif,png,webp">
<cfif NOT listFindNoCase(allowedExtensions, uploadResult.ClientFileExt)>
<cffile action="DELETE" file="#headersDir#/#uploadResult.ServerFile#">
<cfoutput>#serializeJSON({ "OK": false, "ERROR": "invalid_type", "MESSAGE": "Only image files are accepted (jpg, jpeg, gif, png, webp)" })#</cfoutput>
<cfabort>
</cfif>
<!--- Get image info --->
<cfimage source="#headersDir#/#uploadResult.ServerFile#" action="info" structName="imageInfo">
<!--- Resize if needed (max 1200px width for headers) --->
<cfif imageInfo.width GT 1200>
<cfimage action="resize" source="#headersDir#/#uploadResult.ServerFile#" width="1200" destination="#headersDir#/#uploadResult.ServerFile#" overwrite="true">
</cfif>
<!--- Delete old header if exists --->
<cfquery name="qOldHeader" datasource="payfrit">
SELECT BusinessHeaderImageExtension
FROM Businesses
WHERE BusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#bizId#">
</cfquery>
<cfif qOldHeader.recordCount GT 0 AND len(trim(qOldHeader.BusinessHeaderImageExtension)) GT 0>
<cfset oldFile = "#headersDir#/#bizId#.#qOldHeader.BusinessHeaderImageExtension#">
<cfif fileExists(oldFile)>
<cftry>
<cffile action="DELETE" file="#oldFile#">
<cfcatch></cfcatch>
</cftry>
</cfif>
</cfif>
<!--- Rename to BusinessID.ext --->
<cffile action="RENAME" source="#headersDir#/#uploadResult.ServerFile#" destination="#headersDir#/#bizId#.#uploadResult.ClientFileExt#" mode="755">
<!--- Update database --->
<cfquery datasource="payfrit">
UPDATE Businesses
SET BusinessHeaderImageExtension = <cfqueryparam cfsqltype="cf_sql_varchar" value="#uploadResult.ClientFileExt#">
WHERE BusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#bizId#">
</cfquery>
<!--- Return success with image URL --->
<cfoutput>#serializeJSON({
"OK": true,
"ERROR": "",
"MESSAGE": "Header uploaded successfully",
"HEADERURL": "/uploads/headers/#bizId#.#uploadResult.ClientFileExt#",
"WIDTH": imageInfo.width,
"HEIGHT": imageInfo.height
})#</cfoutput>
<cfcatch type="any">
<cfheader statuscode="200" statustext="OK">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfoutput>#serializeJSON({ "OK": false, "ERROR": "server_error", "MESSAGE": cfcatch.message, "DETAIL": cfcatch.detail })#</cfoutput>
</cfcatch>
</cftry>

View file

@ -0,0 +1,71 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
function apiAbort(obj) {
writeOutput(serializeJSON(obj));
abort;
}
function readJsonBody() {
raw = toString(getHttpRequestData().content);
if (isNull(raw) || len(trim(raw)) EQ 0) return {};
try {
parsed = deserializeJSON(raw);
} catch(any e) {
apiAbort({ OK=false, ERROR="bad_json", MESSAGE="Invalid JSON body" });
}
if (!isStruct(parsed)) return {};
return parsed;
}
data = readJsonBody();
httpHeaders = getHttpRequestData().headers;
// Get BusinessID from: session > body > X-Business-ID header
bizId = 0;
if (structKeyExists(request, "BusinessID") && isNumeric(request.BusinessID) && request.BusinessID GT 0) {
bizId = int(request.BusinessID);
}
if (bizId LTE 0 && structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID) && data.BusinessID GT 0) {
bizId = int(data.BusinessID);
}
if (bizId LTE 0 && structKeyExists(httpHeaders, "X-Business-ID") && isNumeric(httpHeaders["X-Business-ID"]) && httpHeaders["X-Business-ID"] GT 0) {
bizId = int(httpHeaders["X-Business-ID"]);
}
if (bizId LTE 0) {
apiAbort({ OK=false, ERROR="no_business_selected" });
}
if (!structKeyExists(data, "ServicePointID") || !isNumeric(data.ServicePointID) || int(data.ServicePointID) LTE 0) {
apiAbort({ OK=false, ERROR="missing_servicepoint_id", MESSAGE="ServicePointID is required" });
}
servicePointId = int(data.ServicePointID);
</cfscript>
<cfquery datasource="payfrit">
UPDATE ServicePoints
SET ServicePointIsActive = 0
WHERE ServicePointID = <cfqueryparam cfsqltype="cf_sql_integer" value="#servicePointId#">
AND ServicePointBusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#bizId#">
</cfquery>
<!--- confirm --->
<cfquery name="qCheck" datasource="payfrit">
SELECT ServicePointID, ServicePointIsActive
FROM ServicePoints
WHERE ServicePointID = <cfqueryparam cfsqltype="cf_sql_integer" value="#servicePointId#">
AND ServicePointBusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#bizId#">
LIMIT 1
</cfquery>
<cfif qCheck.recordCount EQ 0>
<cfoutput>#serializeJSON({ OK=false, ERROR="not_found" })#</cfoutput>
<cfabort>
</cfif>
<cfoutput>#serializeJSON({ OK=true, ERROR="", ServicePointID=servicePointId })#</cfoutput>

68
api/servicepoints/get.cfm Normal file
View file

@ -0,0 +1,68 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
function apiAbort(obj) {
writeOutput(serializeJSON(obj));
abort;
}
function readJsonBody() {
raw = toString(getHttpRequestData().content);
if (isNull(raw) || len(trim(raw)) EQ 0) return {};
try {
parsed = deserializeJSON(raw);
} catch(any e) {
apiAbort({ OK=false, ERROR="bad_json", MESSAGE="Invalid JSON body" });
}
if (!isStruct(parsed)) return {};
return parsed;
}
data = readJsonBody();
if (!structKeyExists(request, "BusinessID") || !isNumeric(request.BusinessID) || request.BusinessID LTE 0) {
apiAbort({ OK=false, ERROR="no_business_selected" });
}
if (!structKeyExists(data, "ServicePointID") || !isNumeric(data.ServicePointID) || int(data.ServicePointID) LTE 0) {
apiAbort({ OK=false, ERROR="missing_servicepoint_id", MESSAGE="ServicePointID is required" });
}
servicePointId = int(data.ServicePointID);
</cfscript>
<cfquery name="q" datasource="payfrit">
SELECT
ServicePointID,
ServicePointBusinessID,
ServicePointName,
ServicePointCode,
ServicePointTypeID,
ServicePointIsActive,
SortOrder
FROM ServicePoints
WHERE ServicePointID = <cfqueryparam cfsqltype="cf_sql_integer" value="#servicePointId#">
AND ServicePointBusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#request.BusinessID#">
LIMIT 1
</cfquery>
<cfif q.recordCount EQ 0>
<cfoutput>#serializeJSON({ OK=false, ERROR="not_found" })#</cfoutput>
<cfabort>
</cfif>
<cfset servicePoint = {
"ServicePointID" = q.ServicePointID,
"BusinessID" = q.ServicePointBusinessID,
"ServicePointName" = q.ServicePointName,
"ServicePointCode" = q.ServicePointCode,
"ServicePointTypeID"= q.ServicePointTypeID,
"IsActive" = q.ServicePointIsActive,
"SortOrder" = q.SortOrder
}>
<cfoutput>#serializeJSON({ OK=true, ERROR="", SERVICEPOINT=servicePoint })#</cfoutput>

View file

@ -1,6 +1,8 @@
<cfsetting showdebugoutput="false"> <cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true"> <cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript> <cfscript>
function apiAbort(payload) { function apiAbort(payload) {
@ -8,129 +10,85 @@ function apiAbort(payload) {
abort; abort;
} }
// Resolve BusinessID tolerant MVP way // Read JSON body once
bizId = 0; data = {};
try {
raw = toString(getHttpRequestData().content);
if (len(trim(raw))) {
data = deserializeJSON(raw);
if (!isStruct(data)) data = {};
}
} catch (any e) {
data = {};
}
if (structKeyExists(request, "BusinessID") && isNumeric(request.BusinessID)) { httpHeaders = getHttpRequestData().headers;
// Get BusinessID from: session > body > X-Business-ID header > URL
bizId = 0;
if (structKeyExists(request, "BusinessID") && isNumeric(request.BusinessID) && request.BusinessID GT 0) {
bizId = int(request.BusinessID); bizId = int(request.BusinessID);
} }
if (bizId LTE 0 && structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID) && data.BusinessID GT 0) {
if (bizId LTE 0) { bizId = int(data.BusinessID);
try {
raw = toString(getHttpRequestData().content);
if (len(trim(raw))) {
body = deserializeJSON(raw);
if (isStruct(body) && structKeyExists(body, "BusinessID") && isNumeric(body.BusinessID)) {
bizId = int(body.BusinessID);
}
}
} catch (any e) {}
} }
if (bizId LTE 0 && structKeyExists(httpHeaders, "X-Business-ID") && isNumeric(httpHeaders["X-Business-ID"]) && httpHeaders["X-Business-ID"] GT 0) {
if (bizId LTE 0 && structKeyExists(url, "BusinessID") && isNumeric(url.BusinessID)) { bizId = int(httpHeaders["X-Business-ID"]);
}
if (bizId LTE 0 && structKeyExists(url, "BusinessID") && isNumeric(url.BusinessID) && url.BusinessID GT 0) {
bizId = int(url.BusinessID); bizId = int(url.BusinessID);
} }
if (bizId LTE 0) { if (bizId LTE 0) {
apiAbort({ "OK": false, "ERROR": "missing_businessid", "DETAIL": "" }); apiAbort({ "OK": false, "ERROR": "missing_businessid" });
} }
try { // Default behavior: only active service points unless onlyActive is explicitly false/0
// Detect the correct business FK column in ServicePoints onlyActive = true;
qCols = queryExecute( if (structKeyExists(data, "onlyActive")) {
" if (isBoolean(data.onlyActive)) {
SELECT COLUMN_NAME onlyActive = data.onlyActive;
FROM INFORMATION_SCHEMA.COLUMNS } else if (isNumeric(data.onlyActive)) {
WHERE TABLE_SCHEMA = DATABASE() onlyActive = (int(data.onlyActive) EQ 1);
AND TABLE_NAME = 'ServicePoints' } else if (isSimpleValue(data.onlyActive)) {
", onlyActive = (lcase(trim(toString(data.onlyActive))) EQ "true");
[],
{ datasource: "payfrit" }
);
cols = [];
for (r in qCols) arrayAppend(cols, r.COLUMN_NAME);
// Candidates in preferred order (add more if needed)
candidates = [
"BusinessID",
"BusinessId",
"ServicePointBusinessID",
"ServicePointsBusinessID",
"Business_ID",
"Business",
"BusinessFk",
"BusinessFK"
];
bizCol = "";
// First: exact candidate match
for (c in candidates) {
for (colName in cols) {
if (lcase(colName) EQ lcase(c)) {
bizCol = colName;
break;
}
}
if (len(bizCol)) break;
} }
// Second: heuristic: any column containing "business" and "id"
if (!len(bizCol)) {
for (colName in cols) {
if (findNoCase("business", colName) && findNoCase("id", colName)) {
bizCol = colName;
break;
}
}
}
if (!len(bizCol)) {
apiAbort({
"OK": false,
"ERROR": "schema_mismatch",
"DETAIL": "Could not find a BusinessID-like column in ServicePoints. Available columns: " & arrayToList(cols, ", ")
});
}
// Build SQL using detected column name (safe because it comes from INFORMATION_SCHEMA)
sql = "
SELECT
ServicePointID,
ServicePointName
FROM ServicePoints
WHERE #bizCol# = ?
ORDER BY ServicePointName
";
q = queryExecute(
sql,
[ { value: bizId, cfsqltype: "cf_sql_integer" } ],
{ datasource: "payfrit" }
);
servicePoints = [];
for (row in q) {
arrayAppend(servicePoints, {
"ServicePointID": row.ServicePointID,
"ServicePointName": row.ServicePointName
});
}
writeOutput(serializeJSON({
"OK": true,
"ERROR": "",
"DETAIL": "",
"BusinessID": bizId,
"BusinessColumn": bizCol,
"COUNT": arrayLen(servicePoints),
"ServicePoints": servicePoints
}));
} catch (any e) {
apiAbort({
"OK": false,
"ERROR": "db_error",
"DETAIL": e.message
});
} }
</cfscript> </cfscript>
<cfquery name="q" datasource="payfrit">
SELECT
ServicePointID,
ServicePointName,
ServicePointTypeID,
ServicePointCode,
ServicePointDescription,
ServicePointSortOrder,
ServicePointIsActive
FROM ServicePoints
WHERE ServicePointBusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#bizId#">
<cfif onlyActive>
AND ServicePointIsActive = 1
</cfif>
ORDER BY ServicePointSortOrder, ServicePointName
</cfquery>
<cfset servicePoints = []>
<cfloop query="q">
<cfset arrayAppend(servicePoints, {
"ServicePointID" = q.ServicePointID,
"ServicePointName" = q.ServicePointName,
"ServicePointTypeID" = q.ServicePointTypeID,
"ServicePointCode" = q.ServicePointCode,
"Description" = q.ServicePointDescription,
"SortOrder" = q.ServicePointSortOrder,
"IsActive" = q.ServicePointIsActive
})>
</cfloop>
<cfoutput>#serializeJSON({
"OK": true,
"ERROR": "",
"BusinessID": bizId,
"COUNT": arrayLen(servicePoints),
"SERVICEPOINTS": servicePoints
})#</cfoutput>

146
api/servicepoints/save.cfm Normal file
View file

@ -0,0 +1,146 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cftry>
<cfscript>
function apiAbort(obj) {
writeOutput(serializeJSON(obj));
abort;
}
function readJsonBody() {
raw = toString(getHttpRequestData().content);
if (isNull(raw) || len(trim(raw)) EQ 0) return {};
try {
parsed = deserializeJSON(raw);
} catch(any e) {
apiAbort({ OK=false, ERROR="bad_json", MESSAGE="Invalid JSON body" });
}
if (!isStruct(parsed)) return {};
return parsed;
}
function normStr(v) {
if (isNull(v)) return "";
return trim(toString(v));
}
data = readJsonBody();
if (!structKeyExists(request, "BusinessID") || !isNumeric(request.BusinessID) || request.BusinessID LTE 0) {
apiAbort({ OK=false, ERROR="no_business_selected" });
}
if (!structKeyExists(data, "ServicePointName") || len(normStr(data.ServicePointName)) EQ 0) {
apiAbort({ OK=false, ERROR="missing_name", MESSAGE="ServicePointName is required" });
}
servicePointId = 0;
if (structKeyExists(data, "ServicePointID") && isNumeric(data.ServicePointID) && int(data.ServicePointID) GT 0) {
servicePointId = int(data.ServicePointID);
}
spName = normStr(data.ServicePointName);
spCode = structKeyExists(data, "ServicePointCode") ? normStr(data.ServicePointCode) : "";
spTypeID = structKeyExists(data, "ServicePointTypeID") && isNumeric(data.ServicePointTypeID) ? int(data.ServicePointTypeID) : 1;
sortOrder = structKeyExists(data, "ServicePointSortOrder") && isNumeric(data.ServicePointSortOrder) ? int(data.ServicePointSortOrder) : 0;
isActive = 1;
if (structKeyExists(data, "IsActive")) {
if (isBoolean(data.IsActive)) isActive = (data.IsActive ? 1 : 0);
else if (isNumeric(data.IsActive)) isActive = int(data.IsActive);
else if (isSimpleValue(data.IsActive)) isActive = (lcase(trim(toString(data.IsActive))) EQ "true" ? 1 : 0);
}
</cfscript>
<cfif servicePointId GT 0>
<!--- Update, scoped to this business --->
<cfquery datasource="payfrit">
UPDATE ServicePoints
SET
ServicePointName = <cfqueryparam cfsqltype="cf_sql_varchar" value="#spName#">,
ServicePointCode = <cfqueryparam cfsqltype="cf_sql_varchar" value="#spCode#" null="#(len(spCode) EQ 0)#">,
ServicePointTypeID = <cfqueryparam cfsqltype="cf_sql_integer" value="#spTypeID#">,
ServicePointIsActive = <cfqueryparam cfsqltype="cf_sql_tinyint" value="#isActive#">,
ServicePointSortOrder = <cfqueryparam cfsqltype="cf_sql_integer" value="#sortOrder#">
WHERE ServicePointID = <cfqueryparam cfsqltype="cf_sql_integer" value="#servicePointId#">
AND ServicePointBusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#request.BusinessID#">
</cfquery>
<!--- confirm it exists/belongs to business --->
<cfquery name="qCheck" datasource="payfrit">
SELECT ServicePointID
FROM ServicePoints
WHERE ServicePointID = <cfqueryparam cfsqltype="cf_sql_integer" value="#servicePointId#">
AND ServicePointBusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#request.BusinessID#">
LIMIT 1
</cfquery>
<cfif qCheck.recordCount EQ 0>
<cfoutput>#serializeJSON({ OK=false, ERROR="not_found" })#</cfoutput>
<cfabort>
</cfif>
<cfelse>
<!--- Insert --->
<cfquery datasource="payfrit">
INSERT INTO ServicePoints (
ServicePointBusinessID,
ServicePointName,
ServicePointCode,
ServicePointTypeID,
ServicePointIsActive,
ServicePointSortOrder
) VALUES (
<cfqueryparam cfsqltype="cf_sql_integer" value="#request.BusinessID#">,
<cfqueryparam cfsqltype="cf_sql_varchar" value="#spName#">,
<cfqueryparam cfsqltype="cf_sql_varchar" value="#spCode#" null="#(len(spCode) EQ 0)#">,
<cfqueryparam cfsqltype="cf_sql_integer" value="#spTypeID#">,
<cfqueryparam cfsqltype="cf_sql_tinyint" value="#isActive#">,
<cfqueryparam cfsqltype="cf_sql_integer" value="#sortOrder#">
)
</cfquery>
<cfquery name="qId" datasource="payfrit">
SELECT LAST_INSERT_ID() AS ServicePointID
</cfquery>
<cfset servicePointId = qId.ServicePointID>
</cfif>
<!--- Return saved row --->
<cfquery name="qOut" datasource="payfrit">
SELECT
ServicePointID,
ServicePointBusinessID,
ServicePointName,
ServicePointCode,
ServicePointTypeID,
ServicePointIsActive,
ServicePointSortOrder
FROM ServicePoints
WHERE ServicePointID = <cfqueryparam cfsqltype="cf_sql_integer" value="#servicePointId#">
AND ServicePointBusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#request.BusinessID#">
LIMIT 1
</cfquery>
<cfset servicePoint = {
"ServicePointID" = qOut.ServicePointID,
"BusinessID" = qOut.ServicePointBusinessID,
"ServicePointName" = qOut.ServicePointName,
"ServicePointCode" = qOut.ServicePointCode,
"ServicePointTypeID"= qOut.ServicePointTypeID,
"IsActive" = qOut.ServicePointIsActive,
"ServicePointSortOrder" = qOut.ServicePointSortOrder
}>
<cfoutput>#serializeJSON({ OK=true, ERROR="", SERVICEPOINT=servicePoint })#</cfoutput>
<cfcatch type="any">
<cfheader statuscode="200" statustext="OK">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfoutput>#serializeJSON({ OK=false, ERROR="server_error", MESSAGE=cfcatch.message, DETAIL=cfcatch.detail })#</cfoutput>
</cfcatch>
</cftry>

View file

@ -0,0 +1,92 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
/**
* Check for duplicate businesses
*
* POST JSON:
* {
* "name": "Business Name",
* "addressLine1": "123 Main St",
* "city": "Los Angeles",
* "state": "CA",
* "zip": "90001"
* }
*
* Returns:
* {
* "OK": true,
* "duplicates": [ { BusinessID, BusinessName, Address } ]
* }
*/
response = { "OK": true, "duplicates": [] };
try {
requestBody = toString(getHttpRequestData().content);
if (!len(requestBody)) {
throw(message="No request body provided");
}
data = deserializeJSON(requestBody);
bizName = structKeyExists(data, "name") && isSimpleValue(data.name) ? trim(data.name) : "";
addressLine1 = structKeyExists(data, "addressLine1") && isSimpleValue(data.addressLine1) ? trim(data.addressLine1) : "";
city = structKeyExists(data, "city") && isSimpleValue(data.city) ? trim(data.city) : "";
state = structKeyExists(data, "state") && isSimpleValue(data.state) ? trim(data.state) : "";
zip = structKeyExists(data, "zip") && isSimpleValue(data.zip) ? trim(data.zip) : "";
// Clean up city - remove trailing punctuation
city = reReplace(city, "[,.\s]+$", "", "all");
// Build query to find potential duplicates
// Match by name (case-insensitive) OR by address components
qDuplicates = queryExecute("
SELECT DISTINCT
b.BusinessID,
b.BusinessName,
a.AddressLine1,
a.AddressCity,
s.tt_StateAbbreviation as AddressState,
a.AddressZIPCode
FROM Businesses b
LEFT JOIN Addresses a ON a.AddressBusinessID = b.BusinessID
LEFT JOIN tt_States s ON s.tt_StateID = a.AddressStateID
WHERE
LOWER(b.BusinessName) = LOWER(:bizName)
OR (
LOWER(a.AddressLine1) = LOWER(:addressLine1)
AND LOWER(a.AddressCity) = LOWER(:city)
AND a.AddressLine1 != ''
AND a.AddressCity != ''
)
ORDER BY b.BusinessName
", {
bizName: bizName,
addressLine1: addressLine1,
city: city
}, { datasource: "payfrit" });
for (i = 1; i <= qDuplicates.recordCount; i++) {
addressParts = [];
if (len(qDuplicates.AddressLine1[i])) arrayAppend(addressParts, qDuplicates.AddressLine1[i]);
if (len(qDuplicates.AddressCity[i])) arrayAppend(addressParts, qDuplicates.AddressCity[i]);
if (len(qDuplicates.AddressState[i])) arrayAppend(addressParts, qDuplicates.AddressState[i]);
if (len(qDuplicates.AddressZIPCode[i])) arrayAppend(addressParts, qDuplicates.AddressZIPCode[i]);
arrayAppend(response.duplicates, {
"BusinessID": qDuplicates.BusinessID[i],
"BusinessName": qDuplicates.BusinessName[i],
"Address": arrayToList(addressParts, ", ")
});
}
} catch (any e) {
response.OK = false;
response.error = e.message;
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -41,7 +41,8 @@ try {
if (businessId == 0) { if (businessId == 0) {
response.steps.append("No businessId provided - creating new business"); response.steps.append("No businessId provided - creating new business");
if (!structKeyExists(biz, "name") || !len(biz.name)) { bizName = structKeyExists(biz, "name") && isSimpleValue(biz.name) ? biz.name : "";
if (!len(bizName)) {
throw(message="Business name is required to create new business"); throw(message="Business name is required to create new business");
} }
@ -49,21 +50,30 @@ try {
throw(message="userId is required to create new business"); throw(message="userId is required to create new business");
} }
// Create address record first (use extracted address fields) // Extract phone number
addressLine1 = structKeyExists(biz, "addressLine1") ? biz.addressLine1 : ""; bizPhone = structKeyExists(biz, "phone") && isSimpleValue(biz.phone) ? trim(biz.phone) : "";
city = structKeyExists(biz, "city") ? biz.city : "";
state = structKeyExists(biz, "state") ? biz.state : ""; // Create address record first (use extracted address fields) - safely extract as simple values
zip = structKeyExists(biz, "zip") ? biz.zip : ""; addressLine1 = structKeyExists(biz, "addressLine1") && isSimpleValue(biz.addressLine1) ? trim(biz.addressLine1) : "";
city = structKeyExists(biz, "city") && isSimpleValue(biz.city) ? trim(biz.city) : "";
state = structKeyExists(biz, "state") && isSimpleValue(biz.state) ? trim(biz.state) : "";
zip = structKeyExists(biz, "zip") && isSimpleValue(biz.zip) ? trim(biz.zip) : "";
// Clean up city - remove trailing punctuation (commas, periods, etc.)
city = reReplace(city, "[,.\s]+$", "", "all");
// Look up state ID from state abbreviation // Look up state ID from state abbreviation
stateID = 0; stateID = 0;
response.steps.append("State value received: '" & state & "' (len: " & len(state) & ")");
if (len(state)) { if (len(state)) {
qState = queryExecute(" qState = queryExecute("
SELECT tt_StateID FROM tt_States WHERE tt_StateAbbreviation = :abbr SELECT tt_StateID FROM tt_States WHERE tt_StateAbbreviation = :abbr
", { abbr: uCase(state) }, { datasource: "payfrit" }); ", { abbr: uCase(state) }, { datasource: "payfrit" });
response.steps.append("State lookup for '" & uCase(state) & "' found " & qState.recordCount & " records");
if (qState.recordCount > 0) { if (qState.recordCount > 0) {
stateID = qState.tt_StateID; stateID = qState.tt_StateID;
response.steps.append("Using stateID: " & stateID);
} }
} }
@ -83,12 +93,13 @@ try {
addressId = qNewAddr.id; addressId = qNewAddr.id;
response.steps.append("Created address record (ID: " & addressId & ")"); response.steps.append("Created address record (ID: " & addressId & ")");
// Create new business with address link // Create new business with address link and phone
queryExecute(" queryExecute("
INSERT INTO Businesses (BusinessName, BusinessUserID, BusinessAddressID, BusinessDeliveryZipCodes, BusinessAddedOn) INSERT INTO Businesses (BusinessName, BusinessPhone, BusinessUserID, BusinessAddressID, BusinessDeliveryZipCodes, BusinessAddedOn)
VALUES (:name, :userId, :addressId, :deliveryZips, NOW()) VALUES (:name, :phone, :userId, :addressId, :deliveryZips, NOW())
", { ", {
name: biz.name, name: bizName,
phone: bizPhone,
userId: userId, userId: userId,
addressId: addressId, addressId: addressId,
deliveryZips: len(zip) ? zip : "" deliveryZips: len(zip) ? zip : ""
@ -96,7 +107,7 @@ try {
qNewBiz = queryExecute("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" }); qNewBiz = queryExecute("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" });
businessId = qNewBiz.id; businessId = qNewBiz.id;
response.steps.append("Created new business: " & biz.name & " (ID: " & businessId & ")"); response.steps.append("Created new business: " & bizName & " (ID: " & businessId & ")");
// Update address with business ID link // Update address with business ID link
queryExecute(" queryExecute("
@ -114,9 +125,11 @@ try {
for (i = 1; i <= arrayLen(hoursSchedule); i++) { for (i = 1; i <= arrayLen(hoursSchedule); i++) {
dayData = hoursSchedule[i]; dayData = hoursSchedule[i];
if (!isStruct(dayData)) continue;
dayID = structKeyExists(dayData, "dayId") ? val(dayData.dayId) : 0; dayID = structKeyExists(dayData, "dayId") ? val(dayData.dayId) : 0;
openTime = structKeyExists(dayData, "open") ? dayData.open : "09:00"; openTime = structKeyExists(dayData, "open") && isSimpleValue(dayData.open) ? dayData.open : "09:00";
closeTime = structKeyExists(dayData, "close") ? dayData.close : "17:00"; closeTime = structKeyExists(dayData, "close") && isSimpleValue(dayData.close) ? dayData.close : "17:00";
// Convert HH:MM to HH:MM:SS if needed // Convert HH:MM to HH:MM:SS if needed
if (len(openTime) == 5) openTime = openTime & ":00"; if (len(openTime) == 5) openTime = openTime & ":00";
@ -158,9 +171,13 @@ try {
for (i = 1; i <= arrayLen(modTemplates); i++) { for (i = 1; i <= arrayLen(modTemplates); i++) {
tmpl = modTemplates[i]; tmpl = modTemplates[i];
tmplName = tmpl.name; tmplName = structKeyExists(tmpl, "name") && isSimpleValue(tmpl.name) ? tmpl.name : "";
if (!len(tmplName)) {
response.steps.append("Warning: Skipping modifier template with no name at index " & i);
continue;
}
required = structKeyExists(tmpl, "required") && tmpl.required == true; required = structKeyExists(tmpl, "required") && tmpl.required == true;
options = structKeyExists(tmpl, "options") ? tmpl.options : []; options = structKeyExists(tmpl, "options") && isArray(tmpl.options) ? tmpl.options : [];
// Debug: Log options info // Debug: Log options info
response.steps.append("Template '" & tmplName & "' has " & arrayLen(options) & " options (type: " & (isArray(options) ? "array" : "other") & ")"); response.steps.append("Template '" & tmplName & "' has " & arrayLen(options) & " options (type: " & (isArray(options) ? "array" : "other") & ")");
@ -204,13 +221,14 @@ try {
optionOrder = 1; optionOrder = 1;
for (j = 1; j <= arrayLen(options); j++) { for (j = 1; j <= arrayLen(options); j++) {
opt = options[j]; opt = options[j];
// Safety check: ensure opt is a struct with a name // Safety check: ensure opt is a struct with a name that's a simple value
if (!isStruct(opt) || !structKeyExists(opt, "name") || !len(opt.name)) { if (!isStruct(opt)) continue;
continue; if (!structKeyExists(opt, "name")) continue;
} if (!isSimpleValue(opt.name)) continue;
if (!len(opt.name)) continue;
optName = opt.name; optName = opt.name;
optPrice = structKeyExists(opt, "price") ? val(opt.price) : 0; optPrice = structKeyExists(opt, "price") && isSimpleValue(opt.price) ? val(opt.price) : 0;
qOpt = queryExecute(" qOpt = queryExecute("
SELECT ItemID FROM Items SELECT ItemID FROM Items
@ -246,7 +264,11 @@ try {
catOrder = 1; catOrder = 1;
for (c = 1; c <= arrayLen(categories); c++) { for (c = 1; c <= arrayLen(categories); c++) {
cat = categories[c]; cat = categories[c];
catName = cat.name; catName = structKeyExists(cat, "name") && isSimpleValue(cat.name) ? cat.name : "";
if (!len(catName)) {
response.steps.append("Warning: Skipping category with no name at index " & c);
continue;
}
// Check if category exists in Categories table // Check if category exists in Categories table
qCat = queryExecute(" qCat = queryExecute("
@ -293,15 +315,37 @@ try {
for (n = 1; n <= arrayLen(items); n++) { for (n = 1; n <= arrayLen(items); n++) {
item = items[n]; item = items[n];
itemName = item.name; if (!isStruct(item)) continue;
itemDesc = structKeyExists(item, "description") ? item.description : "";
itemPrice = structKeyExists(item, "price") ? val(item.price) : 0; // Safely extract all item fields - ensure they're simple values
itemCategory = structKeyExists(item, "category") ? item.category : ""; itemName = structKeyExists(item, "name") && isSimpleValue(item.name) ? item.name : "";
itemModifiers = structKeyExists(item, "modifiers") ? item.modifiers : []; if (!len(itemName)) {
response.steps.append("Warning: Skipping item with no name at index " & n);
continue;
}
itemDesc = "";
if (structKeyExists(item, "description") && isSimpleValue(item.description)) {
itemDesc = item.description;
}
itemPrice = 0;
if (structKeyExists(item, "price")) {
if (isSimpleValue(item.price)) {
itemPrice = val(item.price);
}
}
itemCategory = "";
if (structKeyExists(item, "category") && isSimpleValue(item.category)) {
itemCategory = item.category;
}
itemModifiers = structKeyExists(item, "modifiers") && isArray(item.modifiers) ? item.modifiers : [];
// Get category ID // Get category ID
if (!len(itemCategory) || !structKeyExists(categoryMap, itemCategory)) { if (!len(itemCategory) || !structKeyExists(categoryMap, itemCategory)) {
response.steps.append("Warning: Item '" & itemName & "' has unknown category '" & itemCategory & "' - skipping"); response.steps.append("Warning: Item '" & itemName & "' has unknown category - skipping");
continue; continue;
} }
@ -363,7 +407,15 @@ try {
// Link modifier templates to this item // Link modifier templates to this item
modOrder = 1; modOrder = 1;
for (m = 1; m <= arrayLen(itemModifiers); m++) { for (m = 1; m <= arrayLen(itemModifiers); m++) {
modName = itemModifiers[m]; modRef = itemModifiers[m];
// Handle both string modifier names and struct references
if (isSimpleValue(modRef)) {
modName = modRef;
} else if (isStruct(modRef) && structKeyExists(modRef, "name")) {
modName = modRef.name;
} else {
continue; // Skip invalid modifier reference
}
if (structKeyExists(templateMap, modName)) { if (structKeyExists(templateMap, modName)) {
templateItemID = templateMap[modName]; templateItemID = templateMap[modName];

4
portal/favicon.svg Normal file
View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#6366f1"/>
<text x="16" y="22" text-anchor="middle" font-family="system-ui, sans-serif" font-weight="700" font-size="18" fill="#fff">P</text>
</svg>

After

Width:  |  Height:  |  Size: 256 B

View file

@ -58,6 +58,14 @@
</svg> </svg>
<span>Team</span> <span>Team</span>
</a> </a>
<a href="#beacons" class="nav-item" data-page="beacons">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M12 2v4M12 18v4M2 12h4M18 12h4"/>
<path d="M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
</svg>
<span>Beacons</span>
</a>
<a href="#settings" class="nav-item" data-page="settings"> <a href="#settings" class="nav-item" data-page="settings">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/> <circle cx="12" cy="12" r="3"/>
@ -341,6 +349,50 @@
</div> </div>
</section> </section>
<!-- Beacons Page -->
<section class="page" id="page-beacons">
<div class="beacons-layout">
<!-- Left side: Beacons list -->
<div class="card">
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center;">
<h3>Beacons</h3>
<button class="btn btn-sm btn-primary" onclick="Portal.showBeaconModal()">+ Add Beacon</button>
</div>
<div class="card-body">
<div class="list-group" id="beaconsList">
<div class="empty-state">Loading beacons...</div>
</div>
</div>
</div>
<!-- Middle: Service Points list -->
<div class="card">
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center;">
<h3>Service Points</h3>
<button class="btn btn-sm btn-primary" onclick="Portal.showServicePointModal()">+ Add Service Point</button>
</div>
<div class="card-body">
<div class="list-group" id="servicePointsList">
<div class="empty-state">Loading service points...</div>
</div>
</div>
</div>
<!-- Right side: Assignments -->
<div class="card">
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center;">
<h3>Beacon Assignments</h3>
<button class="btn btn-sm btn-primary" onclick="Portal.showAssignmentModal()">+ Assign</button>
</div>
<div class="card-body">
<div class="list-group" id="assignmentsList">
<div class="empty-state">Loading assignments...</div>
</div>
</div>
</div>
</div>
</section>
<!-- Settings Page --> <!-- Settings Page -->
<section class="page" id="page-settings"> <section class="page" id="page-settings">
<div class="settings-grid"> <div class="settings-grid">
@ -349,37 +401,44 @@
<h3>Business Information</h3> <h3>Business Information</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<form id="businessInfoForm" class="form"> <form id="businessInfoForm" class="form" onsubmit="Portal.saveBusinessInfo(event)">
<div class="form-group"> <div class="form-group">
<label>Business Name</label> <label>Business Name</label>
<input type="text" id="settingBusinessName" class="form-input"> <input type="text" id="settingBusinessName" class="form-input" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Description</label> <label>Phone</label>
<textarea id="settingDescription" class="form-textarea" rows="3"></textarea> <input type="tel" id="settingPhone" class="form-input" placeholder="(555) 123-4567">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Address</label> <label>Street Address</label>
<input type="text" id="settingAddress" class="form-input"> <input type="text" id="settingAddressLine1" class="form-input" placeholder="123 Main St">
</div> </div>
<div class="form-row"> <div class="form-row" style="display:grid;grid-template-columns:2fr 1fr 1fr;gap:12px;">
<div class="form-group"> <div class="form-group">
<label>Phone</label> <label>City</label>
<input type="tel" id="settingPhone" class="form-input"> <input type="text" id="settingCity" class="form-input" placeholder="Los Angeles">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Email</label> <label>State</label>
<input type="email" id="settingEmail" class="form-input"> <select id="settingState" class="form-input">
<option value="">--</option>
</select>
</div>
<div class="form-group">
<label>ZIP</label>
<input type="text" id="settingZip" class="form-input" placeholder="90001" maxlength="10">
</div> </div>
</div> </div>
<button type="submit" class="btn btn-primary">Save Changes</button> <button type="submit" class="btn btn-primary" id="saveBusinessBtn">Save Changes</button>
</form> </form>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header" style="display:flex;justify-content:space-between;align-items:center;">
<h3>Business Hours</h3> <h3>Business Hours</h3>
<button class="btn btn-sm btn-secondary" onclick="Portal.saveHours()">Save Hours</button>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="hours-list" id="hoursEditor"> <div class="hours-list" id="hoursEditor">

View file

@ -652,13 +652,18 @@
.toolbar-btn.primary { .toolbar-btn.primary {
background: var(--primary); background: var(--primary);
border-color: var(--primary); border-color: var(--primary);
color: #000; color: #fff;
} }
.toolbar-btn.primary:hover { .toolbar-btn.primary:hover {
background: var(--primary-hover); background: var(--primary-hover);
} }
.toolbar-btn.primary:active {
background: var(--primary-dark);
transform: scale(0.98);
}
.toolbar-btn svg { .toolbar-btn svg {
width: 16px; width: 16px;
height: 16px; height: 16px;
@ -790,17 +795,11 @@
</button> </button>
</div> </div>
<div class="toolbar-group"> <div class="toolbar-group">
<button class="toolbar-btn" onclick="MenuBuilder.showImportModal()" title="Import JSON"> <button class="toolbar-btn" onclick="MenuBuilder.showOutlineModal()" title="View Full Menu Outline">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/> <path d="M4 6h16M4 12h16M4 18h12"/>
</svg> </svg>
Import Outline
</button>
<button class="toolbar-btn" onclick="MenuBuilder.showExportModal()" title="Export JSON">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
</svg>
Export
</button> </button>
</div> </div>
<div class="toolbar-group" style="margin-left: auto;"> <div class="toolbar-group" style="margin-left: auto;">
@ -874,6 +873,26 @@
<strong id="statPhotosMissing" style="color: var(--warning);">0</strong> <strong id="statPhotosMissing" style="color: var(--warning);">0</strong>
</div> </div>
</div> </div>
<h3>Branding</h3>
<div style="display: flex; flex-direction: column; gap: 8px;">
<button class="btn btn-secondary" onclick="MenuBuilder.uploadHeader()" style="width: 100%; display: flex; align-items: center; gap: 8px; justify-content: center;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
Upload Header
</button>
<button class="btn btn-secondary" onclick="MenuBuilder.showBrandColorPicker()" style="width: 100%; display: flex; align-items: center; gap: 8px; justify-content: center;">
<span id="brandColorSwatch" style="width: 16px; height: 16px; border-radius: 3px; background: #1B4D3E; border: 1px solid rgba(0,0,0,0.2);"></span>
Brand Color
</button>
<p style="font-size: 11px; color: var(--text-muted); margin: 0;">
Header: 1200x400px recommended<br>
Color: Used for category bars in the app
</p>
</div>
</div> </div>
<!-- Main Canvas --> <!-- Main Canvas -->
@ -2836,6 +2855,242 @@
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}, },
// Show outline modal - full menu in hierarchical text format
showOutlineModal() {
document.getElementById('modalTitle').textContent = 'Menu Outline';
let outline = '';
const categories = this.menu.categories || [];
if (categories.length === 0) {
outline = '<div style="color: var(--text-muted); text-align: center; padding: 40px;">No menu items yet</div>';
} else {
outline = '<div class="menu-outline">';
for (const cat of categories) {
outline += `<div class="outline-category">${this.escapeHtml(cat.name)}</div>`;
const items = cat.items || [];
for (const item of items) {
const itemPrice = item.price ? `$${parseFloat(item.price).toFixed(2)}` : '';
outline += `<div class="outline-item">${this.escapeHtml(item.name)}${itemPrice ? ' <span class="outline-price">' + itemPrice + '</span>' : ''}</div>`;
// Render modifiers recursively
outline += this.renderOutlineModifiers(item.modifiers || [], 2);
}
}
outline += '</div>';
}
document.getElementById('modalBody').innerHTML = `
<style>
.menu-outline {
font-family: monospace;
font-size: 13px;
line-height: 1.6;
max-height: 60vh;
overflow-y: auto;
padding: 16px;
background: var(--bg-secondary);
border-radius: 8px;
}
.outline-category {
font-weight: bold;
font-size: 15px;
color: var(--primary);
margin-top: 16px;
margin-bottom: 4px;
text-transform: uppercase;
}
.outline-category:first-child {
margin-top: 0;
}
.outline-item {
padding-left: 20px;
color: var(--text-primary);
}
.outline-modifier {
color: var(--text-muted);
}
.outline-price {
color: var(--success);
}
.outline-modifier .outline-price {
color: var(--warning);
}
</style>
${outline}
`;
this.showModal();
},
// Helper to render modifiers recursively for outline
renderOutlineModifiers(modifiers, depth) {
if (!modifiers || modifiers.length === 0) return '';
let html = '';
const indent = '\u00A0\u00A0\u00A0\u00A0'.repeat(depth); // non-breaking spaces for indent
for (const mod of modifiers) {
const modPrice = mod.price ? `+$${parseFloat(mod.price).toFixed(2)}` : '';
html += `<div class="outline-modifier" style="padding-left: ${depth * 20}px;">${indent.substring(0, 4)}${this.escapeHtml(mod.name)}${modPrice ? ' <span class="outline-price">' + modPrice + '</span>' : ''}</div>`;
// Recurse into nested options
if (mod.options && mod.options.length > 0) {
html += this.renderOutlineModifiers(mod.options, depth + 1);
}
}
return html;
},
// Upload header image (1200x400)
uploadHeader() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
// Validate file type
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!validTypes.includes(file.type)) {
this.toast('Please select a valid image (JPG, PNG, GIF, or WebP)', 'error');
return;
}
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
this.toast('Image must be under 5MB', 'error');
return;
}
// Show loading toast
this.toast('Uploading header...', 'info');
try {
const formData = new FormData();
formData.append('header', file);
formData.append('BusinessID', this.config.businessId);
const response = await fetch(`${this.config.apiBaseUrl}/menu/uploadHeader.cfm`, {
method: 'POST',
body: formData,
credentials: 'include'
});
const data = await response.json();
if (data.OK) {
this.toast('Header uploaded successfully! Recommended size: 1200x400 pixels', 'success');
} else {
this.toast(data.MESSAGE || 'Failed to upload header', 'error');
}
} catch (err) {
console.error('Header upload error:', err);
this.toast('Failed to upload header', 'error');
}
};
input.click();
},
// Show brand color picker modal
showBrandColorPicker() {
const currentColor = this.brandColor || '#1B4D3E';
document.getElementById('modalTitle').textContent = 'Brand Color';
document.getElementById('modalBody').innerHTML = `
<p style="margin-bottom: 16px; color: var(--text-muted);">
This color is used for the category bar gradients in the customer app.
</p>
<div style="display: flex; align-items: center; gap: 16px; margin-bottom: 16px;">
<input type="color" id="brandColorInput" value="${currentColor}"
style="width: 60px; height: 40px; border: none; cursor: pointer; border-radius: 4px;">
<input type="text" id="brandColorHex" value="${currentColor}"
style="width: 100px; padding: 8px; border: 1px solid var(--border); border-radius: 4px; font-family: monospace;"
pattern="^#[0-9A-Fa-f]{6}$" maxlength="7">
<div id="brandColorPreview" style="flex: 1; height: 40px; border-radius: 4px; background: linear-gradient(to bottom, ${currentColor}44, ${currentColor}00, ${currentColor}66);"></div>
</div>
<div style="display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px;">
<button type="button" class="color-preset" data-color="#1B4D3E" style="width: 32px; height: 32px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; background: #1B4D3E;" title="Forest Green (Default)"></button>
<button type="button" class="color-preset" data-color="#2C3E50" style="width: 32px; height: 32px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; background: #2C3E50;" title="Midnight Blue"></button>
<button type="button" class="color-preset" data-color="#8B4513" style="width: 32px; height: 32px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; background: #8B4513;" title="Saddle Brown"></button>
<button type="button" class="color-preset" data-color="#800020" style="width: 32px; height: 32px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; background: #800020;" title="Burgundy"></button>
<button type="button" class="color-preset" data-color="#1A1A2E" style="width: 32px; height: 32px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; background: #1A1A2E;" title="Dark Navy"></button>
<button type="button" class="color-preset" data-color="#4A0E4E" style="width: 32px; height: 32px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; background: #4A0E4E;" title="Deep Purple"></button>
<button type="button" class="color-preset" data-color="#CC5500" style="width: 32px; height: 32px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; background: #CC5500;" title="Burnt Orange"></button>
<button type="button" class="color-preset" data-color="#355E3B" style="width: 32px; height: 32px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; background: #355E3B;" title="Hunter Green"></button>
</div>
<div style="display: flex; gap: 8px; justify-content: flex-end;">
<button type="button" class="btn btn-secondary" onclick="MenuBuilder.hideModal()">Cancel</button>
<button type="button" class="btn btn-primary" onclick="MenuBuilder.saveBrandColor()">Save Color</button>
</div>
`;
this.showModal();
// Wire up color picker sync
const colorInput = document.getElementById('brandColorInput');
const hexInput = document.getElementById('brandColorHex');
const preview = document.getElementById('brandColorPreview');
const updatePreview = (color) => {
preview.style.background = `linear-gradient(to bottom, ${color}44, ${color}00, ${color}66)`;
};
colorInput.addEventListener('input', (e) => {
hexInput.value = e.target.value.toUpperCase();
updatePreview(e.target.value);
});
hexInput.addEventListener('input', (e) => {
let val = e.target.value;
if (!val.startsWith('#')) val = '#' + val;
if (/^#[0-9A-Fa-f]{6}$/.test(val)) {
colorInput.value = val;
updatePreview(val);
}
});
// Preset buttons
document.querySelectorAll('.color-preset').forEach(btn => {
btn.addEventListener('click', () => {
const color = btn.dataset.color;
colorInput.value = color;
hexInput.value = color;
updatePreview(color);
});
});
},
// Save brand color
async saveBrandColor() {
const color = document.getElementById('brandColorHex').value.toUpperCase();
if (!/^#[0-9A-Fa-f]{6}$/.test(color)) {
this.toast('Invalid color format. Use #RRGGBB', 'error');
return;
}
try {
const response = await fetch(`${this.config.apiBaseUrl}/businesses/saveBrandColor.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ BusinessID: this.config.businessId, BrandColor: color })
});
const data = await response.json();
if (data.OK) {
this.brandColor = color;
document.getElementById('brandColorSwatch').style.background = color;
this.hideModal();
this.toast('Brand color saved!', 'success');
} else {
this.toast(data.MESSAGE || 'Failed to save color', 'error');
}
} catch (err) {
console.error('Save brand color error:', err);
this.toast('Failed to save color', 'error');
}
},
// Load menu from API // Load menu from API
async loadMenu() { async loadMenu() {
try { try {
@ -2856,6 +3111,12 @@
console.log('[MenuBuilder] Menu categories:', this.menu.categories?.length || 0); console.log('[MenuBuilder] Menu categories:', this.menu.categories?.length || 0);
// Store templates from API (default to empty array if not provided) // Store templates from API (default to empty array if not provided)
this.templates = data.TEMPLATES || []; this.templates = data.TEMPLATES || [];
// Load brand color if set
if (data.BRANDCOLOR && data.BRANDCOLOR.length > 0) {
this.brandColor = data.BRANDCOLOR;
const swatch = document.getElementById('brandColorSwatch');
if (swatch) swatch.style.background = data.BRANDCOLOR;
}
this.renderTemplateLibrary(); this.renderTemplateLibrary();
this.render(); this.render();
} else { } else {

View file

@ -4,6 +4,7 @@
--primary: #6366f1; --primary: #6366f1;
--primary-dark: #4f46e5; --primary-dark: #4f46e5;
--primary-light: #818cf8; --primary-light: #818cf8;
--primary-hover: #4f46e5;
--success: #22c55e; --success: #22c55e;
--warning: #f59e0b; --warning: #f59e0b;
--danger: #ef4444; --danger: #ef4444;
@ -23,6 +24,13 @@
--radius: 8px; --radius: 8px;
--shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06); --shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06);
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05); --shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05);
/* Menu Builder variables */
--bg-card: #ffffff;
--bg-secondary: var(--gray-50);
--border-color: var(--gray-200);
--text-primary: var(--gray-900);
--text-muted: var(--gray-500);
} }
* { * {
@ -555,6 +563,70 @@ body {
gap: 24px; gap: 24px;
} }
/* Beacons Page Layout */
.beacons-layout {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
@media (max-width: 1200px) {
.beacons-layout {
grid-template-columns: 1fr;
}
}
.list-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.list-group-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--gray-50);
border-radius: var(--radius);
transition: background 0.2s;
}
.list-group-item:hover {
background: var(--gray-100);
}
.list-group-item .item-info {
flex: 1;
}
.list-group-item .item-name {
font-weight: 500;
color: var(--gray-800);
}
.list-group-item .item-detail {
font-size: 12px;
color: var(--gray-500);
margin-top: 2px;
}
.list-group-item .item-actions {
display: flex;
gap: 8px;
}
.list-group-item .item-actions button {
padding: 4px 8px;
font-size: 12px;
}
.empty-state {
text-align: center;
color: var(--gray-500);
padding: 40px 20px;
}
/* Stripe Status */ /* Stripe Status */
.stripe-status { .stripe-status {
display: flex; display: flex;

View file

@ -225,6 +225,9 @@ const Portal = {
case 'team': case 'team':
await this.loadTeam(); await this.loadTeam();
break; break;
case 'beacons':
await this.loadBeaconsPage();
break;
case 'settings': case 'settings':
await this.loadSettings(); await this.loadSettings();
break; break;
@ -652,6 +655,9 @@ const Portal = {
async loadSettings() { async loadSettings() {
console.log('[Portal] Loading settings...'); console.log('[Portal] Loading settings...');
// Load states dropdown first
await this.loadStatesDropdown();
// Load business info // Load business info
await this.loadBusinessInfo(); await this.loadBusinessInfo();
@ -659,6 +665,24 @@ const Portal = {
await this.checkStripeStatus(); await this.checkStripeStatus();
}, },
// Load states for dropdown
async loadStatesDropdown() {
try {
const response = await fetch(`${this.config.apiBaseUrl}/addresses/states.cfm`);
const data = await response.json();
if (data.OK && data.STATES) {
const select = document.getElementById('settingState');
select.innerHTML = '<option value="">--</option>';
data.STATES.forEach(state => {
select.innerHTML += `<option value="${state.Abbr}">${state.Abbr}</option>`;
});
}
} catch (err) {
console.error('[Portal] Error loading states:', err);
}
},
// Load business info for settings // Load business info for settings
async loadBusinessInfo() { async loadBusinessInfo() {
try { try {
@ -671,17 +695,172 @@ const Portal = {
if (data.OK && data.BUSINESS) { if (data.OK && data.BUSINESS) {
const biz = data.BUSINESS; const biz = data.BUSINESS;
this.currentBusiness = biz;
// Populate form fields
document.getElementById('settingBusinessName').value = biz.BusinessName || ''; document.getElementById('settingBusinessName').value = biz.BusinessName || '';
document.getElementById('settingDescription').value = biz.BusinessDescription || '';
document.getElementById('settingAddress').value = biz.BusinessAddress || '';
document.getElementById('settingPhone').value = biz.BusinessPhone || ''; document.getElementById('settingPhone').value = biz.BusinessPhone || '';
document.getElementById('settingEmail').value = biz.BusinessEmail || ''; document.getElementById('settingAddressLine1').value = biz.AddressLine1 || '';
document.getElementById('settingCity').value = biz.AddressCity || '';
document.getElementById('settingState').value = biz.AddressState || '';
document.getElementById('settingZip').value = biz.AddressZip || '';
// Render hours editor
this.renderHoursEditor(biz.BusinessHoursDetail || []);
} }
} catch (err) { } catch (err) {
console.error('[Portal] Error loading business info:', err); console.error('[Portal] Error loading business info:', err);
} }
}, },
// Render hours editor
renderHoursEditor(hours) {
const container = document.getElementById('hoursEditor');
const dayNames = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const dayIds = [1, 2, 3, 4, 5, 6, 7]; // Monday=1 through Sunday=7
// Create a map of existing hours by day ID
const hoursMap = {};
hours.forEach(h => {
hoursMap[h.dayId] = h;
});
let html = '<div class="hours-grid" style="display:flex;flex-direction:column;gap:12px;">';
dayIds.forEach((dayId, idx) => {
const dayName = dayNames[idx];
const existing = hoursMap[dayId];
const isClosed = !existing;
const openTime = existing ? this.formatTimeFor24Input(existing.open) : '09:00';
const closeTime = existing ? this.formatTimeFor24Input(existing.close) : '17:00';
html += `
<div class="hours-row" style="display:grid;grid-template-columns:100px 1fr 1fr auto;gap:12px;align-items:center;">
<label style="font-weight:500;">${dayName}</label>
<input type="time" id="hours_open_${dayId}" value="${openTime}" class="form-input" ${isClosed ? 'disabled' : ''} style="padding:8px;">
<input type="time" id="hours_close_${dayId}" value="${closeTime}" class="form-input" ${isClosed ? 'disabled' : ''} style="padding:8px;">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
<input type="checkbox" id="hours_closed_${dayId}" ${isClosed ? 'checked' : ''} onchange="Portal.toggleHoursDay(${dayId})">
Closed
</label>
</div>
`;
});
html += '</div>';
container.innerHTML = html;
},
// Helper to convert "7:00 AM" format to "07:00" for time input
formatTimeFor24Input(timeStr) {
if (!timeStr) return '09:00';
// If already in 24h format (HH:MM), return as-is
if (/^\d{2}:\d{2}$/.test(timeStr)) return timeStr;
// Parse "7:00 AM" or "7:30 PM" format
const match = timeStr.match(/(\d{1,2}):(\d{2})\s*(AM|PM)/i);
if (match) {
let hours = parseInt(match[1]);
const minutes = match[2];
const ampm = match[3].toUpperCase();
if (ampm === 'PM' && hours < 12) hours += 12;
if (ampm === 'AM' && hours === 12) hours = 0;
return `${String(hours).padStart(2, '0')}:${minutes}`;
}
return timeStr;
},
// Toggle hours day closed/open
toggleHoursDay(dayId) {
const isClosed = document.getElementById(`hours_closed_${dayId}`).checked;
document.getElementById(`hours_open_${dayId}`).disabled = isClosed;
document.getElementById(`hours_close_${dayId}`).disabled = isClosed;
},
// Save business info
async saveBusinessInfo(event) {
event.preventDefault();
const btn = document.getElementById('saveBusinessBtn');
const originalText = btn.textContent;
btn.textContent = 'Saving...';
btn.disabled = true;
try {
const response = await fetch(`${this.config.apiBaseUrl}/businesses/update.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
BusinessID: this.config.businessId,
BusinessName: document.getElementById('settingBusinessName').value,
BusinessPhone: document.getElementById('settingPhone').value,
AddressLine1: document.getElementById('settingAddressLine1').value,
City: document.getElementById('settingCity').value,
State: document.getElementById('settingState').value,
Zip: document.getElementById('settingZip').value
})
});
const data = await response.json();
if (data.OK) {
this.showToast('Business info saved!', 'success');
// Reload to refresh sidebar etc
await this.loadBusinessInfo();
} else {
this.showToast(data.ERROR || 'Failed to save', 'error');
}
} catch (err) {
console.error('[Portal] Error saving business info:', err);
this.showToast('Error saving business info', 'error');
} finally {
btn.textContent = originalText;
btn.disabled = false;
}
},
// Save hours
async saveHours() {
const dayIds = [1, 2, 3, 4, 5, 6, 7];
const hours = [];
dayIds.forEach(dayId => {
const isClosed = document.getElementById(`hours_closed_${dayId}`).checked;
if (!isClosed) {
hours.push({
dayId: dayId,
open: document.getElementById(`hours_open_${dayId}`).value,
close: document.getElementById(`hours_close_${dayId}`).value
});
}
});
try {
const response = await fetch(`${this.config.apiBaseUrl}/businesses/updateHours.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
BusinessID: this.config.businessId,
Hours: hours
})
});
const data = await response.json();
if (data.OK) {
this.showToast('Hours saved!', 'success');
} else {
this.showToast(data.ERROR || 'Failed to save hours', 'error');
}
} catch (err) {
console.error('[Portal] Error saving hours:', err);
this.showToast('Error saving hours', 'error');
}
},
// Check Stripe Connect status // Check Stripe Connect status
async checkStripeStatus() { async checkStripeStatus() {
const statusContainer = document.getElementById('stripeStatus'); const statusContainer = document.getElementById('stripeStatus');
@ -1097,6 +1276,485 @@ const Portal = {
this.toast(`Invitation sent to ${email}`, 'success'); this.toast(`Invitation sent to ${email}`, 'success');
this.closeModal(); this.closeModal();
}); });
},
// ========== BEACONS PAGE ==========
beacons: [],
servicePoints: [],
assignments: [],
// Load beacons page data
async loadBeaconsPage() {
await Promise.all([
this.loadBeacons(),
this.loadServicePoints(),
this.loadAssignments()
]);
},
// Load beacons list
async loadBeacons() {
const container = document.getElementById('beaconsList');
try {
const response = await fetch(`${this.config.apiBaseUrl}/beacons/list.cfm`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Token': this.config.token,
'X-Business-ID': this.config.businessId
},
body: JSON.stringify({ onlyActive: true })
});
const data = await response.json();
if (data.OK) {
this.beacons = data.BEACONS || [];
this.renderBeaconsList();
} else {
console.error('[Portal] Beacons API error:', data.ERROR);
container.innerHTML = `<div class="empty-state">Error: ${data.ERROR || 'Unknown error'}</div>`;
}
} catch (err) {
console.error('[Portal] Error loading beacons:', err);
container.innerHTML = `<div class="empty-state">Failed to load beacons</div>`;
}
},
// Render beacons list
renderBeaconsList() {
const container = document.getElementById('beaconsList');
if (this.beacons.length === 0) {
container.innerHTML = '<div class="empty-state">No beacons yet. Add your first beacon!</div>';
return;
}
container.innerHTML = this.beacons.map(b => `
<div class="list-group-item ${b.IsActive ? '' : 'inactive'}">
<div class="item-info">
<div class="item-name">${this.escapeHtml(b.BeaconName)}</div>
<div class="item-detail">${b.UUID || b.NamespaceId || 'No UUID'}</div>
</div>
<div class="item-actions">
<button class="btn btn-sm btn-secondary" onclick="Portal.editBeacon(${b.BeaconID})">Edit</button>
<button class="btn btn-sm btn-danger" onclick="Portal.deleteBeacon(${b.BeaconID})">Delete</button>
</div>
</div>
`).join('');
},
// Load service points list
async loadServicePoints() {
try {
const response = await fetch(`${this.config.apiBaseUrl}/servicepoints/list.cfm`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Token': this.config.token,
'X-Business-ID': this.config.businessId
},
body: JSON.stringify({ BusinessID: this.config.businessId })
});
const data = await response.json();
if (data.OK) {
this.servicePoints = data.SERVICEPOINTS || data.ServicePoints || [];
this.renderServicePointsList();
}
} catch (err) {
console.error('[Portal] Error loading service points:', err);
}
},
// Render service points list
renderServicePointsList() {
const container = document.getElementById('servicePointsList');
if (this.servicePoints.length === 0) {
container.innerHTML = '<div class="empty-state">No service points yet. Add tables, counters, etc.</div>';
return;
}
container.innerHTML = this.servicePoints.map(sp => `
<div class="list-group-item">
<div class="item-info">
<div class="item-name">${this.escapeHtml(sp.ServicePointName)}</div>
</div>
<div class="item-actions">
<button class="btn btn-sm btn-secondary" onclick="Portal.editServicePoint(${sp.ServicePointID})">Edit</button>
<button class="btn btn-sm btn-danger" onclick="Portal.deleteServicePoint(${sp.ServicePointID})">Delete</button>
</div>
</div>
`).join('');
},
// Load assignments list
async loadAssignments() {
const container = document.getElementById('assignmentsList');
try {
const response = await fetch(`${this.config.apiBaseUrl}/assignments/list.cfm`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Token': this.config.token,
'X-Business-ID': this.config.businessId
},
body: JSON.stringify({})
});
const data = await response.json();
if (data.OK) {
this.assignments = data.ASSIGNMENTS || [];
this.renderAssignmentsList();
} else {
console.error('[Portal] Assignments API error:', data.ERROR);
container.innerHTML = `<div class="empty-state">Error: ${data.ERROR || 'Unknown error'}</div>`;
}
} catch (err) {
console.error('[Portal] Error loading assignments:', err);
container.innerHTML = `<div class="empty-state">Failed to load assignments</div>`;
}
},
// Render assignments list
renderAssignmentsList() {
const container = document.getElementById('assignmentsList');
if (this.assignments.length === 0) {
container.innerHTML = '<div class="empty-state">No assignments yet. Link beacons to service points.</div>';
return;
}
container.innerHTML = this.assignments.map(a => `
<div class="list-group-item">
<div class="item-info">
<div class="item-name">${this.escapeHtml(a.BeaconName)} ${this.escapeHtml(a.ServicePointName)}</div>
<div class="item-detail">${a.lt_Beacon_Businesses_ServicePointNotes || ''}</div>
</div>
<div class="item-actions">
<button class="btn btn-sm btn-danger" onclick="Portal.deleteAssignment(${a.lt_Beacon_Businesses_ServicePointID})">Remove</button>
</div>
</div>
`).join('');
},
// Show beacon modal (add/edit)
showBeaconModal(beaconId = null) {
const isEdit = beaconId !== null;
const beacon = isEdit ? this.beacons.find(b => b.BeaconID === beaconId) : {};
document.getElementById('modalTitle').textContent = isEdit ? 'Edit Beacon' : 'Add Beacon';
document.getElementById('modalBody').innerHTML = `
<form id="beaconForm" class="form">
<input type="hidden" id="beaconId" value="${beaconId || ''}">
<div class="form-group">
<label>Beacon Name</label>
<input type="text" id="beaconName" class="form-input" value="${this.escapeHtml(beacon.BeaconName || '')}" required>
</div>
<div class="form-group">
<label>UUID</label>
<input type="text" id="beaconUUID" class="form-input" value="${beacon.UUID || ''}" placeholder="e.g., FDA50693-A4E2-4FB1-AFCF-C6EB07647825">
</div>
<div class="form-group">
<label>
<input type="checkbox" id="beaconIsActive" ${beacon.IsActive !== 0 ? 'checked' : ''}> Active
</label>
</div>
<button type="submit" class="btn btn-primary">${isEdit ? 'Save Changes' : 'Add Beacon'}</button>
</form>
`;
this.showModal();
document.getElementById('beaconForm').addEventListener('submit', (e) => {
e.preventDefault();
this.saveBeacon();
});
},
// Save beacon
async saveBeacon() {
const beaconId = document.getElementById('beaconId').value;
const payload = {
BeaconName: document.getElementById('beaconName').value,
UUID: document.getElementById('beaconUUID').value,
IsActive: document.getElementById('beaconIsActive').checked
};
if (beaconId) {
payload.BeaconID = parseInt(beaconId);
}
try {
const response = await fetch(`${this.config.apiBaseUrl}/beacons/save.cfm`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Token': this.config.token,
'X-Business-ID': this.config.businessId
},
body: JSON.stringify(payload)
});
const data = await response.json();
if (data.OK) {
this.toast('Beacon saved!', 'success');
this.closeModal();
await this.loadBeacons();
} else {
this.toast(data.ERROR || 'Failed to save beacon', 'error');
}
} catch (err) {
console.error('[Portal] Error saving beacon:', err);
this.toast('Error saving beacon', 'error');
}
},
// Edit beacon
editBeacon(beaconId) {
this.showBeaconModal(beaconId);
},
// Delete beacon
async deleteBeacon(beaconId) {
if (!confirm('Are you sure you want to deactivate this beacon?')) return;
try {
const response = await fetch(`${this.config.apiBaseUrl}/beacons/delete.cfm`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Token': this.config.token,
'X-Business-ID': this.config.businessId
},
body: JSON.stringify({ BeaconID: beaconId })
});
const data = await response.json();
if (data.OK) {
this.toast('Beacon deactivated', 'success');
await this.loadBeacons();
} else {
this.toast(data.ERROR || 'Failed to delete beacon', 'error');
}
} catch (err) {
console.error('[Portal] Error deleting beacon:', err);
this.toast('Error deleting beacon', 'error');
}
},
// Show service point modal (add/edit)
showServicePointModal(servicePointId = null) {
const isEdit = servicePointId !== null;
const sp = isEdit ? this.servicePoints.find(s => s.ServicePointID === servicePointId) : {};
document.getElementById('modalTitle').textContent = isEdit ? 'Edit Service Point' : 'Add Service Point';
document.getElementById('modalBody').innerHTML = `
<form id="servicePointForm" class="form">
<input type="hidden" id="servicePointId" value="${servicePointId || ''}">
<div class="form-group">
<label>Name</label>
<input type="text" id="servicePointName" class="form-input" value="${this.escapeHtml(sp.ServicePointName || '')}" required placeholder="e.g., Table 1, Counter A">
</div>
<div class="form-group">
<label>Code (optional)</label>
<input type="text" id="servicePointCode" class="form-input" value="${sp.ServicePointCode || ''}" placeholder="Short code for display">
</div>
<button type="submit" class="btn btn-primary">${isEdit ? 'Save Changes' : 'Add Service Point'}</button>
</form>
`;
this.showModal();
document.getElementById('servicePointForm').addEventListener('submit', (e) => {
e.preventDefault();
this.saveServicePoint();
});
},
// Save service point
async saveServicePoint() {
const spId = document.getElementById('servicePointId').value;
const payload = {
ServicePointName: document.getElementById('servicePointName').value,
ServicePointCode: document.getElementById('servicePointCode').value
};
if (spId) {
payload.ServicePointID = parseInt(spId);
}
try {
const response = await fetch(`${this.config.apiBaseUrl}/servicepoints/save.cfm`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Token': this.config.token,
'X-Business-ID': this.config.businessId
},
body: JSON.stringify(payload)
});
const data = await response.json();
if (data.OK) {
this.toast('Service point saved!', 'success');
this.closeModal();
await this.loadServicePoints();
} else {
this.toast(data.ERROR || 'Failed to save service point', 'error');
}
} catch (err) {
console.error('[Portal] Error saving service point:', err);
this.toast('Error saving service point', 'error');
}
},
// Edit service point
editServicePoint(servicePointId) {
this.showServicePointModal(servicePointId);
},
// Delete service point
async deleteServicePoint(servicePointId) {
if (!confirm('Are you sure you want to deactivate this service point?')) return;
try {
const response = await fetch(`${this.config.apiBaseUrl}/servicepoints/delete.cfm`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Token': this.config.token,
'X-Business-ID': this.config.businessId
},
body: JSON.stringify({ ServicePointID: servicePointId })
});
const data = await response.json();
if (data.OK) {
this.toast('Service point deactivated', 'success');
await this.loadServicePoints();
} else {
this.toast(data.ERROR || 'Failed to delete service point', 'error');
}
} catch (err) {
console.error('[Portal] Error deleting service point:', err);
this.toast('Error deleting service point', 'error');
}
},
// Show assignment modal
showAssignmentModal() {
// Filter out beacons and service points that are already assigned
const assignedBeaconIds = new Set(this.assignments.map(a => a.BeaconID));
const assignedSPIds = new Set(this.assignments.map(a => a.ServicePointID));
const availableBeacons = this.beacons.filter(b => b.IsActive && !assignedBeaconIds.has(b.BeaconID));
const availableSPs = this.servicePoints.filter(sp => !assignedSPIds.has(sp.ServicePointID));
if (availableBeacons.length === 0) {
this.toast('No unassigned beacons available', 'warning');
return;
}
if (availableSPs.length === 0) {
this.toast('No unassigned service points available', 'warning');
return;
}
document.getElementById('modalTitle').textContent = 'Assign Beacon to Service Point';
document.getElementById('modalBody').innerHTML = `
<form id="assignmentForm" class="form">
<div class="form-group">
<label>Beacon</label>
<select id="assignBeaconId" class="form-input" required>
<option value="">Select a beacon...</option>
${availableBeacons.map(b => `<option value="${b.BeaconID}">${this.escapeHtml(b.BeaconName)}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label>Service Point</label>
<select id="assignServicePointId" class="form-input" required>
<option value="">Select a service point...</option>
${availableSPs.map(sp => `<option value="${sp.ServicePointID}">${this.escapeHtml(sp.ServicePointName)}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label>Notes (optional)</label>
<input type="text" id="assignNotes" class="form-input" maxlength="255" placeholder="Any notes about this assignment">
</div>
<button type="submit" class="btn btn-primary">Create Assignment</button>
</form>
`;
this.showModal();
document.getElementById('assignmentForm').addEventListener('submit', (e) => {
e.preventDefault();
this.saveAssignment();
});
},
// Save assignment
async saveAssignment() {
const payload = {
BeaconID: parseInt(document.getElementById('assignBeaconId').value),
ServicePointID: parseInt(document.getElementById('assignServicePointId').value),
Notes: document.getElementById('assignNotes').value
};
try {
const response = await fetch(`${this.config.apiBaseUrl}/assignments/save.cfm`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Token': this.config.token,
'X-Business-ID': this.config.businessId
},
body: JSON.stringify(payload)
});
const data = await response.json();
if (data.OK) {
this.toast('Assignment created!', 'success');
this.closeModal();
await this.loadAssignments();
} else {
this.toast(data.ERROR || 'Failed to create assignment', 'error');
}
} catch (err) {
console.error('[Portal] Error saving assignment:', err);
this.toast('Error creating assignment', 'error');
}
},
// Delete assignment
async deleteAssignment(assignmentId) {
if (!confirm('Are you sure you want to remove this assignment?')) return;
try {
const response = await fetch(`${this.config.apiBaseUrl}/assignments/delete.cfm`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Token': this.config.token,
'X-Business-ID': this.config.businessId
},
body: JSON.stringify({ lt_Beacon_Businesses_ServicePointID: assignmentId })
});
const data = await response.json();
if (data.OK) {
this.toast('Assignment removed', 'success');
await this.loadAssignments();
} else {
this.toast(data.ERROR || 'Failed to remove assignment', 'error');
}
} catch (err) {
console.error('[Portal] Error deleting assignment:', err);
this.toast('Error removing assignment', 'error');
}
},
// Escape HTML helper
escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>"']/g, m => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[m]));
} }
}; };

View file

@ -974,9 +974,11 @@
}); });
function initializeConfig() { function initializeConfig() {
// Get business ID from URL or localStorage (optional for new business setup) // Get business ID from URL only - don't use localStorage for wizard
// The wizard is for creating NEW businesses, so we shouldn't default to an existing one
// Only use bid from URL if explicitly editing an existing business
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
config.businessId = urlParams.get('bid') || localStorage.getItem('payfrit_portal_business') || null; config.businessId = urlParams.get('bid') || null;
// Determine API base URL // Determine API base URL
const basePath = window.location.pathname.includes('/biz.payfrit.com/') const basePath = window.location.pathname.includes('/biz.payfrit.com/')
@ -1211,110 +1213,102 @@
'sunday': 6, 'sun': 6 'sunday': 6, 'sun': 6
}; };
// Initialize all days as closed // Initialize all days as open with defaults
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {
schedule.push({ open: '09:00', close: '17:00', closed: true }); schedule.push({ open: '09:00', close: '17:00', closed: false });
} }
if (!hoursText || !hoursText.trim()) { if (!hoursText || !hoursText.trim()) {
return schedule; return schedule;
} }
hoursText = hoursText.toLowerCase(); // Try to extract a time range
// Extract time range - find first time pattern
const timePattern = /(\d{1,2}):?(\d{2})?\s*(am|pm|a\.m\.|p\.m\.)?\s*(?:to|[-])\s*(\d{1,2}):?(\d{2})?\s*(am|pm|a\.m\.|p\.m\.)?/i; const timePattern = /(\d{1,2}):?(\d{2})?\s*(am|pm|a\.m\.|p\.m\.)?\s*(?:to|[-])\s*(\d{1,2}):?(\d{2})?\s*(am|pm|a\.m\.|p\.m\.)?/i;
const timeMatch = hoursText.match(timePattern); const timeMatch = hoursText.match(timePattern);
let openTime = '09:00';
let closeTime = '17:00';
if (timeMatch) { if (timeMatch) {
const convertTo24Hour = (hour, minute, ampm) => { const convertTo24Hour = (hour, minute, ampm) => {
let h = parseInt(hour); let h = parseInt(hour);
const m = minute ? parseInt(minute) : 0; const m = minute ? parseInt(minute) : 0;
if (ampm) { if (ampm) {
ampm = ampm.replace(/\./g, '').toLowerCase(); ampm = ampm.replace(/\./g, '').toLowerCase();
if (ampm === 'pm' && h < 12) h += 12; if (ampm === 'pm' && h < 12) h += 12;
if (ampm === 'am' && h === 12) h = 0; if (ampm === 'am' && h === 12) h = 0;
} }
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}; };
openTime = convertTo24Hour(timeMatch[1], timeMatch[2], timeMatch[3]); const openTime = convertTo24Hour(timeMatch[1], timeMatch[2], timeMatch[3]);
closeTime = convertTo24Hour(timeMatch[4], timeMatch[5], timeMatch[6]); const closeTime = convertTo24Hour(timeMatch[4], timeMatch[5], timeMatch[6]);
}
// Find which days are mentioned // Apply extracted time to all days
const dayPattern = /(mon|monday|tue|tuesday|tues|wed|wednesday|thu|thursday|thur|thurs|fri|friday|sat|saturday|sun|sunday)/gi;
const dayMatches = hoursText.match(dayPattern) || [];
if (dayMatches.length === 0) {
// No days mentioned, assume all days open
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {
schedule[i] = { open: openTime, close: closeTime, closed: false }; schedule[i] = { open: openTime, close: closeTime, closed: false };
} }
return schedule;
} }
// Process day ranges and individual days
const daysSet = new Set();
let i = 0;
while (i < dayMatches.length) {
const currentDay = dayMatches[i].toLowerCase();
const currentDayIdx = dayMap[currentDay];
// Check if next day forms a range
if (i < dayMatches.length - 1) {
const nextDay = dayMatches[i + 1].toLowerCase();
const betweenPattern = new RegExp(currentDay + '\\s*[-]\\s*' + nextDay, 'i');
if (betweenPattern.test(hoursText)) {
// It's a range
const nextDayIdx = dayMap[nextDay];
for (let d = currentDayIdx; d <= nextDayIdx; d++) {
daysSet.add(d);
}
i += 2;
continue;
}
}
// Individual day
daysSet.add(currentDayIdx);
i++;
}
// Apply times to the days found
daysSet.forEach(dayIdx => {
schedule[dayIdx] = { open: openTime, close: closeTime, closed: false };
});
return schedule; return schedule;
} }
// Toggle day closed/open // Toggle day closed/open - time inputs always stay editable
function toggleDayClosed(dayIdx) { function toggleDayClosed(dayIdx) {
const closedCheckbox = document.getElementById(`closed_${dayIdx}`); // No-op: time inputs stay editable so user can set hours before unchecking "Closed"
const openInput = document.getElementById(`open_${dayIdx}`); }
const closeInput = document.getElementById(`close_${dayIdx}`);
if (closedCheckbox.checked) { // Check for duplicate businesses before creating a new one
openInput.disabled = true; async function checkForDuplicateBusiness(biz) {
closeInput.disabled = true; try {
} else { const response = await fetch(`${config.apiBaseUrl}/setup/checkDuplicate.cfm`, {
openInput.disabled = false; method: 'POST',
closeInput.disabled = false; headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: biz.name || '',
addressLine1: biz.addressLine1 || '',
city: biz.city || '',
state: biz.state || '',
zip: biz.zip || ''
})
});
const result = await response.json();
if (result.OK && result.duplicates && result.duplicates.length > 0) {
// Show duplicate warning
const dupList = result.duplicates.map(d =>
`<li><strong>${d.BusinessName}</strong> (ID: ${d.BusinessID})<br><small>${d.Address || 'No address'}</small></li>`
).join('');
addMessage('ai', `
<div style="background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 16px; margin-bottom: 16px;">
<p style="margin: 0 0 12px 0; font-weight: 600; color: #92400e;">
Possible duplicate found!
</p>
<p style="margin: 0 0 12px 0; color: #78350f;">
A business with a similar name or address already exists:
</p>
<ul style="margin: 0 0 16px 0; padding-left: 20px; color: #78350f;">
${dupList}
</ul>
<p style="margin: 0; color: #78350f; font-size: 14px;">
You can continue to create a new business, or go back and select the existing one from the portal.
</p>
</div>
`);
}
} catch (error) {
console.error('Error checking for duplicates:', error);
// Don't block the wizard if duplicate check fails
} }
} }
// Step 1: Business Info // Step 1: Business Info
function showBusinessInfoStep() { async function showBusinessInfoStep() {
updateProgress(2); updateProgress(2);
const biz = config.extractedData.business || {}; const biz = config.extractedData.business || {};
// Check for duplicate businesses before showing the form
await checkForDuplicateBusiness(biz);
console.log('Business data:', biz); console.log('Business data:', biz);
// Parse address into components if it's a single string // Parse address into components if it's a single string
@ -1348,8 +1342,8 @@
// What's left is AddressLine1 + City // What's left is AddressLine1 + City
// Try to split by comma first // Try to split by comma first
if (remaining.includes(',')) { if (remaining.includes(',')) {
const parts = remaining.split(',').map(p => p.trim()); const parts = remaining.split(',').map(p => p.trim()).filter(p => p.length > 0);
addressLine1 = parts[0]; addressLine1 = parts[0] || '';
city = parts.slice(1).join(', '); city = parts.slice(1).join(', ');
} else { } else {
// No comma - try to find last sequence of words as city // No comma - try to find last sequence of words as city
@ -1368,6 +1362,9 @@
addressLine1 = remaining; addressLine1 = remaining;
} }
} }
// Clean up any trailing commas, periods, or whitespace from city
city = city.replace(/[,.\s]+$/, '').trim();
} }
console.log('Parsed address:', { addressLine1, city, state, zip }); console.log('Parsed address:', { addressLine1, city, state, zip });
@ -1422,13 +1419,11 @@
<td style="padding:8px;font-weight:500;">${day}</td> <td style="padding:8px;font-weight:500;">${day}</td>
<td style="padding:8px;"> <td style="padding:8px;">
<input type="time" id="open_${idx}" value="${dayData.open}" <input type="time" id="open_${idx}" value="${dayData.open}"
style="padding:4px 8px;border:1px solid var(--gray-300);border-radius:4px;font-size:14px;" style="padding:4px 8px;border:1px solid var(--gray-300);border-radius:4px;font-size:14px;">
${dayData.closed ? 'disabled' : ''}>
</td> </td>
<td style="padding:8px;"> <td style="padding:8px;">
<input type="time" id="close_${idx}" value="${dayData.close}" <input type="time" id="close_${idx}" value="${dayData.close}"
style="padding:4px 8px;border:1px solid var(--gray-300);border-radius:4px;font-size:14px;" style="padding:4px 8px;border:1px solid var(--gray-300);border-radius:4px;font-size:14px;">
${dayData.closed ? 'disabled' : ''}>
</td> </td>
<td style="padding:8px;text-align:center;"> <td style="padding:8px;text-align:center;">
<input type="checkbox" id="closed_${idx}" ${dayData.closed ? 'checked' : ''} <input type="checkbox" id="closed_${idx}" ${dayData.closed ? 'checked' : ''}
@ -1454,7 +1449,7 @@
} }
function confirmBusinessInfo() { function confirmBusinessInfo() {
// Collect hours from the table // Collect hours from the table - only include non-closed days
const hoursSchedule = []; const hoursSchedule = [];
const dayNames = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; const dayNames = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
@ -1463,12 +1458,15 @@
const closeTime = document.getElementById(`close_${i}`).value; const closeTime = document.getElementById(`close_${i}`).value;
const isClosed = document.getElementById(`closed_${i}`).checked; const isClosed = document.getElementById(`closed_${i}`).checked;
hoursSchedule.push({ // Only add days that are not closed
day: dayNames[i], if (!isClosed) {
dayId: i + 1, // 1=Monday, 7=Sunday hoursSchedule.push({
open: isClosed ? openTime : openTime, // If closed, both times will be the same day: dayNames[i],
close: isClosed ? openTime : closeTime // Set close = open when closed dayId: i + 1, // 1=Monday, 7=Sunday
}); open: openTime,
close: closeTime
});
}
} }
// Update stored data with any edits // Update stored data with any edits