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,
TaxRate,
BrandColor,
BrandColorLight,
SessionEnabled,
SessionLockMinutes,
SessionPaymentStrategy,
@ -150,6 +151,7 @@ try {
"TaxRate": taxRate,
"TaxRatePercent": taxRate * 100,
"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,
"SessionLockMinutes": isNumeric(q.SessionLockMinutes) ? q.SessionLockMinutes : 30,
"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" });
}
// 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) {
// Strip leading # if present
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)) {
// Helper to validate and normalize a hex color
function normalizeHex(raw) {
var c = isSimpleValue(raw) ? trim(raw) : "";
if (!len(c)) return "";
if (left(c, 1) == chr(35)) c = right(c, len(c) - 1);
if (len(c) != 6 || !reFind("^[0-9A-Fa-f]{6}$", c))
apiAbort({ "OK": false, "ERROR": "invalid_color", "MESSAGE": "Color must be a valid 6-digit hex color (e.g. 1B4D3E or ##1B4D3E)" });
}
// Store uppercase, no # prefix
brandColor = uCase(brandColor);
return uCase(c);
}
brandColor = normalizeHex(structKeyExists(data, "BrandColor") ? data.BrandColor : "");
brandColorLight = normalizeHex(structKeyExists(data, "BrandColorLight") ? data.BrandColorLight : "");
// Update the database
queryTimed("
UPDATE Businesses
SET BrandColor = :color
SET BrandColor = :color, BrandColorLight = :colorLight
WHERE ID = :bizId
", {
color: { value: brandColor, cfsqltype: "cf_sql_varchar" },
colorLight: { value: brandColorLight, cfsqltype: "cf_sql_varchar" },
bizId: { value: bizId, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
@ -60,7 +57,8 @@ writeOutput(serializeJSON({
"OK": true,
"ERROR": "",
"MESSAGE": "Brand color saved",
"BRANDCOLOR": brandColor
"BRANDCOLOR": brandColor,
"BRANDCOLORLIGHT": brandColorLight
}));
</cfscript>

View file

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

View file

@ -162,7 +162,7 @@
/* Category Card */
.category-card {
background: var(--gray-50);
background: var(--brand-tint, var(--gray-50));
border-radius: 12px;
border: 2px solid var(--gray-200);
overflow: hidden;
@ -182,7 +182,7 @@
display: flex;
align-items: center;
padding: 12px 16px;
background: #fff;
background: var(--brand-tint, #fff);
cursor: grab;
gap: 12px;
}
@ -314,7 +314,7 @@
align-items: center;
gap: 12px;
padding: 10px 12px;
background: #fff;
background: var(--brand-tint, #fff);
border-radius: 8px;
border: 1px solid var(--gray-200);
cursor: grab;
@ -3622,37 +3622,56 @@
// Show brand color picker modal
showBrandColorPicker() {
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 = `
<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 style="margin-bottom: 20px;">
<label style="display: block; font-weight: 600; margin-bottom: 6px; font-size: 13px;">Brand Color (Dark)</label>
<p style="margin-bottom: 10px; color: var(--text-muted); font-size: 12px;">
Used for category bar gradients in the customer app.
</p>
<div style="display: flex; align-items: center; gap: 16px; margin-bottom: 12px;">
<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;">
<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 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 style="border-top: 1px solid var(--border); padding-top: 16px; margin-bottom: 20px;">
<label style="display: block; font-weight: 600; margin-bottom: 6px; font-size: 13px;">Brand Color (Light)</label>
<p style="margin-bottom: 10px; color: var(--text-muted); font-size: 12px;">
Subtle tint for menu builder cards and backgrounds. Leave empty for white.
</p>
<div style="display: flex; align-items: center; gap: 16px;">
<input type="color" id="brandColorLightInput" value="${currentLight || '#F5F5F5'}"
style="width: 60px; height: 40px; border: none; cursor: pointer; border-radius: 4px;">
<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 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-primary" onclick="MenuBuilder.saveBrandColor()">Save Color</button>
<button type="button" class="btn btn-primary" onclick="MenuBuilder.saveBrandColor()">Save Colors</button>
</div>
`;
this.showModal();
// Wire up color picker sync
// Wire up dark color picker sync
const colorInput = document.getElementById('brandColorInput');
const hexInput = document.getElementById('brandColorHex');
const preview = document.getElementById('brandColorPreview');
@ -3675,7 +3694,7 @@
}
});
// Preset buttons
// Preset buttons (dark only)
document.querySelectorAll('.color-preset').forEach(btn => {
btn.addEventListener('click', () => {
const color = btn.dataset.color;
@ -3684,13 +3703,37 @@
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
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');
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;
}
@ -3698,12 +3741,18 @@
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 })
body: JSON.stringify({
BusinessID: this.config.businessId,
BrandColor: color,
BrandColorLight: lightColor
})
});
const data = await response.json();
if (data.OK) {
this.brandColor = color;
this.brandColorLight = data.BRANDCOLORLIGHT ? '#' + data.BRANDCOLORLIGHT : '';
document.getElementById('brandColorSwatch').style.background = color;
this.applyBrandTint();
this.closeModal();
this.toast('Brand color saved!', 'success');
} 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
async loadMenu(menuId = null) {
try {
@ -3749,12 +3808,14 @@
// Store templates from API (default to empty array if not provided)
this.templates = data.TEMPLATES || [];
// Load brand color if set
// Load brand colors
if (data.BRANDCOLOR && data.BRANDCOLOR.length > 0) {
this.brandColor = data.BRANDCOLOR;
const swatch = document.getElementById('brandColorSwatch');
if (swatch) swatch.style.background = data.BRANDCOLOR;
}
this.brandColorLight = data.BRANDCOLORLIGHT || '';
this.applyBrandTint();
this.renderTemplateLibrary();
this.render();
} else {