/** * 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; }), }; }