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:
parent
946c2ebdf0
commit
a318b8668f
6 changed files with 225 additions and 164 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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">×</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">
|
||||
|
|
|
|||
Reference in a new issue