275 lines
12 KiB
JavaScript
275 lines
12 KiB
JavaScript
/**
|
||
* tenantConfig.js — Estimate Wizard tenant-configurable defaults.
|
||
*
|
||
* Spec §14 (Admin Controls) requires that pricing, discounts, warranty text,
|
||
* package scope, service-life, and scoring weights are NOT hard-coded — they are
|
||
* tenant-controlled. For the demo these defaults live here and can be overridden
|
||
* by the admin page (Phase 5). Nothing customer-facing should bypass this object.
|
||
*
|
||
* Manufacturer-neutral by design (spec §3, §15): packages are generic categories,
|
||
* internal SKU/vendor names are intentionally absent from customer-facing fields.
|
||
*/
|
||
|
||
// ── Good / Better / Best packages (spec §7) ──────────────────────────────────
|
||
export const DEFAULT_PACKAGES = [
|
||
{
|
||
type: 'GOOD',
|
||
name: 'Standard Architectural',
|
||
category: 'Architectural / laminate shingle system',
|
||
materialClass: 'standard',
|
||
warrantyLabel: '30-year material warranty category',
|
||
position: 'Lowest upfront investment',
|
||
fit: 'Shorter ownership horizon, limited budget, lower hail exposure, or preparing to sell soon.',
|
||
// price rule: per-square installed price (demo). Real pricing engine lives in admin.
|
||
pricePerSquare: 420,
|
||
serviceLifeYears: 28, // tenant-configurable (e.g. 25–30)
|
||
insuranceDiscountPct: 0, // Good is not impact-rated → no assumed discount
|
||
hailResistance: 'Standard',
|
||
scope: ['Architectural laminate shingles', 'Code-required underlayment', 'Standard hip/ridge', 'Standard accessories'],
|
||
accent: 'zinc',
|
||
},
|
||
{
|
||
type: 'BETTER',
|
||
name: 'Class 4 Impact-Rated',
|
||
category: 'Class 4 impact-rated architectural shingle system',
|
||
materialClass: 'impact-rated',
|
||
warrantyLabel: 'Enhanced manufacturer warranty category',
|
||
position: 'Balanced protection and budget',
|
||
fit: 'Medium-to-long ownership, meaningful hail exposure, wants stronger protection without premium-system cost.',
|
||
pricePerSquare: 565,
|
||
serviceLifeYears: 35, // tenant-configurable (e.g. 30–40)
|
||
insuranceDiscountPct: 15, // illustrative; "ask your carrier to confirm"
|
||
hailResistance: 'Higher',
|
||
scope: ['Class 4 impact-rated shingles', 'Upgraded underlayment', 'Standard hip/ridge', 'Improved hail resistance'],
|
||
accent: 'blue',
|
||
},
|
||
{
|
||
type: 'BEST',
|
||
name: 'Premium Long-Life System',
|
||
category: 'Composite / synthetic or stone-coated steel category',
|
||
materialClass: 'premium',
|
||
warrantyLabel: 'Premium long-life warranty category',
|
||
position: 'Highest long-term protection',
|
||
fit: 'Long ownership, high hail exposure, high premium, wants fewer future roof cycles and strongest curb appeal.',
|
||
pricePerSquare: 950,
|
||
serviceLifeYears: 50, // tenant-configurable (e.g. 50+)
|
||
insuranceDiscountPct: 20, // illustrative; "ask your carrier to confirm"
|
||
hailResistance: 'Highest',
|
||
scope: ['Composite/synthetic or stone-coated steel', 'Premium underlayment', 'Upgraded accessories', 'Highest durability positioning'],
|
||
accent: 'amber',
|
||
},
|
||
];
|
||
|
||
// ── Add-ons (spec §8) — plain-language, never fear-based ──────────────────────
|
||
export const DEFAULT_ADDONS = [
|
||
{
|
||
id: 'hip-ridge',
|
||
name: 'High-profile hip & ridge',
|
||
value: 'Improves the finished look and strengthens the ridge-line appearance.',
|
||
price: 480,
|
||
compatibility: ['GOOD', 'BETTER', 'BEST'],
|
||
recommendedOn: ['BEST'],
|
||
triggerKeys: ['aesthetics'],
|
||
},
|
||
{
|
||
id: 'solar-vents',
|
||
name: 'Solar attic vents',
|
||
value: 'Helps move hot attic air and may improve comfort and roof-system health.',
|
||
price: 650,
|
||
compatibility: ['GOOD', 'BETTER', 'BEST'],
|
||
recommendedOn: [],
|
||
triggerKeys: ['attic_hot', 'energy_bills'],
|
||
},
|
||
{
|
||
id: 'gutter-guards',
|
||
name: 'Gutter guards',
|
||
value: 'Reduces gutter debris buildup and maintenance frequency.',
|
||
price: 900,
|
||
compatibility: ['GOOD', 'BETTER', 'BEST'],
|
||
recommendedOn: [],
|
||
triggerKeys: ['canopy'],
|
||
},
|
||
{
|
||
id: 'critter-guards',
|
||
name: 'Critter guards',
|
||
value: 'Helps block common rodent and small-animal roofline entry points.',
|
||
price: 350,
|
||
compatibility: ['GOOD', 'BETTER', 'BEST'],
|
||
recommendedOn: [],
|
||
triggerKeys: ['pests'],
|
||
},
|
||
{
|
||
id: 'upgraded-flashing',
|
||
name: 'Upgraded flashing / penetrations',
|
||
value: 'Improves protection around common leak points.',
|
||
price: 520,
|
||
compatibility: ['GOOD', 'BETTER', 'BEST'],
|
||
recommendedOn: ['BEST'],
|
||
triggerKeys: ['leak', 'old_roof', 'complex'],
|
||
},
|
||
{
|
||
id: 'enhanced-underlayment',
|
||
name: 'Enhanced underlayment / ice-water zones',
|
||
value: 'Adds protection in vulnerable valleys, eaves, and penetrations.',
|
||
price: 700,
|
||
compatibility: ['GOOD', 'BETTER', 'BEST'],
|
||
recommendedOn: [],
|
||
triggerKeys: ['complex', 'leak'],
|
||
},
|
||
];
|
||
|
||
// ── Scoring weights (spec §9) — deterministic, tenant-adjustable ──────────────
|
||
export const DEFAULT_SCORING_WEIGHTS = {
|
||
ownershipHorizon: 0.25,
|
||
hailRisk: 0.25,
|
||
insuranceSavings: 0.20,
|
||
budgetPreference: 0.15,
|
||
roofAge: 0.10,
|
||
aesthetics: 0.05,
|
||
};
|
||
|
||
// ── Storm-risk settings (spec §6, §14) ────────────────────────────────────────
|
||
export const DEFAULT_STORM_SETTINGS = {
|
||
windowYears: 20, // 10 fallback / 30 for smoother trends
|
||
radiusMiles: 10,
|
||
minHailSizeInches: 0.75,
|
||
dedupeWindowHours: 6,
|
||
};
|
||
|
||
// ── Pricing engine knobs (spec §14) ───────────────────────────────────────────
|
||
export const DEFAULT_PRICING = {
|
||
wasteFactor: 1.05,
|
||
minJobPrice: 6500,
|
||
pitchFactor: 1.0, // multiplied per pitch/stories/complexity in admin
|
||
taxPlaceholderPct: 0,
|
||
permitPlaceholder: 0,
|
||
// No measurement source in the wizard (spec §3) → assume an average roof size
|
||
// so the customer sees a PRELIMINARY range. Verified during inspection.
|
||
assumedSquares: 25, // ~2,500 sq ft of roof surface
|
||
rangeSpread: 0.08, // ±8% band shown to the customer (low/base/high)
|
||
};
|
||
|
||
// ── Insurance discount scenarios (spec §10/§14) — low/base/high ───────────────
|
||
export const DEFAULT_DISCOUNT_SCENARIOS = {
|
||
GOOD: { low: 0, base: 0, high: 0 },
|
||
BETTER: { low: 10, base: 15, high: 18 },
|
||
BEST: { low: 15, base: 18, high: 22 },
|
||
};
|
||
|
||
// ── Report branding (spec §14) ────────────────────────────────────────────────
|
||
export const DEFAULT_BRANDING = {
|
||
company: 'LynkedUp Pro',
|
||
phone: '866-259-6533',
|
||
email: 'estimates@lynkeduppro.com',
|
||
serviceArea: 'Texas & surrounding regions',
|
||
primaryColor: '#fda913',
|
||
legalFooter: 'Preliminary estimate. Not a binding quote.',
|
||
};
|
||
|
||
// ── Legal / disclaimer (spec §15) — verbatim required template ────────────────
|
||
export const DISCLAIMER_TEMPLATE =
|
||
'This report is an educational, preliminary estimate based on the information provided and available ' +
|
||
'property/storm data. Final pricing requires property measurement and contractor inspection. Insurance ' +
|
||
'premium savings are illustrative only and must be confirmed with your insurance carrier or licensed ' +
|
||
'insurance professional. Historical storm information is not a forecast or guarantee.';
|
||
|
||
// Schema version of the config shape. The *published* tenant version is this plus
|
||
// a revision suffix (e.g. tenant-config-v1.r4) that bumps on every admin save, so
|
||
// each report records exactly which package content/pricing produced it (§14, §18).
|
||
export const TENANT_CONFIG_VERSION = 'tenant-config-v1';
|
||
export const DISCLAIMER_VERSION = 'disclaimer-v1';
|
||
|
||
// Admin-editable overrides (spec §14). Held in memory only — like the rest of the
|
||
// CRM demo store, nothing is written to localStorage, so this resets on a full page
|
||
// reload. The wizard merges these over the defaults so pricing/branding/feature
|
||
// flags are tenant-controlled, not code. Module-scoped so the value survives route
|
||
// changes within a session (admin → wizard → leads) without persistence.
|
||
let configOverrides = {};
|
||
|
||
// Same-tab change signal so an open wizard refreshes when the admin publishes,
|
||
// without a hard reload (the in-memory store has no `storage` event to lean on).
|
||
export const CONFIG_CHANGE_EVENT = 'lup-estimate-wizard-config-changed';
|
||
|
||
function notifyConfigChanged() {
|
||
try { window.dispatchEvent(new Event(CONFIG_CHANGE_EVENT)); } catch { /* SSR/no window */ }
|
||
}
|
||
|
||
function defaults() {
|
||
return {
|
||
version: TENANT_CONFIG_VERSION,
|
||
disclaimerVersion: DISCLAIMER_VERSION,
|
||
// Feature flags (spec §18): kill-switch, AI toggle, conservative copy mode.
|
||
featureEnabled: true,
|
||
aiExplanationsEnabled: true,
|
||
conservativeMode: false,
|
||
packages: DEFAULT_PACKAGES,
|
||
addons: DEFAULT_ADDONS,
|
||
scoringWeights: DEFAULT_SCORING_WEIGHTS,
|
||
stormSettings: DEFAULT_STORM_SETTINGS,
|
||
pricing: DEFAULT_PRICING,
|
||
discountScenarios: DEFAULT_DISCOUNT_SCENARIOS,
|
||
branding: DEFAULT_BRANDING,
|
||
disclaimer: DISCLAIMER_TEMPLATE,
|
||
};
|
||
}
|
||
|
||
export function loadConfigOverrides() {
|
||
return configOverrides;
|
||
}
|
||
|
||
export function saveConfigOverrides(overrides) {
|
||
configOverrides = overrides || {};
|
||
notifyConfigChanged();
|
||
}
|
||
|
||
export function resetConfigOverrides() {
|
||
configOverrides = {};
|
||
notifyConfigChanged();
|
||
}
|
||
|
||
/**
|
||
* Publish a new tenant config version (spec §14: "versioned"). Bumps the revision,
|
||
* stamps versionedAt, and appends to a capped version history so admins can see and
|
||
* audit every change. Returns the saved overrides (with the new version metadata).
|
||
*/
|
||
export function publishConfigOverrides(overrides) {
|
||
const prev = loadConfigOverrides();
|
||
const revision = (Number(prev.revision) || 0) + 1;
|
||
const version = `${TENANT_CONFIG_VERSION}.r${revision}`;
|
||
const versionedAt = new Date().toISOString();
|
||
const history = Array.isArray(prev.versionHistory) ? prev.versionHistory : [];
|
||
|
||
const saved = {
|
||
...overrides,
|
||
revision,
|
||
version,
|
||
versionedAt,
|
||
versionHistory: [{ revision, version, versionedAt }, ...history].slice(0, 25),
|
||
};
|
||
saveConfigOverrides(saved);
|
||
return saved;
|
||
}
|
||
|
||
export function getTenantConfig() {
|
||
const base = defaults();
|
||
const o = loadConfigOverrides();
|
||
|
||
// Shallow-merge top-level objects so partial admin edits don't drop defaults.
|
||
// `...o` lets a published version/versionedAt/revision override the base schema
|
||
// version, so getTenantConfig().version reflects the tenant's edits.
|
||
return {
|
||
...base,
|
||
...o,
|
||
pricing: { ...base.pricing, ...(o.pricing || {}) },
|
||
branding: { ...base.branding, ...(o.branding || {}) },
|
||
scoringWeights: { ...base.scoringWeights, ...(o.scoringWeights || {}) },
|
||
stormSettings: { ...base.stormSettings, ...(o.stormSettings || {}) },
|
||
discountScenarios: { ...base.discountScenarios, ...(o.discountScenarios || {}) },
|
||
// Packages: merge admin price/label edits by type, keep default scope/copy.
|
||
packages: base.packages.map((p) => {
|
||
const ov = (o.packages || []).find((x) => x.type === p.type);
|
||
return ov ? { ...p, ...ov } : p;
|
||
}),
|
||
};
|
||
}
|