+ Preliminary — verified at inspection. Add-ons can be revised with your rep.
+
+
+
+ );
+};
+
+export default AddonsStep;
diff --git a/src/modules/estimateWizard/components/steps/OptionsStep.jsx b/src/modules/estimateWizard/components/steps/OptionsStep.jsx
new file mode 100644
index 0000000..4e0a4bb
--- /dev/null
+++ b/src/modules/estimateWizard/components/steps/OptionsStep.jsx
@@ -0,0 +1,37 @@
+/**
+ * OptionsStep — Screen 7 "Build options" (spec §7).
+ * Shows all three Good/Better/Best options; customer selects one. The AI highlight
+ * (Phase 4) will badge a recommended option without hiding the others.
+ */
+import React from 'react';
+import { Info } from 'lucide-react';
+import { useEstimateWizard } from '../../EstimateWizardContext';
+import GoodBetterBestCards from '../GoodBetterBestCards';
+
+const OptionsStep = () => {
+ const { session, patchSession, tenantConfig, logEvent } = useEstimateWizard();
+
+ const handleSelect = (type) => {
+ patchSession({ selectedPackage: type });
+ logEvent('package_selected', { package: type });
+ };
+
+ return (
+
+
+
+
+ These are preliminary ranges based on an assumed roof size. Final pricing requires a roof
+ measurement and inspection — your rep will confirm everything. This is not a binding quote.
+
+
+ );
+};
+
+export default OptionsStep;
diff --git a/src/modules/estimateWizard/data/tenantConfig.js b/src/modules/estimateWizard/data/tenantConfig.js
index 4ef34ab..7ed022f 100644
--- a/src/modules/estimateWizard/data/tenantConfig.js
+++ b/src/modules/estimateWizard/data/tenantConfig.js
@@ -143,6 +143,10 @@ export const DEFAULT_PRICING = {
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 ───────────────
diff --git a/src/modules/estimateWizard/engine/pricing.js b/src/modules/estimateWizard/engine/pricing.js
new file mode 100644
index 0000000..404dfcf
--- /dev/null
+++ b/src/modules/estimateWizard/engine/pricing.js
@@ -0,0 +1,83 @@
+/**
+ * pricing.js — preliminary package + add-on pricing (spec §7, §8, §10, §14).
+ *
+ * Pure functions. All inputs come from tenantConfig (nothing hard-coded per §14).
+ * Because the wizard has no verified roof measurement (spec §3), every price is a
+ * PRELIMINARY RANGE (low/base/high) and must be labeled as such — final pricing
+ * requires inspection.
+ */
+
+const round = (n, to = 50) => Math.round(n / to) * to;
+
+/**
+ * Price a single package for an assumed roof size.
+ * @returns {{ low:number, base:number, high:number }}
+ */
+export function pricePackage(pkg, pricing) {
+ const squares = pricing.assumedSquares ?? 25;
+ const waste = pricing.wasteFactor ?? 1.05;
+ const pitch = pricing.pitchFactor ?? 1.0;
+
+ const raw = pkg.pricePerSquare * squares * waste * pitch;
+ const base = Math.max(pricing.minJobPrice ?? 0, raw);
+ const spread = pricing.rangeSpread ?? 0.08;
+
+ return {
+ low: round(base * (1 - spread)),
+ base: round(base),
+ high: round(base * (1 + spread)),
+ };
+}
+
+/** Price every package (keyed by type). */
+export function priceAllPackages(packages, pricing) {
+ const out = {};
+ packages.forEach((p) => { out[p.type] = pricePackage(p, pricing); });
+ return out;
+}
+
+/** Total price of the selected add-ons (flat, demo). */
+export function addonsTotal(addons, selectedIds) {
+ return addons
+ .filter((a) => selectedIds.includes(a.id))
+ .reduce((sum, a) => sum + (a.price || 0), 0);
+}
+
+/**
+ * Derive add-on trigger keys from the customer's wizard answers + hail snapshot.
+ * Drives default-selected add-ons and AI "recommend when…" hints (spec §8).
+ */
+export function deriveAddonTriggers(inputs, hailSnapshot) {
+ const t = new Set();
+ if (inputs.issueType === 'leak') t.add('leak');
+ if (inputs.issueType === 'aging' || (inputs.roofAgeYears ?? 0) >= 15) t.add('old_roof');
+ if (inputs.atticConcern === 'attic_hot') t.add('attic_hot');
+ if (inputs.atticConcern === 'energy_bills') t.add('energy_bills');
+ if (inputs.pestConcern === 'yes') t.add('pests');
+ if (inputs.budgetPreference === 'longterm') t.add('aesthetics');
+ if (hailSnapshot?.exposureTier === 'high') t.add('complex');
+ return t;
+}
+
+/**
+ * Which add-ons should be pre-selected for a given package + answers (spec §8).
+ * An add-on defaults ON if its triggers fire, or it's recommended on this package.
+ */
+export function defaultSelectedAddons(addons, packageType, triggers) {
+ return addons
+ .filter((a) =>
+ (a.recommendedOn || []).includes(packageType) ||
+ (a.triggerKeys || []).some((k) => triggers.has(k))
+ )
+ .map((a) => a.id);
+}
+
+/** Is an add-on "recommended" for display badge purposes? */
+export function isAddonRecommended(addon, packageType, triggers) {
+ return (addon.recommendedOn || []).includes(packageType) ||
+ (addon.triggerKeys || []).some((k) => triggers.has(k));
+}
+
+export function formatUSD(n) {
+ return `$${Math.round(n).toLocaleString('en-US')}`;
+}