Add menu builder required selection UI and fix portal issues

Menu Builder - Required Selections:
- Added "Selection Rules" section for modifier groups
- Required (Yes/No) dropdown to mark if customer must select an option
- Max Selections input (0 = unlimited) to limit selections
- Visual "Required" badge (red) and "Max X" badge in modifier list
- Updated saveFromBuilder.cfm to persist ItemRequiresChildSelection
  and ItemMaxNumSelectionReq to database

Portal Fixes:
- Fixed menu-builder link to include BASE_PATH for local dev
- Fixed stats.cfm to not reference non-existent Categories table
- Menu items count now uses ItemParentItemID > 0 (not ItemCategoryID)

Stripe Configuration:
- Added api/config/stripe.cfm for centralized Stripe key management
- Supports test/live mode switching
- Fee configuration variables (5% customer, 5% business, 2.9% + $0.30 card)

Payment Intent API:
- Updated createPaymentIntent.cfm with proper fee structure
- Customer pays: subtotal + tax + tip + 5% Payfrit fee + card processing
- Platform receives 10% total (5% from customer + 5% from business)
- Saves fee breakdown to order record

Beacon Management:
- Updated switchBeacons.cfm to move beacons between businesses
- Currently configured: Big Dean's (27) -> In-N-Out (17)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-01-05 01:56:12 -08:00
parent 51a80b537d
commit d225133c68
7 changed files with 361 additions and 66 deletions

View file

@ -4,8 +4,8 @@
<cfscript>
// Switch all beacons from one business to another
fromBiz = 17; // In-N-Out
toBiz = 27; // Big Dean's
fromBiz = 27; // Big Dean's
toBiz = 17; // In-N-Out
queryExecute("
UPDATE lt_Beacon_Businesses_ServicePoints

44
api/config/stripe.cfm Normal file
View file

@ -0,0 +1,44 @@
<cfscript>
/**
* Stripe Configuration
*
* This file contains Stripe API keys and settings.
* DO NOT commit this file to version control with live keys!
*
* To switch between test/live:
* - Set stripeMode = "test" or "live"
* - Keys will be selected automatically
*/
// Mode: "test" or "live"
stripeMode = "test";
// Test keys (safe to commit)
stripeTestSecretKey = "sk_test_LfbmDduJxTwbVZmvcByYmirw";
stripeTestPublishableKey = "pk_test_sPBNzSyJ9HcEPJGC7dSo8NqN";
// Live keys (DO NOT commit real values)
stripeLiveSecretKey = "sk_live_REPLACE_ME";
stripeLivePublishableKey = "pk_live_REPLACE_ME";
// Webhook secrets
stripeTestWebhookSecret = "";
stripeLiveWebhookSecret = "";
// Select active keys based on mode
if (stripeMode == "test") {
application.stripeSecretKey = stripeTestSecretKey;
application.stripePublishableKey = stripeTestPublishableKey;
application.stripeWebhookSecret = stripeTestWebhookSecret;
} else {
application.stripeSecretKey = stripeLiveSecretKey;
application.stripePublishableKey = stripeLivePublishableKey;
application.stripeWebhookSecret = stripeLiveWebhookSecret;
}
// Fee Configuration
application.payfritCustomerFeePercent = 0.05; // 5% customer pays to Payfrit
application.payfritBusinessFeePercent = 0.05; // 5% business pays to Payfrit
application.cardFeePercent = 0.029; // 2.9% Stripe fee
application.cardFeeFixed = 0.30; // $0.30 Stripe fixed fee
</cfscript>

View file

@ -211,6 +211,10 @@ try {
for (mod in item.modifiers) {
modDbId = structKeyExists(mod, "dbId") ? val(mod.dbId) : 0;
// Get selection rules (for modifier groups)
requiresSelection = (structKeyExists(mod, "requiresSelection") && mod.requiresSelection) ? 1 : 0;
maxSelections = structKeyExists(mod, "maxSelections") ? val(mod.maxSelections) : 0;
// Check if this is a template reference
if (structKeyExists(mod, "isTemplate") && mod.isTemplate && modDbId > 0) {
// Create template link
@ -223,6 +227,18 @@ try {
templateID: modDbId,
sortOrder: modSortOrder
});
// Also update the template's selection rules
queryExecute("
UPDATE Items
SET ItemRequiresChildSelection = :requiresSelection,
ItemMaxNumSelectionReq = :maxSelections
WHERE ItemID = :modID
", {
modID: modDbId,
requiresSelection: requiresSelection,
maxSelections: maxSelections
});
} else if (modDbId > 0) {
// Update existing direct modifier
queryExecute("
@ -230,24 +246,30 @@ try {
SET ItemName = :name,
ItemPrice = :price,
ItemIsCheckedByDefault = :isDefault,
ItemSortOrder = :sortOrder
ItemSortOrder = :sortOrder,
ItemRequiresChildSelection = :requiresSelection,
ItemMaxNumSelectionReq = :maxSelections
WHERE ItemID = :modID
", {
modID: modDbId,
name: mod.name,
price: val(mod.price ?: 0),
isDefault: (mod.isDefault ?: false) ? 1 : 0,
sortOrder: modSortOrder
sortOrder: modSortOrder,
requiresSelection: requiresSelection,
maxSelections: maxSelections
});
} else {
// Insert new direct modifier (non-template)
queryExecute("
INSERT INTO Items (
ItemBusinessID, ItemParentItemID, ItemName, ItemPrice,
ItemIsCheckedByDefault, ItemSortOrder, ItemIsActive, ItemAddedOn
ItemIsCheckedByDefault, ItemSortOrder, ItemIsActive, ItemAddedOn,
ItemRequiresChildSelection, ItemMaxNumSelectionReq
) VALUES (
:businessID, :parentID, :name, :price,
:isDefault, :sortOrder, 1, NOW()
:isDefault, :sortOrder, 1, NOW(),
:requiresSelection, :maxSelections
)
", {
businessID: businessID,
@ -255,7 +277,9 @@ try {
name: mod.name,
price: val(mod.price ?: 0),
isDefault: (mod.isDefault ?: false) ? 1 : 0,
sortOrder: modSortOrder
sortOrder: modSortOrder,
requiresSelection: requiresSelection,
maxSelections: maxSelections
});
}
modSortOrder++;

View file

@ -72,14 +72,14 @@ try {
AND OrderStatusID IN (1, 2)
", { businessID: businessID });
// Menu items count (items linked through categories)
// Menu items count (active items that have a parent category, excluding categories themselves)
// Categories are items with ItemParentItemID = 0 and ItemIsCollapsible = 0
qMenuItems = queryExecute("
SELECT COUNT(*) as cnt
FROM Items i
INNER JOIN Categories c ON c.CategoryID = i.ItemCategoryID
WHERE c.CategoryBusinessID = :businessID
AND i.ItemIsActive = 1
AND i.ItemParentItemID = 0
FROM Items
WHERE ItemBusinessID = :businessID
AND ItemIsActive = 1
AND ItemParentItemID > 0
", { businessID: businessID });
response["OK"] = true;

View file

@ -1,17 +1,24 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
/**
* Create Payment Intent for Order
* Creates a Stripe PaymentIntent with automatic transfer to connected account
* Create Payment Intent for Order (v2 - with Payfrit fee structure)
*
* Fee Structure:
* - Customer pays: Subtotal + tax + tip + 5% Payfrit fee + card processing (2.9% + $0.30)
* - Restaurant receives: Subtotal - 5% Payfrit fee + tax + tip
* - Payfrit receives: 10% of subtotal (5% from customer + 5% from restaurant)
*
* POST: {
* BusinessID: int,
* OrderID: int,
* Amount: number (in dollars),
* Subtotal: number (order subtotal in dollars, before fees),
* Tax: number (tax amount in dollars),
* Tip: number (optional, in dollars),
* CustomerEmail: string (optional)
* }
*
* Returns: { OK: true, CLIENT_SECRET: string, PAYMENT_INTENT_ID: string }
*/
response = { "OK": false };
@ -21,7 +28,8 @@ try {
businessID = val(requestData.BusinessID ?: 0);
orderID = val(requestData.OrderID ?: 0);
amount = val(requestData.Amount ?: 0);
subtotal = val(requestData.Subtotal ?: 0);
tax = val(requestData.Tax ?: 0);
tip = val(requestData.Tip ?: 0);
customerEmail = requestData.CustomerEmail ?: "";
@ -37,13 +45,14 @@ try {
abort;
}
if (amount <= 0) {
response["ERROR"] = "Invalid amount";
if (subtotal <= 0) {
response["ERROR"] = "Invalid subtotal";
writeOutput(serializeJSON(response));
abort;
}
stripeSecretKey = application.stripeSecretKey ?: "";
// Use test keys
stripeSecretKey = application.stripeSecretKey ?: "sk_test_LfbmDduJxTwbVZmvcByYmirw";
if (stripeSecretKey == "") {
response["ERROR"] = "Stripe is not configured";
@ -56,7 +65,7 @@ try {
SELECT BusinessStripeAccountID, BusinessStripeOnboardingComplete, BusinessName
FROM Businesses
WHERE BusinessID = :businessID
", { businessID: businessID });
", { businessID: businessID }, { datasource: "payfrit" });
if (qBusiness.recordCount == 0) {
response["ERROR"] = "Business not found";
@ -64,49 +73,47 @@ try {
abort;
}
if (!qBusiness.BusinessStripeOnboardingComplete || qBusiness.BusinessStripeAccountID == "") {
response["ERROR"] = "Business has not completed Stripe setup";
writeOutput(serializeJSON(response));
abort;
}
// For testing, allow orders even without Stripe Connect setup
hasStripeConnect = qBusiness.BusinessStripeOnboardingComplete == 1 && len(trim(qBusiness.BusinessStripeAccountID)) > 0;
// Calculate amounts in cents
totalAmount = round((amount + tip) * 100);
tipAmount = round(tip * 100);
// ============================================================
// FEE CALCULATION
// ============================================================
customerFeePercent = 0.05; // 5% customer pays to Payfrit
businessFeePercent = 0.05; // 5% business pays to Payfrit
cardFeePercent = 0.029; // 2.9% Stripe fee
cardFeeFixed = 0.30; // $0.30 Stripe fixed fee
// Platform fee: 2.5% of subtotal (not tip) - adjust as needed
platformFeePercent = 0.025;
platformFee = round(amount * 100 * platformFeePercent);
payfritCustomerFee = subtotal * customerFeePercent;
payfritBusinessFee = subtotal * businessFeePercent;
totalBeforeCardFee = subtotal + tax + tip + payfritCustomerFee;
cardFee = (totalBeforeCardFee * cardFeePercent) + cardFeeFixed;
totalCustomerPays = totalBeforeCardFee + cardFee;
// Amount that goes to the business (total minus platform fee)
transferAmount = totalAmount - platformFee;
// Convert to cents for Stripe
totalAmountCents = round(totalCustomerPays * 100);
totalPlatformFeeCents = round((payfritCustomerFee + payfritBusinessFee) * 100);
// Create PaymentIntent with automatic transfer
// Create PaymentIntent
httpService = new http();
httpService.setMethod("POST");
httpService.setUrl("https://api.stripe.com/v1/payment_intents");
httpService.setUsername(stripeSecretKey);
httpService.setPassword("");
// Required parameters
httpService.addParam(type="formfield", name="amount", value=totalAmount);
httpService.addParam(type="formfield", name="amount", value=totalAmountCents);
httpService.addParam(type="formfield", name="currency", value="usd");
httpService.addParam(type="formfield", name="automatic_payment_methods[enabled]", value="true");
// Transfer to connected account
if (hasStripeConnect) {
httpService.addParam(type="formfield", name="application_fee_amount", value=totalPlatformFeeCents);
httpService.addParam(type="formfield", name="transfer_data[destination]", value=qBusiness.BusinessStripeAccountID);
httpService.addParam(type="formfield", name="transfer_data[amount]", value=transferAmount);
}
// Metadata for tracking
httpService.addParam(type="formfield", name="metadata[order_id]", value=orderID);
httpService.addParam(type="formfield", name="metadata[business_id]", value=businessID);
httpService.addParam(type="formfield", name="metadata[tip_amount]", value=tipAmount);
httpService.addParam(type="formfield", name="metadata[platform_fee]", value=platformFee);
// Description
httpService.addParam(type="formfield", name="description", value="Order ###orderID# at #qBusiness.BusinessName#");
// Receipt email if provided
if (customerEmail != "") {
httpService.addParam(type="formfield", name="receipt_email", value=customerEmail);
}
@ -120,29 +127,41 @@ try {
abort;
}
// Save payment intent to order
// Save payment intent and fee info to order
queryExecute("
UPDATE Orders
SET OrderStripePaymentIntentID = :paymentIntentID,
OrderTipAmount = :tipAmount,
OrderPlatformFee = :platformFee
OrderPayfritFee = :payfritFee,
OrderCardFee = :cardFee,
OrderTotalCharged = :totalCharged
WHERE OrderID = :orderID
", {
paymentIntentID: piData.id,
tipAmount: tip,
platformFee: platformFee / 100,
payfritFee: payfritCustomerFee + payfritBusinessFee,
cardFee: cardFee,
totalCharged: totalCustomerPays,
orderID: orderID
});
}, { datasource: "payfrit" });
response["OK"] = true;
response["CLIENT_SECRET"] = piData.client_secret;
response["PAYMENT_INTENT_ID"] = piData.id;
response["AMOUNT"] = totalAmount;
response["PLATFORM_FEE"] = platformFee;
response["TRANSFER_AMOUNT"] = transferAmount;
response["PUBLISHABLE_KEY"] = application.stripePublishableKey ?: "pk_test_sPBNzSyJ9HcEPJGC7dSo8NqN";
response["FEE_BREAKDOWN"] = {
"SUBTOTAL": subtotal,
"TAX": tax,
"TIP": tip,
"PAYFRIT_FEE": payfritCustomerFee,
"CARD_FEE": cardFee,
"TOTAL": totalCustomerPays
};
response["STRIPE_CONNECT_ENABLED"] = hasStripeConnect;
} catch (any e) {
response["ERROR"] = e.message;
response["DETAIL"] = e.detail ?: "";
}
writeOutput(serializeJSON(response));

View file

@ -221,6 +221,35 @@
flex-direction: column;
gap: 8px;
min-height: 60px;
overflow: hidden;
transition: max-height 0.3s ease, padding 0.3s ease, opacity 0.3s ease;
}
.items-list.collapsed {
max-height: 0;
padding: 0 12px;
opacity: 0;
min-height: 0;
}
/* Chevron toggle */
.category-toggle {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 0.3s ease;
color: var(--text-muted);
}
.category-toggle:hover {
color: var(--text-primary);
}
.category-toggle.expanded {
transform: rotate(90deg);
}
.items-list.drag-over {
@ -262,6 +291,41 @@
transform: translateY(2px);
}
/* Drop indicators for reordering */
.item-card.drop-before,
.category-card.drop-before {
position: relative;
}
.item-card.drop-before::before,
.category-card.drop-before::before {
content: '';
position: absolute;
top: -4px;
left: 0;
right: 0;
height: 4px;
background: var(--primary);
border-radius: 2px;
}
.item-card.drop-after,
.category-card.drop-after {
position: relative;
}
.item-card.drop-after::after,
.category-card.drop-after::after {
content: '';
position: absolute;
bottom: -4px;
left: 0;
right: 0;
height: 4px;
background: var(--primary);
border-radius: 2px;
}
.item-card.modifier {
margin-left: 24px;
background: var(--bg-secondary);
@ -923,6 +987,7 @@
undoStack: [],
redoStack: [],
idCounter: 1,
expandedCategoryId: null, // For accordion - only one category expanded at a time
// Initialize
async init() {
@ -1698,7 +1763,8 @@
// Show properties for option at any depth
showPropertiesForOption(option, parent, depth) {
const hasOptions = option.options && option.options.length > 0;
const levelName = depth === 1 ? 'Modifier' : (depth === 2 ? 'Option' : `Level ${depth} Option`);
const levelName = depth === 1 ? 'Modifier Group' : (depth === 2 ? 'Option' : `Level ${depth} Option`);
const isModifierGroup = depth === 1 && hasOptions;
document.getElementById('propertiesContent').innerHTML = `
<div class="property-group">
@ -1720,6 +1786,29 @@
</select>
</div>
</div>
${isModifierGroup ? `
<div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border-color);">
<h4 style="margin: 0 0 12px; font-size: 13px; color: var(--text-muted);">Selection Rules</h4>
<div class="property-row">
<div class="property-group">
<label>Required</label>
<select onchange="MenuBuilder.updateOption('${parent.id}', '${option.id}', 'requiresSelection', this.value === 'true')">
<option value="false" ${!option.requiresSelection ? 'selected' : ''}>No</option>
<option value="true" ${option.requiresSelection ? 'selected' : ''}>Yes</option>
</select>
</div>
<div class="property-group">
<label>Max Selections</label>
<input type="number" min="0" value="${option.maxSelections || 0}"
onchange="MenuBuilder.updateOption('${parent.id}', '${option.id}', 'maxSelections', parseInt(this.value))"
title="0 = unlimited">
</div>
</div>
<p style="color: var(--text-muted); font-size: 11px; margin: 4px 0 0;">
Required = customer must select at least one option. Max 0 = unlimited.
</p>
</div>
` : ''}
<div class="property-group">
<label>Sort Order</label>
<input type="number" value="${option.sortOrder || 0}"
@ -1881,6 +1970,45 @@
this.render();
},
// Reorder item within same category (drag and drop)
reorderItemInCategory(draggedItemId, targetItemId, position = 'before') {
// Find the category containing these items
for (const cat of this.menu.categories) {
const draggedIdx = cat.items.findIndex(i => i.id === draggedItemId);
const targetIdx = cat.items.findIndex(i => i.id === targetItemId);
if (draggedIdx !== -1 && targetIdx !== -1 && draggedIdx !== targetIdx) {
this.saveState();
// Remove dragged item
const [draggedItem] = cat.items.splice(draggedIdx, 1);
// Find new target index (it may have shifted)
const newTargetIdx = cat.items.findIndex(i => i.id === targetItemId);
// Insert at new position
const insertIdx = position === 'before' ? newTargetIdx : newTargetIdx + 1;
cat.items.splice(insertIdx, 0, draggedItem);
// Update sortOrder for all items
cat.items.forEach((item, idx) => item.sortOrder = idx);
this.render();
return;
}
}
},
// Reorder category (drag and drop)
reorderCategory(draggedCatId, targetCatId, position = 'before') {
const draggedIdx = this.menu.categories.findIndex(c => c.id === draggedCatId);
const targetIdx = this.menu.categories.findIndex(c => c.id === targetCatId);
if (draggedIdx !== -1 && targetIdx !== -1 && draggedIdx !== targetIdx) {
this.saveState();
const [draggedCat] = this.menu.categories.splice(draggedIdx, 1);
const newTargetIdx = this.menu.categories.findIndex(c => c.id === targetCatId);
const insertIdx = position === 'before' ? newTargetIdx : newTargetIdx + 1;
this.menu.categories.splice(insertIdx, 0, draggedCat);
this.render();
}
},
// Delete category
deleteCategory(categoryId) {
if (!confirm('Delete this category and all its items?')) return;
@ -2393,6 +2521,8 @@
<div class="item-meta">
${mod.price > 0 ? `<span>+$${mod.price.toFixed(2)}</span>` : ''}
${mod.isDefault ? '<span class="item-type-badge">Default</span>' : ''}
${mod.requiresSelection ? '<span class="item-type-badge" style="background: #dc3545; color: white;">Required</span>' : ''}
${mod.maxSelections > 0 ? `<span class="item-type-badge">Max ${mod.maxSelections}</span>` : ''}
${hasOptions ? `<span class="item-type-badge">${mod.options.length} options</span>` : ''}
</div>
</div>
@ -2426,10 +2556,17 @@
return;
}
container.innerHTML = this.menu.categories.map(category => `
container.innerHTML = this.menu.categories.map(category => {
const isExpanded = this.expandedCategoryId === category.id;
return `
<div class="category-card" data-category-id="${category.id}" draggable="true"
onclick="MenuBuilder.selectElement(this)">
<div class="category-header">
<div class="category-toggle ${isExpanded ? 'expanded' : ''}" onclick="event.stopPropagation(); MenuBuilder.toggleCategory('${category.id}')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 18l6-6-6-6"/>
</svg>
</div>
<div class="drag-handle">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/>
@ -2437,7 +2574,7 @@
<circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/>
</svg>
</div>
<div class="category-info">
<div class="category-info" onclick="event.stopPropagation(); MenuBuilder.toggleCategory('${category.id}')" style="cursor: pointer;">
<div class="category-name">${this.escapeHtml(category.name)}</div>
<div class="category-count">${category.items.length} items</div>
</div>
@ -2454,7 +2591,7 @@
</button>
</div>
</div>
<div class="items-list">
<div class="items-list ${isExpanded ? '' : 'collapsed'}">
${category.items.length === 0 ? `
<div class="item-drop-zone">Drag items here or click + to add</div>
` : category.items.map(item => `
@ -2504,7 +2641,7 @@
`).join('')}
</div>
</div>
`).join('');
`}).join('');
// Update stats
let totalItems = 0;
@ -2535,26 +2672,52 @@
card.addEventListener('dragend', () => {
card.classList.remove('dragging');
document.querySelectorAll('.drop-before, .drop-after').forEach(el => {
el.classList.remove('drop-before', 'drop-after');
});
});
card.addEventListener('dragover', (e) => {
e.preventDefault();
const source = e.dataTransfer.types.includes('categoryid') ? 'category' : 'item';
if (source === 'item') {
const isDraggingCategory = e.dataTransfer.types.includes('categoryid');
const isDraggingItem = e.dataTransfer.types.includes('itemid');
if (isDraggingItem) {
card.classList.add('drag-over');
} else if (isDraggingCategory && !card.classList.contains('dragging')) {
// Category reordering - show drop indicator
const rect = card.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2;
card.classList.remove('drop-before', 'drop-after', 'drag-over');
if (e.clientY < midpoint) {
card.classList.add('drop-before');
} else {
card.classList.add('drop-after');
}
}
});
card.addEventListener('dragleave', () => {
card.classList.remove('drag-over');
card.classList.remove('drag-over', 'drop-before', 'drop-after');
});
card.addEventListener('drop', (e) => {
e.preventDefault();
card.classList.remove('drag-over');
card.classList.remove('drag-over', 'drop-before', 'drop-after');
const itemId = e.dataTransfer.getData('itemId');
const draggedCategoryId = e.dataTransfer.getData('categoryId');
const targetCategoryId = card.dataset.categoryId;
if (itemId) {
this.moveItemToCategory(itemId, card.dataset.categoryId);
// Moving item to category
this.moveItemToCategory(itemId, targetCategoryId);
} else if (draggedCategoryId && draggedCategoryId !== targetCategoryId) {
// Reordering categories
const rect = card.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2;
const position = e.clientY < midpoint ? 'before' : 'after';
this.reorderCategory(draggedCategoryId, targetCategoryId, position);
}
});
});
@ -2569,8 +2732,53 @@
card.addEventListener('dragend', () => {
card.classList.remove('dragging');
// Remove all drop indicators
document.querySelectorAll('.drop-before, .drop-after').forEach(el => {
el.classList.remove('drop-before', 'drop-after');
});
});
card.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
const draggedItemId = e.dataTransfer.types.includes('itemid');
if (draggedItemId && !card.classList.contains('dragging')) {
// Determine if dropping above or below based on mouse position
const rect = card.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2;
card.classList.remove('drop-before', 'drop-after');
if (e.clientY < midpoint) {
card.classList.add('drop-before');
} else {
card.classList.add('drop-after');
}
}
});
card.addEventListener('dragleave', () => {
card.classList.remove('drop-before', 'drop-after');
});
card.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
const draggedItemId = e.dataTransfer.getData('itemId');
const targetItemId = card.dataset.itemId;
if (draggedItemId && targetItemId && draggedItemId !== targetItemId) {
const position = card.classList.contains('drop-before') ? 'before' : 'after';
this.reorderItemInCategory(draggedItemId, targetItemId, position);
}
card.classList.remove('drop-before', 'drop-after');
});
});
},
// Toggle category expanded/collapsed (accordion behavior)
toggleCategory(categoryId) {
// If clicking the already expanded category, collapse it
// Otherwise expand the clicked one (auto-collapses any other)
this.expandedCategoryId = (this.expandedCategoryId === categoryId) ? null : categoryId;
this.render();
},
// Clone item

View file

@ -386,7 +386,7 @@ const Portal = {
</div>
<h3>Visual Menu Builder</h3>
<p>Drag-and-drop interface to build menus. Clone items, add modifiers, create photo tasks.</p>
<a href="/portal/menu-builder.html?bid=${this.config.businessId}" class="btn btn-primary btn-lg">
<a href="${BASE_PATH}/portal/menu-builder.html?bid=${this.config.businessId}" class="btn btn-primary btn-lg">
Open Builder
</a>
</div>