Add BrandColorLight for subtle card tinting in menu builder

New BrandColorLight column on Businesses table. Menu builder cards
(categories, subcategories, items) get a very subtle tint from the
light brand color. White when no color is set. Brand color picker
now has both dark and light fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-03-05 14:42:13 -08:00
parent b9755a1e72
commit 3bd7585383
4 changed files with 115 additions and 49 deletions

View file

@ -44,6 +44,7 @@ try {
HeaderImageExtension, HeaderImageExtension,
TaxRate, TaxRate,
BrandColor, BrandColor,
BrandColorLight,
SessionEnabled, SessionEnabled,
SessionLockMinutes, SessionLockMinutes,
SessionPaymentStrategy, SessionPaymentStrategy,
@ -150,6 +151,7 @@ try {
"TaxRate": taxRate, "TaxRate": taxRate,
"TaxRatePercent": taxRate * 100, "TaxRatePercent": taxRate * 100,
"BrandColor": len(q.BrandColor) ? (left(q.BrandColor, 1) == chr(35) ? q.BrandColor : chr(35) & q.BrandColor) : "", "BrandColor": len(q.BrandColor) ? (left(q.BrandColor, 1) == chr(35) ? q.BrandColor : chr(35) & q.BrandColor) : "",
"BrandColorLight": len(q.BrandColorLight) ? (left(q.BrandColorLight, 1) == chr(35) ? q.BrandColorLight : chr(35) & q.BrandColorLight) : "",
"SessionEnabled": isNumeric(q.SessionEnabled) ? q.SessionEnabled : 0, "SessionEnabled": isNumeric(q.SessionEnabled) ? q.SessionEnabled : 0,
"SessionLockMinutes": isNumeric(q.SessionLockMinutes) ? q.SessionLockMinutes : 30, "SessionLockMinutes": isNumeric(q.SessionLockMinutes) ? q.SessionLockMinutes : 30,
"SessionPaymentStrategy": len(q.SessionPaymentStrategy) ? q.SessionPaymentStrategy : "A", "SessionPaymentStrategy": len(q.SessionPaymentStrategy) ? q.SessionPaymentStrategy : "A",

View file

@ -29,30 +29,27 @@ if (bizId LTE 0) {
apiAbort({ "OK": false, "ERROR": "missing_businessid", "MESSAGE": "BusinessID is required" }); apiAbort({ "OK": false, "ERROR": "missing_businessid", "MESSAGE": "BusinessID is required" });
} }
// Validate color format // Helper to validate and normalize a hex color
brandColor = structKeyExists(data, "BrandColor") && isSimpleValue(data.BrandColor) ? trim(data.BrandColor) : ""; function normalizeHex(raw) {
var c = isSimpleValue(raw) ? trim(raw) : "";
// Allow empty to clear, or validate hex format if (!len(c)) return "";
if (len(brandColor) GT 0) { if (left(c, 1) == chr(35)) c = right(c, len(c) - 1);
// Strip leading # if present if (len(c) != 6 || !reFind("^[0-9A-Fa-f]{6}$", c))
if (left(brandColor, 1) == chr(35)) {
brandColor = right(brandColor, len(brandColor) - 1);
}
// Must be exactly 6 hex chars
if (len(brandColor) != 6 || !reFind("^[0-9A-Fa-f]{6}$", brandColor)) {
apiAbort({ "OK": false, "ERROR": "invalid_color", "MESSAGE": "Color must be a valid 6-digit hex color (e.g. 1B4D3E or ##1B4D3E)" }); apiAbort({ "OK": false, "ERROR": "invalid_color", "MESSAGE": "Color must be a valid 6-digit hex color (e.g. 1B4D3E or ##1B4D3E)" });
} return uCase(c);
// Store uppercase, no # prefix
brandColor = uCase(brandColor);
} }
brandColor = normalizeHex(structKeyExists(data, "BrandColor") ? data.BrandColor : "");
brandColorLight = normalizeHex(structKeyExists(data, "BrandColorLight") ? data.BrandColorLight : "");
// Update the database // Update the database
queryTimed(" queryTimed("
UPDATE Businesses UPDATE Businesses
SET BrandColor = :color SET BrandColor = :color, BrandColorLight = :colorLight
WHERE ID = :bizId WHERE ID = :bizId
", { ", {
color: { value: brandColor, cfsqltype: "cf_sql_varchar" }, color: { value: brandColor, cfsqltype: "cf_sql_varchar" },
colorLight: { value: brandColorLight, cfsqltype: "cf_sql_varchar" },
bizId: { value: bizId, cfsqltype: "cf_sql_integer" } bizId: { value: bizId, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" }); }, { datasource: "payfrit" });
@ -60,7 +57,8 @@ writeOutput(serializeJSON({
"OK": true, "OK": true,
"ERROR": "", "ERROR": "",
"MESSAGE": "Brand color saved", "MESSAGE": "Brand color saved",
"BRANDCOLOR": brandColor "BRANDCOLOR": brandColor,
"BRANDCOLORLIGHT": brandColorLight
})); }));
</cfscript> </cfscript>

View file

@ -461,14 +461,18 @@ try {
arrayAppend(templateLibrary, templatesById[templateID]); arrayAppend(templateLibrary, templatesById[templateID]);
} }
// Get business brand color // Get business brand colors
brandColor = ""; brandColor = "";
brandColorLight = "";
try { try {
qBrand = queryTimed(" qBrand = queryTimed("
SELECT BrandColor AS BusinessBrandColor FROM Businesses WHERE ID = :bizId SELECT BrandColor, BrandColorLight FROM Businesses WHERE ID = :bizId
", { bizId: businessID }, { datasource: "payfrit" }); ", { bizId: businessID }, { datasource: "payfrit" });
if (qBrand.recordCount > 0 && len(trim(qBrand.BusinessBrandColor))) { if (qBrand.recordCount > 0) {
brandColor = left(qBrand.BusinessBrandColor, 1) == chr(35) ? qBrand.BusinessBrandColor : chr(35) & qBrand.BusinessBrandColor; if (len(trim(qBrand.BrandColor)))
brandColor = left(qBrand.BrandColor, 1) == chr(35) ? qBrand.BrandColor : chr(35) & qBrand.BrandColor;
if (len(trim(qBrand.BrandColorLight)))
brandColorLight = left(qBrand.BrandColorLight, 1) == chr(35) ? qBrand.BrandColorLight : chr(35) & qBrand.BrandColorLight;
} }
} catch (any e) { } catch (any e) {
// Column may not exist yet, ignore // Column may not exist yet, ignore
@ -481,6 +485,7 @@ try {
response["DEFAULT_MENU_ID"] = defaultMenuID; response["DEFAULT_MENU_ID"] = defaultMenuID;
response["TEMPLATES"] = templateLibrary; response["TEMPLATES"] = templateLibrary;
response["BRANDCOLOR"] = brandColor; response["BRANDCOLOR"] = brandColor;
response["BRANDCOLORLIGHT"] = brandColorLight;
response["CATEGORY_COUNT"] = arrayLen(categories); response["CATEGORY_COUNT"] = arrayLen(categories);
response["TEMPLATE_COUNT"] = arrayLen(templateLibrary); response["TEMPLATE_COUNT"] = arrayLen(templateLibrary);
response["MENU_COUNT"] = arrayLen(allMenus); response["MENU_COUNT"] = arrayLen(allMenus);

View file

@ -162,7 +162,7 @@
/* Category Card */ /* Category Card */
.category-card { .category-card {
background: var(--gray-50); background: var(--brand-tint, var(--gray-50));
border-radius: 12px; border-radius: 12px;
border: 2px solid var(--gray-200); border: 2px solid var(--gray-200);
overflow: hidden; overflow: hidden;
@ -182,7 +182,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
padding: 12px 16px; padding: 12px 16px;
background: #fff; background: var(--brand-tint, #fff);
cursor: grab; cursor: grab;
gap: 12px; gap: 12px;
} }
@ -314,7 +314,7 @@
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 10px 12px; padding: 10px 12px;
background: #fff; background: var(--brand-tint, #fff);
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--gray-200); border: 1px solid var(--gray-200);
cursor: grab; cursor: grab;
@ -3622,37 +3622,56 @@
// Show brand color picker modal // Show brand color picker modal
showBrandColorPicker() { showBrandColorPicker() {
const currentColor = this.brandColor || '#1B4D3E'; const currentColor = this.brandColor || '#1B4D3E';
document.getElementById('modalTitle').textContent = 'Brand Color'; const currentLight = this.brandColorLight || '';
document.getElementById('modalTitle').textContent = 'Brand Colors';
document.getElementById('modalBody').innerHTML = ` document.getElementById('modalBody').innerHTML = `
<p style="margin-bottom: 16px; color: var(--text-muted);"> <div style="margin-bottom: 20px;">
This color is used for the category bar gradients in the customer app. <label style="display: block; font-weight: 600; margin-bottom: 6px; font-size: 13px;">Brand Color (Dark)</label>
</p> <p style="margin-bottom: 10px; color: var(--text-muted); font-size: 12px;">
<div style="display: flex; align-items: center; gap: 16px; margin-bottom: 16px;"> Used for category bar gradients in the customer app.
<input type="color" id="brandColorInput" value="${currentColor}" </p>
style="width: 60px; height: 40px; border: none; cursor: pointer; border-radius: 4px;"> <div style="display: flex; align-items: center; gap: 16px; margin-bottom: 12px;">
<input type="text" id="brandColorHex" value="${currentColor}" <input type="color" id="brandColorInput" value="${currentColor}"
style="width: 100px; padding: 8px; border: 1px solid var(--border); border-radius: 4px; font-family: monospace;" style="width: 60px; height: 40px; border: none; cursor: pointer; border-radius: 4px;">
pattern="^#[0-9A-Fa-f]{6}$" maxlength="7"> <input type="text" id="brandColorHex" value="${currentColor}"
<div id="brandColorPreview" style="flex: 1; height: 40px; border-radius: 4px; background: linear-gradient(to bottom, ${currentColor}44, ${currentColor}00, ${currentColor}66);"></div> 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;">
<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"></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> </div>
<div style="display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px;"> <div style="border-top: 1px solid var(--border); padding-top: 16px; margin-bottom: 20px;">
<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> <label style="display: block; font-weight: 600; margin-bottom: 6px; font-size: 13px;">Brand Color (Light)</label>
<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> <p style="margin-bottom: 10px; color: var(--text-muted); font-size: 12px;">
<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> Subtle tint for menu builder cards and backgrounds. Leave empty for white.
<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> </p>
<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> <div style="display: flex; align-items: center; gap: 16px;">
<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> <input type="color" id="brandColorLightInput" value="${currentLight || '#F5F5F5'}"
<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> style="width: 60px; height: 40px; border: none; cursor: pointer; border-radius: 4px;">
<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> <input type="text" id="brandColorLightHex" value="${currentLight}"
style="width: 100px; padding: 8px; border: 1px solid var(--border); border-radius: 4px; font-family: monospace;"
placeholder="#F5F5F5" maxlength="7">
<div id="brandColorLightPreview" style="flex: 1; height: 40px; border-radius: 4px; border: 1px solid var(--border); background: ${currentLight || '#fff'};"></div>
<button type="button" class="btn btn-secondary" style="font-size: 12px; padding: 6px 10px;" onclick="document.getElementById('brandColorLightHex').value='';document.getElementById('brandColorLightPreview').style.background='#fff';">Clear</button>
</div>
</div> </div>
<div style="display: flex; gap: 8px; justify-content: flex-end;"> <div style="display: flex; gap: 8px; justify-content: flex-end;">
<button type="button" class="btn btn-secondary" onclick="MenuBuilder.closeModal()">Cancel</button> <button type="button" class="btn btn-secondary" onclick="MenuBuilder.closeModal()">Cancel</button>
<button type="button" class="btn btn-primary" onclick="MenuBuilder.saveBrandColor()">Save Color</button> <button type="button" class="btn btn-primary" onclick="MenuBuilder.saveBrandColor()">Save Colors</button>
</div> </div>
`; `;
this.showModal(); this.showModal();
// Wire up color picker sync // Wire up dark color picker sync
const colorInput = document.getElementById('brandColorInput'); const colorInput = document.getElementById('brandColorInput');
const hexInput = document.getElementById('brandColorHex'); const hexInput = document.getElementById('brandColorHex');
const preview = document.getElementById('brandColorPreview'); const preview = document.getElementById('brandColorPreview');
@ -3675,7 +3694,7 @@
} }
}); });
// Preset buttons // Preset buttons (dark only)
document.querySelectorAll('.color-preset').forEach(btn => { document.querySelectorAll('.color-preset').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const color = btn.dataset.color; const color = btn.dataset.color;
@ -3684,13 +3703,37 @@
updatePreview(color); updatePreview(color);
}); });
}); });
// Wire up light color picker sync
const lightInput = document.getElementById('brandColorLightInput');
const lightHex = document.getElementById('brandColorLightHex');
const lightPreview = document.getElementById('brandColorLightPreview');
lightInput.addEventListener('input', (e) => {
lightHex.value = e.target.value.toUpperCase();
lightPreview.style.background = e.target.value;
});
lightHex.addEventListener('input', (e) => {
let val = e.target.value;
if (!val.startsWith('#')) val = '#' + val;
if (/^#[0-9A-Fa-f]{6}$/.test(val)) {
lightInput.value = val;
lightPreview.style.background = val;
}
});
}, },
// Save brand color // Save brand color
async saveBrandColor() { async saveBrandColor() {
const color = document.getElementById('brandColorHex').value.toUpperCase(); const color = document.getElementById('brandColorHex').value.toUpperCase();
if (!/^#[0-9A-Fa-f]{6}$/.test(color)) { if (!/^#[0-9A-Fa-f]{6}$/.test(color)) {
this.toast('Invalid color format. Use #RRGGBB', 'error'); this.toast('Invalid dark color format. Use #RRGGBB', 'error');
return;
}
const lightColor = (document.getElementById('brandColorLightHex')?.value || '').toUpperCase();
if (lightColor && !/^#[0-9A-Fa-f]{6}$/.test(lightColor)) {
this.toast('Invalid light color format. Use #RRGGBB or leave empty', 'error');
return; return;
} }
@ -3698,12 +3741,18 @@
const response = await fetch(`${this.config.apiBaseUrl}/businesses/saveBrandColor.cfm`, { const response = await fetch(`${this.config.apiBaseUrl}/businesses/saveBrandColor.cfm`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ BusinessID: this.config.businessId, BrandColor: color }) body: JSON.stringify({
BusinessID: this.config.businessId,
BrandColor: color,
BrandColorLight: lightColor
})
}); });
const data = await response.json(); const data = await response.json();
if (data.OK) { if (data.OK) {
this.brandColor = color; this.brandColor = color;
this.brandColorLight = data.BRANDCOLORLIGHT ? '#' + data.BRANDCOLORLIGHT : '';
document.getElementById('brandColorSwatch').style.background = color; document.getElementById('brandColorSwatch').style.background = color;
this.applyBrandTint();
this.closeModal(); this.closeModal();
this.toast('Brand color saved!', 'success'); this.toast('Brand color saved!', 'success');
} else { } else {
@ -3715,6 +3764,16 @@
} }
}, },
applyBrandTint() {
const canvas = document.getElementById('menuCanvas');
if (!canvas) return;
if (this.brandColorLight && this.brandColorLight.length >= 4) {
canvas.style.setProperty('--brand-tint', this.brandColorLight + '0A');
} else {
canvas.style.setProperty('--brand-tint', 'transparent');
}
},
// Load menu from API // Load menu from API
async loadMenu(menuId = null) { async loadMenu(menuId = null) {
try { try {
@ -3749,12 +3808,14 @@
// 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 // Load brand colors
if (data.BRANDCOLOR && data.BRANDCOLOR.length > 0) { if (data.BRANDCOLOR && data.BRANDCOLOR.length > 0) {
this.brandColor = data.BRANDCOLOR; this.brandColor = data.BRANDCOLOR;
const swatch = document.getElementById('brandColorSwatch'); const swatch = document.getElementById('brandColorSwatch');
if (swatch) swatch.style.background = data.BRANDCOLOR; if (swatch) swatch.style.background = data.BRANDCOLOR;
} }
this.brandColorLight = data.BRANDCOLORLIGHT || '';
this.applyBrandTint();
this.renderTemplateLibrary(); this.renderTemplateLibrary();
this.render(); this.render();
} else { } else {