Files
LynkedUpPro_CRM/src/modules/estimateWizard/data/tenantConfig.js
T
2026-06-10 13:26:10 +05:30

275 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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. 2530)
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. 3040)
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;
}),
};
}