Beacon delete fix, price extraction, tax rate lookup, add modifiers form

This commit is contained in:
John Mizerek 2026-02-14 19:17:48 -08:00
parent 0bdf9d60b7
commit b360284e56
5 changed files with 256 additions and 104 deletions

View file

@ -40,46 +40,32 @@ if (bizId LTE 0) {
apiAbort({ OK=false, ERROR="no_business_selected" }); apiAbort({ OK=false, ERROR="no_business_selected" });
} }
if (!structKeyExists(data, "BeaconID") || !isNumeric(data.BeaconID) || int(data.BeaconID) LTE 0) { // Get ServicePointID (sharding beacons are service points with BeaconMinor)
apiAbort({ OK=false, ERROR="missing_beacon_id", MESSAGE="BeaconID is required" }); servicePointId = structKeyExists(data, "ServicePointID") && isNumeric(data.ServicePointID) ? int(data.ServicePointID) : 0;
}
beaconId = int(data.BeaconID); if (servicePointId LTE 0) {
apiAbort({ OK=false, ERROR="missing_service_point_id", MESSAGE="ServicePointID is required" });
}
</cfscript> </cfscript>
<!--- Clear BeaconMinor on service point to remove the beacon --->
<cfquery datasource="payfrit"> <cfquery datasource="payfrit">
UPDATE Beacons UPDATE ServicePoints
SET IsActive = 0 SET BeaconMinor = NULL
WHERE ID = <cfqueryparam cfsqltype="cf_sql_integer" value="#beaconId#"> WHERE ID = <cfqueryparam cfsqltype="cf_sql_integer" value="#servicePointId#">
AND ( AND BusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#bizId#">
BusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#bizId#">
OR ID IN (
SELECT lt.BeaconID FROM lt_BeaconsID_BusinessesID lt
WHERE lt.BeaconID = <cfqueryparam cfsqltype="cf_sql_integer" value="#beaconId#">
AND lt.BusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#bizId#">
)
)
</cfquery> </cfquery>
<!--- confirm --->
<cfquery name="qCheck" datasource="payfrit"> <cfquery name="qCheck" datasource="payfrit">
SELECT ID, IsActive SELECT ID FROM ServicePoints
FROM Beacons WHERE ID = <cfqueryparam cfsqltype="cf_sql_integer" value="#servicePointId#">
WHERE ID = <cfqueryparam cfsqltype="cf_sql_integer" value="#beaconId#"> AND BusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#bizId#">
AND (
BusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#bizId#">
OR EXISTS (
SELECT 1 FROM lt_BeaconsID_BusinessesID lt
WHERE lt.BeaconID = <cfqueryparam cfsqltype="cf_sql_integer" value="#beaconId#">
AND lt.BusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#bizId#">
)
)
LIMIT 1 LIMIT 1
</cfquery> </cfquery>
<cfif qCheck.recordCount EQ 0> <cfif qCheck.recordCount EQ 0>
<cfoutput>#serializeJSON({ OK=false, ERROR="not_found" })#</cfoutput> <cfoutput>#serializeJSON({ OK=false, ERROR="not_found", DEBUG_ServicePointID=servicePointId, DEBUG_BusinessID=bizId })#</cfoutput>
<cfabort> <cfabort>
</cfif> </cfif>
<cfoutput>#serializeJSON({ OK=true, ERROR="", BeaconID=beaconId })#</cfoutput> <cfoutput>#serializeJSON({ OK=true, ERROR="", ServicePointID=servicePointId })#</cfoutput>

View file

@ -121,13 +121,15 @@
<cfset itemStruct["name"] = itemName> <cfset itemStruct["name"] = itemName>
<cfset itemStruct["modifiers"] = arrayNew(1)> <cfset itemStruct["modifiers"] = arrayNew(1)>
<!--- Extract price ---> <!--- Extract price - look for any dollar amount in the block --->
<cfset priceMatch = reMatchNoCase('<span[^>]*class="price"[^>]*>\$?([0-9.]+)</span>', block)> <cfset itemStruct["price"] = 0>
<cfset priceMatch = reMatchNoCase('\$([0-9]+\.?[0-9]*)', block)>
<cfif arrayLen(priceMatch)> <cfif arrayLen(priceMatch)>
<cfset priceStr = reReplaceNoCase(priceMatch[1], '.*>\\$?([0-9.]+)</span>.*', '\1')> <!--- priceMatch[1] is like "$12.99", strip the $ --->
<cfset itemStruct["price"] = val(priceStr)> <cfset priceStr = replace(priceMatch[1], "$", "")>
<cfelse> <cfif isNumeric(priceStr) AND val(priceStr) GT 0>
<cfset itemStruct["price"] = 0> <cfset itemStruct["price"] = val(priceStr)>
</cfif>
</cfif> </cfif>
<!--- Extract description ---> <!--- Extract description --->
@ -334,17 +336,43 @@
</cfif> </cfif>
</cfif> </cfif>
<cfif structKeyExists(group, "items") AND isArray(group.items)> <cfif structKeyExists(group, "items") AND isArray(group.items)>
<!--- Debug: log first item's structure --->
<cfif arrayLen(group.items) GT 0 AND NOT structKeyExists(variables, "loggedItemKeys")>
<cfset variables.loggedItemKeys = true>
<cfset firstItem = group.items[1]>
<cfif isStruct(firstItem)>
<cfset arrayAppend(response.steps, "First item keys: " & structKeyList(firstItem))>
<!--- Log a few specific values for debugging --->
<cfif structKeyExists(firstItem, "price")>
<cfset arrayAppend(response.steps, "item.price = " & firstItem.price)>
</cfif>
<cfif structKeyExists(firstItem, "basePrice")>
<cfset arrayAppend(response.steps, "item.basePrice = " & firstItem.basePrice)>
</cfif>
<cfif structKeyExists(firstItem, "displayPrice")>
<cfset arrayAppend(response.steps, "item.displayPrice = " & firstItem.displayPrice)>
</cfif>
</cfif>
</cfif>
<cfloop array="#group.items#" index="item"> <cfloop array="#group.items#" index="item">
<cfif structKeyExists(item, "name")> <cfif structKeyExists(item, "name")>
<!--- Map item name to category ---> <!--- Map item name to category --->
<cfif len(groupName)> <cfif len(groupName)>
<cfset itemCategoryMap[item.name] = groupName> <cfset itemCategoryMap[item.name] = groupName>
</cfif> </cfif>
<!--- Extract price ---> <!--- Extract price - try multiple field names --->
<cfif structKeyExists(item, "price") AND isNumeric(item.price)> <cfif structKeyExists(item, "price") AND isNumeric(item.price)>
<cfset itemPriceMap[item.name] = val(item.price)> <cfset itemPriceMap[item.name] = val(item.price)>
<cfelseif structKeyExists(item, "unitPrice") AND isNumeric(item.unitPrice)> <cfelseif structKeyExists(item, "unitPrice") AND isNumeric(item.unitPrice)>
<cfset itemPriceMap[item.name] = val(item.unitPrice)> <cfset itemPriceMap[item.name] = val(item.unitPrice)>
<cfelseif structKeyExists(item, "basePrice") AND isNumeric(item.basePrice)>
<cfset itemPriceMap[item.name] = val(item.basePrice)>
<cfelseif structKeyExists(item, "displayPrice")>
<!--- displayPrice might be a string like "$12.99" --->
<cfset priceStr = reReplace(item.displayPrice, "[^0-9.]", "", "all")>
<cfif len(priceStr) AND isNumeric(priceStr)>
<cfset itemPriceMap[item.name] = val(priceStr)>
</cfif>
</cfif> </cfif>
<!--- Extract image URLs ---> <!--- Extract image URLs --->
<cfif structKeyExists(item, "imageUrls")> <cfif structKeyExists(item, "imageUrls")>

View file

@ -3,32 +3,19 @@
<cfcontent type="application/json; charset=utf-8" reset="true"> <cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store"> <cfheader name="Cache-Control" value="no-store">
<cfset response = { "OK": false, "taxRate": 0, "message": "", "state": "" }> <cfset response = { "OK": false, "taxRate": 0, "message": "", "city": "", "state": "" }>
<!--- State base sales tax rates (2024) - local rates may add 1-5% more --->
<cfset stateTaxRates = {
"AL": 4, "AK": 0, "AZ": 5.6, "AR": 6.5, "CA": 7.25,
"CO": 2.9, "CT": 6.35, "DE": 0, "FL": 6, "GA": 4,
"HI": 4, "ID": 6, "IL": 6.25, "IN": 7, "IA": 6,
"KS": 6.5, "KY": 6, "LA": 4.45, "ME": 5.5, "MD": 6,
"MA": 6.25, "MI": 6, "MN": 6.875, "MS": 7, "MO": 4.225,
"MT": 0, "NE": 5.5, "NV": 6.85, "NH": 0, "NJ": 6.625,
"NM": 4.875, "NY": 4, "NC": 4.75, "ND": 5, "OH": 5.75,
"OK": 4.5, "OR": 0, "PA": 6, "RI": 7, "SC": 6,
"SD": 4.2, "TN": 7, "TX": 6.25, "UT": 6.1, "VT": 6,
"VA": 5.3, "WA": 6.5, "WV": 6, "WI": 5, "WY": 4, "DC": 6
}>
<!--- Estimated average local rate addition by state (rough estimates) --->
<cfset stateLocalAdd = {
"AL": 5.2, "AZ": 2.8, "AR": 3, "CA": 1.5, "CO": 4.8,
"GA": 4, "IL": 2.5, "KS": 2.5, "LA": 5.5, "MO": 4,
"NV": 1.4, "NM": 2.7, "NY": 4.5, "NC": 2.3, "OH": 1.5,
"OK": 4.5, "SC": 2, "SD": 2, "TN": 2.5, "TX": 2,
"UT": 1.3, "WA": 3, "WI": 0.6
}>
<cftry> <cftry>
<!--- Load Claude API Key --->
<cfset CLAUDE_API_KEY = "">
<cfset configPath = getDirectoryFromPath(getCurrentTemplatePath()) & "../../config/claude.json">
<cfif fileExists(configPath)>
<cfset configData = deserializeJSON(fileRead(configPath))>
<cfif structKeyExists(configData, "apiKey")>
<cfset CLAUDE_API_KEY = configData.apiKey>
</cfif>
</cfif>
<!--- Get ZIP code from URL or form ---> <!--- Get ZIP code from URL or form --->
<cfset zipCode = ""> <cfset zipCode = "">
<cfif structKeyExists(url, "zip")> <cfif structKeyExists(url, "zip")>
@ -47,43 +34,87 @@
<!--- Use just the 5-digit portion ---> <!--- Use just the 5-digit portion --->
<cfset zipCode = left(zipCode, 5)> <cfset zipCode = left(zipCode, 5)>
<!--- Use Zippopotam.us to get state from ZIP (free, no API key) ---> <!--- Use Zippopotam.us to get city/state from ZIP --->
<cfhttp url="https://api.zippopotam.us/us/#zipCode#" method="GET" timeout="10" result="httpResult"> <cfhttp url="https://api.zippopotam.us/us/#zipCode#" method="GET" timeout="10" result="httpResult">
</cfhttp> </cfhttp>
<cfif httpResult.statusCode CONTAINS "200"> <cfif NOT httpResult.statusCode CONTAINS "200">
<cfset zipData = deserializeJSON(httpResult.fileContent)> <cfset response.message = "ZIP lookup failed">
<cfoutput>#serializeJSON(response)#</cfoutput>
<cfabort>
</cfif>
<cfif structKeyExists(zipData, "places") AND isArray(zipData.places) AND arrayLen(zipData.places) GT 0> <cfset zipData = deserializeJSON(httpResult.fileContent)>
<cfset place = zipData.places[1]> <cfif NOT structKeyExists(zipData, "places") OR NOT isArray(zipData.places) OR arrayLen(zipData.places) EQ 0>
<cfset stateAbbr = uCase(place["state abbreviation"])> <cfset response.message = "No location data for ZIP">
<cfset response.state = stateAbbr> <cfoutput>#serializeJSON(response)#</cfoutput>
<cfabort>
</cfif>
<!--- Look up state base rate ---> <cfset place = zipData.places[1]>
<cfif structKeyExists(stateTaxRates, stateAbbr)> <cfset cityName = place["place name"]>
<cfset baseRate = stateTaxRates[stateAbbr]> <cfset stateAbbr = uCase(place["state abbreviation"])>
<cfset response.city = cityName>
<cfset response.state = stateAbbr>
<!--- Add estimated local rate if available ---> <!--- If no Claude API key, return state-only estimate --->
<cfset localAdd = 0> <cfif NOT len(CLAUDE_API_KEY)>
<cfif structKeyExists(stateLocalAdd, stateAbbr)> <!--- Fallback: state base rates --->
<cfset localAdd = stateLocalAdd[stateAbbr]> <cfset stateTaxRates = {
</cfif> "AL": 4, "AK": 0, "AZ": 5.6, "AR": 6.5, "CA": 7.25,
"CO": 2.9, "CT": 6.35, "DE": 0, "FL": 6, "GA": 4,
"HI": 4, "ID": 6, "IL": 6.25, "IN": 7, "IA": 6,
"KS": 6.5, "KY": 6, "LA": 4.45, "ME": 5.5, "MD": 6,
"MA": 6.25, "MI": 6, "MN": 6.875, "MS": 7, "MO": 4.225,
"MT": 0, "NE": 5.5, "NV": 6.85, "NH": 0, "NJ": 6.625,
"NM": 4.875, "NY": 4, "NC": 4.75, "ND": 5, "OH": 5.75,
"OK": 4.5, "OR": 0, "PA": 6, "RI": 7, "SC": 6,
"SD": 4.2, "TN": 7, "TX": 6.25, "UT": 6.1, "VT": 6,
"VA": 5.3, "WA": 6.5, "WV": 6, "WI": 5, "WY": 4, "DC": 6
}>
<cfif structKeyExists(stateTaxRates, stateAbbr)>
<cfset response.taxRate = stateTaxRates[stateAbbr]>
<cfset response.OK = true>
<cfset response.message = "State base rate only (no API key)">
</cfif>
<cfoutput>#serializeJSON(response)#</cfoutput>
<cfabort>
</cfif>
<cfset totalRate = baseRate + localAdd> <!--- Ask Claude for the exact tax rate --->
<!--- Round to 2 decimal places ---> <cfset requestBody = {
<cfset response.taxRate = round(totalRate * 100) / 100> "model": "claude-sonnet-4-20250514",
"max_tokens": 100,
"temperature": 0,
"messages": [{
"role": "user",
"content": "What is the current combined sales tax rate (state + county + city + special districts) for #cityName#, #stateAbbr# (ZIP code #zipCode#)? Return ONLY a single number representing the percentage, like 10.25 for 10.25%. No text, no explanation, just the number."
}]
}>
<cfhttp url="https://api.anthropic.com/v1/messages" method="POST" timeout="30" result="claudeResult">
<cfhttpparam type="header" name="Content-Type" value="application/json">
<cfhttpparam type="header" name="x-api-key" value="#CLAUDE_API_KEY#">
<cfhttpparam type="header" name="anthropic-version" value="2023-06-01">
<cfhttpparam type="body" value="#serializeJSON(requestBody)#">
</cfhttp>
<cfif claudeResult.statusCode CONTAINS "200">
<cfset claudeResponse = deserializeJSON(claudeResult.fileContent)>
<cfif structKeyExists(claudeResponse, "content") AND arrayLen(claudeResponse.content)>
<cfset rateText = trim(claudeResponse.content[1].text)>
<!--- Extract just the number --->
<cfset rateText = reReplace(rateText, "[^0-9.]", "", "all")>
<cfif isNumeric(rateText) AND val(rateText) GT 0 AND val(rateText) LT 20>
<cfset response.taxRate = val(rateText)>
<cfset response.OK = true> <cfset response.OK = true>
<cfset response.stateRate = baseRate> <cfset response.message = "Tax rate for #cityName#, #stateAbbr#">
<cfset response.localRate = localAdd>
<cfset response.message = "Estimated rate for #place['place name']#, #stateAbbr#">
<cfelse> <cfelse>
<cfset response.message = "Unknown state: #stateAbbr#"> <cfset response.message = "Could not parse tax rate">
</cfif> </cfif>
<cfelse>
<cfset response.message = "No location data for ZIP">
</cfif> </cfif>
<cfelse> <cfelse>
<cfset response.message = "ZIP lookup failed: #httpResult.statusCode#"> <cfset response.message = "Claude API error">
</cfif> </cfif>
<cfcatch type="any"> <cfcatch type="any">

View file

@ -1696,18 +1696,21 @@ const Portal = {
return; return;
} }
container.innerHTML = this.beacons.map(b => ` container.innerHTML = this.beacons.map(b => {
<div class="list-group-item ${b.IsActive ? '' : 'inactive'}"> const minorInfo = b.Minor ? ` (Minor: ${b.Minor})` : '';
<div class="item-info"> return `
<div class="item-name">${this.escapeHtml(b.Name)}</div> <div class="list-group-item ${b.IsActive ? '' : 'inactive'}">
<div class="item-detail">${b.UUID || b.NamespaceId || 'No UUID'}</div> <div class="item-info">
<div class="item-name">${this.escapeHtml(b.Name)}${minorInfo}</div>
<div class="item-detail">${b.UUID || 'No UUID'}</div>
</div>
<div class="item-actions">
<button class="btn btn-sm btn-secondary" onclick="Portal.editBeacon(${b.ServicePointID})">Edit</button>
<button class="btn btn-sm btn-danger" onclick="Portal.deleteBeacon(${b.ServicePointID})">Delete</button>
</div>
</div> </div>
<div class="item-actions"> `;
<button class="btn btn-sm btn-secondary" onclick="Portal.editBeacon(${b.BeaconID})">Edit</button> }).join('');
<button class="btn btn-sm btn-danger" onclick="Portal.deleteBeacon(${b.BeaconID})">Delete</button>
</div>
</div>
`).join('');
}, },
// Load service points list // Load service points list
@ -1879,9 +1882,9 @@ const Portal = {
this.showBeaconModal(beaconId); this.showBeaconModal(beaconId);
}, },
// Delete beacon // Delete beacon (removes BeaconMinor from service point)
async deleteBeacon(beaconId) { async deleteBeacon(servicePointId) {
if (!confirm('Are you sure you want to deactivate this beacon?')) return; if (!confirm('Are you sure you want to remove this beacon?')) return;
try { try {
const response = await fetch(`${this.config.apiBaseUrl}/beacons/delete.cfm`, { const response = await fetch(`${this.config.apiBaseUrl}/beacons/delete.cfm`, {
@ -1891,19 +1894,19 @@ const Portal = {
'X-User-Token': this.config.token, 'X-User-Token': this.config.token,
'X-Business-ID': this.config.businessId 'X-Business-ID': this.config.businessId
}, },
body: JSON.stringify({ BeaconID: beaconId }) body: JSON.stringify({ ServicePointID: servicePointId })
}); });
const data = await response.json(); const data = await response.json();
if (data.OK) { if (data.OK) {
this.toast('Beacon deactivated', 'success'); this.toast('Beacon removed', 'success');
await this.loadBeacons(); await this.loadBeacons();
} else { } else {
this.toast(data.ERROR || 'Failed to delete beacon', 'error'); this.toast(data.ERROR || 'Failed to remove beacon', 'error');
} }
} catch (err) { } catch (err) {
console.error('[Portal] Error deleting beacon:', err); console.error('[Portal] Error removing beacon:', err);
this.toast('Error deleting beacon', 'error'); this.toast('Error removing beacon', 'error');
} }
}, },

View file

@ -2509,6 +2509,110 @@
} }
} }
function showAddModifierForm() {
// Initialize modifiers array if needed
if (!config.extractedData.modifiers) {
config.extractedData.modifiers = [];
}
addMessage('ai', `
<div class="add-modifier-form">
<h4>Add Modifier Template</h4>
<div class="form-group">
<label>Modifier Name</label>
<input type="text" id="newModName" class="form-control" placeholder="e.g., Size, Add-ons, Spice Level">
</div>
<div class="form-group">
<label>
<input type="checkbox" id="newModRequired"> Required (customer must choose)
</label>
</div>
<div class="form-group">
<label>Options</label>
<div id="newModOptions">
<div class="modifier-option-row">
<input type="text" class="form-control option-name" placeholder="Option name">
<input type="number" class="form-control option-price" placeholder="Price" step="0.01" min="0">
<button class="btn btn-sm btn-outline" onclick="removeModifierOption(this)">×</button>
</div>
</div>
<button class="btn btn-sm btn-outline" onclick="addModifierOption()">+ Add Option</button>
</div>
<div class="action-buttons">
<button class="btn btn-outline" onclick="showItemsStep()">Cancel</button>
<button class="btn btn-outline" onclick="saveModifierAndAddAnother()">Save & Add Another</button>
<button class="btn btn-primary" onclick="saveModifierAndContinue()">Save & Continue</button>
</div>
</div>
`);
}
function addModifierOption() {
const container = document.getElementById('newModOptions');
const row = document.createElement('div');
row.className = 'modifier-option-row';
row.innerHTML = \`
<input type="text" class="form-control option-name" placeholder="Option name">
<input type="number" class="form-control option-price" placeholder="Price" step="0.01" min="0">
<button class="btn btn-sm btn-outline" onclick="removeModifierOption(this)">×</button>
\`;
container.appendChild(row);
}
function removeModifierOption(btn) {
const row = btn.closest('.modifier-option-row');
const container = document.getElementById('newModOptions');
if (container.children.length > 1) {
row.remove();
}
}
function saveCurrentModifier() {
const name = document.getElementById('newModName').value.trim();
if (!name) {
alert('Please enter a modifier name');
return false;
}
const required = document.getElementById('newModRequired').checked;
const optionRows = document.querySelectorAll('#newModOptions .modifier-option-row');
const options = [];
optionRows.forEach(row => {
const optName = row.querySelector('.option-name').value.trim();
const optPrice = parseFloat(row.querySelector('.option-price').value) || 0;
if (optName) {
options.push({ name: optName, price: optPrice });
}
});
if (options.length === 0) {
alert('Please add at least one option');
return false;
}
config.extractedData.modifiers.push({
name: name,
required: required,
options: options,
appliesTo: 'uncertain'
});
return true;
}
function saveModifierAndAddAnother() {
if (saveCurrentModifier()) {
showAddModifierForm();
}
}
function saveModifierAndContinue() {
if (saveCurrentModifier()) {
showItemsStep();
}
}
function confirmModifiers() { function confirmModifiers() {
const list = document.getElementById('modifiersList'); const list = document.getElementById('modifiersList');
const templates = list.querySelectorAll('.modifier-template'); const templates = list.querySelectorAll('.modifier-template');