Fix cart/tax issues and add menu item thumbnails

- uploadItemPhoto: Add EXIF orientation fix, generate thumb/medium/full sizes
- getActiveCart: Disable old cart lookup (always returns no cart)
- getOrCreateCart: Always create fresh cart instead of reusing old ones
- getCart: Add IsDeleted filter, calculate subtotal/tax/total server-side
- getDetail: Remove default 8.25% tax rate (business must configure)
- menu-builder: Add lightbox for full-size images, use thumbnail URLs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-02-08 14:22:54 -08:00
parent 946c2ebdf0
commit a318b8668f
6 changed files with 225 additions and 164 deletions

View file

@ -15,6 +15,71 @@ if (structKeyExists(form, "ItemID") && isNumeric(form.ItemID) && form.ItemID GT
if (itemId LTE 0) {
apiAbort({ "OK": false, "ERROR": "missing_itemid", "MESSAGE": "ItemID is required" });
}
// Fix EXIF orientation - rotate image based on EXIF Orientation tag
function fixOrientation(img) {
try {
exif = imageGetEXIFMetadata(img);
if (structKeyExists(exif, "Orientation")) {
orientation = val(exif.Orientation);
switch(orientation) {
case 3: imageRotate(img, 180); break;
case 6: imageRotate(img, 90); break;
case 8: imageRotate(img, 270); break;
// 2,4,5,7 involve flips which are rare, skip for now
}
}
} catch (any e) {
// No EXIF or can't read it - that's fine
}
return img;
}
// Resize image maintaining aspect ratio, fitting within maxSize box
function resizeToFit(img, maxSize) {
w = imageGetWidth(img);
h = imageGetHeight(img);
if (w <= maxSize && h <= maxSize) return img;
if (w > h) {
newW = maxSize;
newH = int(h * (maxSize / w));
} else {
newH = maxSize;
newW = int(w * (maxSize / h));
}
imageResize(img, newW, newH, "highQuality");
return img;
}
// Create square thumbnail (center crop)
function createSquareThumb(img, size) {
thumb = imageCopy(img, 0, 0, imageGetWidth(img), imageGetHeight(img));
w = imageGetWidth(thumb);
h = imageGetHeight(thumb);
// Resize so smallest dimension equals size
if (w > h) {
imageResize(thumb, "", size, "highQuality");
} else {
imageResize(thumb, size, "", "highQuality");
}
// Center crop to square
w = imageGetWidth(thumb);
h = imageGetHeight(thumb);
if (w > h) {
x = int((w - h) / 2);
imageCrop(thumb, x, 0, h, h);
} else if (h > w) {
y = int((h - w) / 2);
imageCrop(thumb, 0, y, w, w);
}
// Final resize to exact size
imageResize(thumb, size, size, "highQuality");
return thumb;
}
</cfscript>
<!--- Check if file was uploaded --->
@ -35,30 +100,54 @@ if (itemId LTE 0) {
<cfabort>
</cfif>
<!--- Convert HEIC/HEIF to jpg for broader compatibility --->
<cfif actualExt EQ "heic" OR actualExt EQ "heif">
<cfset actualExt = "jpg">
</cfif>
<!--- Delete old photo if exists (any extension) --->
<!--- Delete old photos and thumbnails if they exist --->
<cfscript>
for (ext in listToArray("jpg,jpeg,gif,png,webp,heic,heif")) {
oldFile = "#itemsDir#/#itemId#.#ext#";
if (fileExists(oldFile)) {
try { fileDelete(oldFile); } catch (any e) {}
for (ext in listToArray("jpg,jpeg,gif,png,webp")) {
for (suffix in ["", "_thumb", "_medium"]) {
oldFile = "#itemsDir#/#itemId##suffix#.#ext#";
if (fileExists(oldFile)) {
try { fileDelete(oldFile); } catch (any e) {}
}
}
}
</cfscript>
<!--- Rename to ItemID.ext (use actualExt which may be converted from heic) --->
<cffile action="RENAME" source="#itemsDir#/#uploadResult.ServerFile#" destination="#itemsDir#/#itemId#.#actualExt#" mode="755">
<!--- Read the uploaded image and process --->
<cfscript>
uploadedFile = "#itemsDir#/#uploadResult.ServerFile#";
img = imageNew(uploadedFile);
<!--- Return success with image URL --->
// Fix EXIF orientation (portrait photos appearing landscape)
img = fixOrientation(img);
// Create thumbnail (64x64 square for list view - 2x for retina)
thumb = createSquareThumb(img, 128);
imageWrite(thumb, "#itemsDir#/#itemId#_thumb.jpg", 0.85);
// Create medium size (400px max for detail view)
medium = imageCopy(img, 0, 0, imageGetWidth(img), imageGetHeight(img));
medium = resizeToFit(medium, 400);
imageWrite(medium, "#itemsDir#/#itemId#_medium.jpg", 0.85);
// Save full size (max 1200px to keep file sizes reasonable)
img = resizeToFit(img, 1200);
imageWrite(img, "#itemsDir#/#itemId#.jpg", 0.90);
// Delete the original uploaded file
try { fileDelete(uploadedFile); } catch (any e) {}
// Add cache buster
cacheBuster = dateFormat(now(), "yyyymmdd") & timeFormat(now(), "HHmmss");
</cfscript>
<!--- Return success with image URLs --->
<cfoutput>#serializeJSON({
"OK": true,
"ERROR": "",
"MESSAGE": "Photo uploaded successfully",
"IMAGEURL": "/uploads/items/#itemId#.#actualExt#"
"IMAGEURL": "/uploads/items/#itemId#.jpg?v=#cacheBuster#",
"THUMBURL": "/uploads/items/#itemId#_thumb.jpg?v=#cacheBuster#",
"MEDIUMURL": "/uploads/items/#itemId#_medium.jpg?v=#cacheBuster#"
})#</cfoutput>
<cfcatch type="any">

View file

@ -6,93 +6,13 @@
<cfscript>
/**
* Get the user's active cart (status=0) if one exists
* Used at app startup to check if user has an existing order in progress
*
* Query params:
* UserID - the user's ID
*
* Returns the cart info including businessId, orderTypeId, and item count
* DISABLED: Always returns no cart - old carts are abandoned automatically
*/
response = { "OK": false };
try {
UserID = val(url.UserID ?: 0);
if (UserID LTE 0) {
response["ERROR"] = "missing_user";
response["MESSAGE"] = "UserID is required";
writeOutput(serializeJSON(response));
abort;
}
// Get active cart (status = 0) for this user
qCart = queryTimed("
SELECT
o.ID,
o.UUID,
o.BusinessID,
o.OrderTypeID,
o.StatusID,
o.ServicePointID,
o.AddedOn,
b.Name AS BusinessName,
b.OrderTypes AS BusinessOrderTypes,
sp.Name AS ServicePointName,
(SELECT COUNT(*)
FROM OrderLineItems oli
WHERE oli.OrderID = o.ID
AND oli.IsDeleted = 0
AND oli.ParentOrderLineItemID = 0) as ItemCount
FROM Orders o
LEFT JOIN Businesses b ON b.ID = o.BusinessID
LEFT JOIN ServicePoints sp ON sp.ID = o.ServicePointID
WHERE o.UserID = :userId
AND o.StatusID = 0
ORDER BY o.ID DESC
LIMIT 1
", {
userId: { value: UserID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
if (qCart.recordCount GT 0) {
orderTypeName = "";
switch (qCart.OrderTypeID) {
case 0: orderTypeName = "Undecided"; break;
case 1: orderTypeName = "Dine-In"; break;
case 2: orderTypeName = "Takeaway"; break;
case 3: orderTypeName = "Delivery"; break;
}
// Parse business order types (e.g., "1,2,3" -> array of ints)
businessOrderTypes = len(trim(qCart.BusinessOrderTypes)) ? qCart.BusinessOrderTypes : "1,2,3";
orderTypesArray = listToArray(businessOrderTypes, ",");
response["OK"] = true;
response["HAS_CART"] = true;
response["CART"] = {
"OrderID": val(qCart.ID),
"OrderUUID": qCart.UUID ?: "",
"BusinessID": val(qCart.BusinessID),
"BusinessName": len(trim(qCart.BusinessName)) ? qCart.BusinessName : "",
"OrderTypes": orderTypesArray,
"OrderTypeID": val(qCart.OrderTypeID),
"OrderTypeName": orderTypeName,
"ServicePointID": val(qCart.ServicePointID),
"ServicePointName": len(trim(qCart.ServicePointName)) ? qCart.ServicePointName : "",
"ItemCount": val(qCart.ItemCount),
"AddedOn": dateTimeFormat(qCart.AddedOn, "yyyy-mm-dd HH:nn:ss")
};
} else {
response["OK"] = true;
response["HAS_CART"] = false;
response["CART"] = javacast("null", "");
}
} catch (any e) {
response["ERROR"] = "server_error";
response["MESSAGE"] = e.message;
}
writeOutput(serializeJSON(response));
// Always return no active cart - users start fresh each session
writeOutput(serializeJSON({
"OK": true,
"HAS_CART": false,
"CART": javacast("null", "")
}));
</cfscript>

View file

@ -97,6 +97,7 @@
INNER JOIN Items i ON i.ID = oli.ItemID
LEFT JOIN Items parent ON parent.ID = i.ParentItemID
WHERE oli.OrderID = ?
AND oli.IsDeleted = 0
ORDER BY oli.ID
",
[ { value = OrderID, cfsqltype = "cf_sql_integer" } ],
@ -104,6 +105,7 @@
)>
<cfset rows = []>
<cfset subtotal = 0>
<cfloop query="qLI">
<cfset arrayAppend(rows, {
"OrderLineItemID": val(qLI.ID),
@ -121,8 +123,17 @@
"ItemParentName": qLI.ItemParentName ?: "",
"IsCheckedByDefault": val(qLI.IsCheckedByDefault)
})>
<!--- Add to subtotal (root items only - modifiers are included in parent price) --->
<cfif val(qLI.ParentOrderLineItemID) EQ 0>
<cfset subtotal = subtotal + (val(qLI.Price) * val(qLI.Quantity))>
</cfif>
</cfloop>
<!--- Calculate tax (no default - business must configure their rate) --->
<cfset taxAmount = subtotal * val(businessTaxRate)>
<cfset deliveryFee = val(qOrder.OrderTypeID) EQ 3 ? val(qOrder.DeliveryFee) : 0>
<cfset total = subtotal + taxAmount + deliveryFee>
<cfset apiAbort({
"OK": true,
"ERROR": "",
@ -133,9 +144,12 @@
"BusinessID": val(qOrder.BusinessID),
"DeliveryMultiplier": val(qOrder.DeliveryMultiplier),
"OrderTypeID": val(qOrder.OrderTypeID),
"DeliveryFee": val(qOrder.DeliveryFee),
"DeliveryFee": deliveryFee,
"BusinessDeliveryFee": val(businessDeliveryFee),
"TaxRate": val(businessTaxRate),
"Subtotal": subtotal,
"Tax": taxAmount,
"Total": total,
"OrderTypes": businessOrderTypesArray,
"StatusID": val(qOrder.StatusID),
"AddressID": val(qOrder.AddressID),

View file

@ -140,8 +140,8 @@ try {
subtotal += itemTotal;
}
// Calculate tax using business tax rate or default 8.25%
taxRate = isNumeric(qOrder.TaxRate) && qOrder.TaxRate > 0 ? qOrder.TaxRate : 0.0825;
// Calculate tax using business tax rate (no default - business must configure)
taxRate = isNumeric(qOrder.TaxRate) && qOrder.TaxRate > 0 ? qOrder.TaxRate : 0;
tax = subtotal * taxRate;
// Get tip from order

View file

@ -179,63 +179,7 @@
</cfif>
<cftry>
<!--- Find existing cart (StatusID=0 assumed cart) --->
<!--- Look for any active cart for this user/business - order type can be changed later --->
<cfset qFind = queryTimed(
"
SELECT ID, OrderTypeID
FROM Orders
WHERE UserID = ?
AND BusinessID = ?
AND StatusID = 0
ORDER BY ID DESC
LIMIT 1
",
[
{ value = UserID, cfsqltype = "cf_sql_integer" },
{ value = BusinessID, cfsqltype = "cf_sql_integer" }
],
{ datasource = "payfrit" }
)>
<cfif qFind.recordCount GT 0>
<!--- Always update the service point to match the current table/beacon --->
<cfif ServicePointID GT 0>
<cfset queryTimed(
"UPDATE Orders SET ServicePointID = ?, LastEditedOn = ? WHERE ID = ?",
[
{ value = ServicePointID, cfsqltype = "cf_sql_integer" },
{ value = now(), cfsqltype = "cf_sql_timestamp" },
{ value = qFind.ID, cfsqltype = "cf_sql_integer" }
],
{ datasource = "payfrit" }
)>
</cfif>
<!--- Check if cart order type differs from requested and cart is empty --->
<!--- If so, update the cart's order type to match the new flow --->
<cfif qFind.OrderTypeID NEQ OrderTypeID>
<cfset qLineItems = queryTimed(
"SELECT COUNT(*) AS ItemCount FROM OrderLineItems WHERE OrderID = ? AND IsDeleted = 0",
[ { value = qFind.ID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<!--- Only update order type if cart is empty (allows switching flows) --->
<cfif qLineItems.ItemCount EQ 0>
<cfset queryTimed(
"UPDATE Orders SET OrderTypeID = ?, LastEditedOn = ? WHERE ID = ?",
[
{ value = OrderTypeID, cfsqltype = "cf_sql_integer" },
{ value = now(), cfsqltype = "cf_sql_timestamp" },
{ value = qFind.ID, cfsqltype = "cf_sql_integer" }
],
{ datasource = "payfrit" }
)>
</cfif>
</cfif>
<cfset payload = loadCartPayload(qFind.ID)>
<cfset apiAbort(payload)>
</cfif>
<!--- Always create a fresh cart - old carts are abandoned automatically --->
<!--- Create new cart order --->
<cfset qBiz = queryTimed(

View file

@ -573,6 +573,60 @@
width: 100%;
height: 100%;
object-fit: cover;
cursor: pointer;
}
.photo-preview img:hover {
opacity: 0.9;
}
/* Lightbox for full-size images */
.lightbox-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
padding: 20px;
}
.lightbox-content {
position: relative;
max-width: 90vw;
max-height: 90vh;
}
.lightbox-content img {
max-width: 100%;
max-height: 90vh;
object-fit: contain;
border-radius: 8px;
}
.lightbox-close {
position: absolute;
top: -40px;
right: 0;
background: transparent;
border: none;
color: white;
font-size: 32px;
cursor: pointer;
padding: 0;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.lightbox-close:hover {
color: var(--primary);
}
.photo-actions {
@ -1132,6 +1186,46 @@
expandedItemId: null, // For item accordion - only one item expanded at a time
expandedModifierIds: new Set(), // Track which modifiers are expanded
// Get thumbnail URLs from base image URL
// Thumbnails are always .jpg regardless of original format
// E.g., /uploads/items/123.png -> { thumb: /uploads/items/123_thumb.jpg, medium: /uploads/items/123_medium.jpg, full: /uploads/items/123.png }
getImageUrls(baseUrl) {
if (!baseUrl) return { thumb: null, medium: null, full: null };
// Strip query string for manipulation
const qIdx = baseUrl.indexOf('?');
const query = qIdx > -1 ? baseUrl.substring(qIdx) : '';
const cleanUrl = qIdx > -1 ? baseUrl.substring(0, qIdx) : baseUrl;
// Find extension
const dotIdx = cleanUrl.lastIndexOf('.');
if (dotIdx === -1) return { thumb: baseUrl, medium: baseUrl, full: baseUrl };
const base = cleanUrl.substring(0, dotIdx);
// Thumbnails are always saved as .jpg by the server
return {
thumb: base + '_thumb.jpg' + query,
medium: base + '_medium.jpg' + query,
full: cleanUrl + query
};
},
// Show full-size image in lightbox
showLightbox(imageUrl) {
if (!imageUrl) return;
const overlay = document.createElement('div');
overlay.className = 'lightbox-overlay';
overlay.innerHTML = `
<div class="lightbox-content">
<img src="${imageUrl}" alt="Full size photo">
<button class="lightbox-close">&times;</button>
</div>
`;
overlay.onclick = (e) => {
if (e.target === overlay || e.target.classList.contains('lightbox-close')) {
overlay.remove();
}
};
document.body.appendChild(overlay);
},
// Initialize
async init() {
console.log('[MenuBuilder] Initializing...');
@ -1747,9 +1841,9 @@
${item.imageUrl ? 'Photo Added' : 'Task Pending'}
</span>` : ''}
</div>
<div class="photo-preview">
<div class="photo-preview" ${item.imageUrl ? `onclick="event.preventDefault(); MenuBuilder.showLightbox('${item.imageUrl}')"` : ''}>
${item.imageUrl ?
`<img src="${item.imageUrl}" alt="${this.escapeHtml(item.name)}">` :
`<img src="${this.getImageUrls(item.imageUrl).medium}" alt="${this.escapeHtml(item.name)}" onerror="this.onerror=null; this.src='${item.imageUrl}'" title="Tap to view full size">` :
`<span>No photo yet</span>`}
</div>
<div class="photo-actions">
@ -3919,7 +4013,7 @@
</svg>
</div>
<div class="item-image">
${item.imageUrl ? `<img src="${item.imageUrl}" alt="">` : '🍽️'}
${item.imageUrl ? `<img src="${this.getImageUrls(item.imageUrl).thumb}" alt="" onerror="this.onerror=null; this.src='${item.imageUrl}'">` : '🍽️'}
${item.photoTaskId ? `
<div class="photo-badge ${item.imageUrl ? '' : 'missing'}" title="${item.imageUrl ? 'Has photo' : 'Photo task pending'}">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">