good better best prising plus add ons

This commit is contained in:
Mayur Shinde
2026-06-09 17:31:58 +05:30
parent 110790d5ae
commit 03801a5087
7 changed files with 350 additions and 4 deletions
@@ -0,0 +1,55 @@
/**
* AddonToggles — optional upgrades as simple toggles (spec §8).
* Plain-language value statements, never fear-based. "Recommended" badge when the
* customer's answers or selected package trigger it. Price/total recompute instantly.
*/
import React from 'react';
import { Plus, Check, Sparkles } from 'lucide-react';
import { formatUSD, isAddonRecommended } from '../engine/pricing';
const AddonToggles = ({ addons, selectedIds, onToggle, packageType, triggers }) => {
return (
<div className="space-y-2.5">
{addons.map((addon) => {
const on = selectedIds.includes(addon.id);
const recommended = isAddonRecommended(addon, packageType, triggers);
return (
<button
key={addon.id}
type="button"
onClick={() => onToggle(addon.id)}
aria-pressed={on}
className={`w-full text-left rounded-2xl border p-4 flex items-start gap-3 transition-all
focus:outline-none focus:ring-2 focus:ring-amber-500
${on
? 'border-amber-500 bg-amber-50 dark:bg-amber-500/10'
: 'border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 hover:border-amber-400'}`}
>
<span className={`mt-0.5 shrink-0 w-6 h-6 rounded-lg flex items-center justify-center border
${on ? 'bg-amber-500 border-amber-500 text-white' : 'border-zinc-300 dark:border-white/20 text-transparent'}`}>
{on ? <Check size={15} /> : <Plus size={15} className="text-zinc-400" />}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-zinc-900 dark:text-white">{addon.name}</span>
{recommended && (
<span className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide text-amber-600 dark:text-amber-400 bg-amber-100 dark:bg-amber-500/15 px-1.5 py-0.5 rounded-full">
<Sparkles size={10} /> Recommended
</span>
)}
</div>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-0.5">{addon.value}</p>
</div>
<span className="text-sm font-semibold text-zinc-700 dark:text-zinc-200 shrink-0">
+{formatUSD(addon.price)}
</span>
</button>
);
})}
</div>
);
};
export default AddonToggles;
@@ -0,0 +1,86 @@
/**
* GoodBetterBestCards — the three-option presentation (spec §7).
* Rules honored:
* - ALWAYS show all three options (never hide alternatives).
* - Manufacturer-neutral copy (generic categories only).
* - A `highlightedType` may be passed (Phase 4 recommendation) to badge one card,
* but every option stays visible and selectable.
*/
import React from 'react';
import { Check, Shield, Star } from 'lucide-react';
import { pricePackage, formatUSD } from '../engine/pricing';
const ACCENT = {
zinc: { ring: 'ring-zinc-300 dark:ring-zinc-600', text: 'text-zinc-600 dark:text-zinc-300', chip: 'bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300' },
blue: { ring: 'ring-blue-400', text: 'text-blue-600 dark:text-blue-400', chip: 'bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400' },
amber: { ring: 'ring-amber-400', text: 'text-amber-600 dark:text-amber-400', chip: 'bg-amber-50 dark:bg-amber-500/10 text-amber-600 dark:text-amber-400' },
};
const TIER_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
const GoodBetterBestCards = ({ packages, pricing, selectedType, onSelect, highlightedType }) => {
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{packages.map((pkg) => {
const a = ACCENT[pkg.accent] || ACCENT.zinc;
const price = pricePackage(pkg, pricing);
const selected = selectedType === pkg.type;
const highlighted = highlightedType === pkg.type;
return (
<button
key={pkg.type}
type="button"
onClick={() => onSelect(pkg.type)}
aria-pressed={selected}
className={`relative text-left rounded-2xl border bg-white dark:bg-zinc-900 p-5 transition-all duration-200
focus:outline-none flex flex-col
${selected
? `border-transparent ring-2 ${a.ring} shadow-lg`
: 'border-zinc-200 dark:border-white/10 hover:-translate-y-0.5 hover:shadow-md'}`}
>
{highlighted && (
<span className="absolute -top-2.5 left-1/2 -translate-x-1/2 inline-flex items-center gap-1 rounded-full bg-gradient-to-r from-amber-400 to-orange-500 text-white text-[11px] font-bold px-2.5 py-0.5 shadow">
<Star size={11} /> Recommended
</span>
)}
<div className="flex items-center justify-between">
<span className={`text-xs font-bold uppercase tracking-wider ${a.text}`}>{TIER_LABEL[pkg.type]}</span>
{selected && <Check size={18} className={a.text} />}
</div>
<h3 className="font-bold text-zinc-900 dark:text-white mt-1">{pkg.name}</h3>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">{pkg.category}</p>
<div className="mt-3">
<div className="text-2xl font-bold text-zinc-900 dark:text-white">
{formatUSD(price.low)}{formatUSD(price.high)}
</div>
<div className="text-[11px] text-zinc-400">preliminary range · verified at inspection</div>
</div>
<div className="flex flex-wrap gap-1.5 mt-3">
<span className={`text-[11px] font-medium px-2 py-0.5 rounded-full ${a.chip}`}>{pkg.warrantyLabel}</span>
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300 inline-flex items-center gap-1">
<Shield size={10} /> {pkg.hailResistance} hail resistance
</span>
</div>
<ul className="mt-3 space-y-1.5 flex-1">
{pkg.scope.map((s) => (
<li key={s} className="flex items-start gap-1.5 text-xs text-zinc-600 dark:text-zinc-300">
<Check size={13} className={`mt-0.5 shrink-0 ${a.text}`} /> {s}
</li>
))}
</ul>
<p className="text-[11px] text-zinc-400 mt-3 italic">{pkg.position}</p>
</button>
);
})}
</div>
);
};
export default GoodBetterBestCards;
@@ -16,6 +16,8 @@ import StartStep from './steps/StartStep';
import AddressStep from './steps/AddressStep';
import PremiumStep from './steps/PremiumStep';
import HailRiskStep from './steps/HailRiskStep';
import OptionsStep from './steps/OptionsStep';
import AddonsStep from './steps/AddonsStep';
import PlaceholderStep from './steps/PlaceholderStep';
// Step registry — later phases swap PlaceholderStep entries for real components.
@@ -24,16 +26,17 @@ const STEP_COMPONENTS = {
address: AddressStep,
premium: PremiumStep,
hailRisk: HailRiskStep,
options: (props) => <PlaceholderStep stepId="options" {...props} />,
addons: (props) => <PlaceholderStep stepId="addons" {...props} />,
options: OptionsStep,
addons: AddonsStep,
recommendation: (props) => <PlaceholderStep stepId="recommendation" {...props} />,
report: (props) => <PlaceholderStep stepId="report" {...props} />,
};
// Validation per step: returns true when the user may advance.
function canAdvance(step, inputs) {
function canAdvance(step, inputs, session) {
if (step.id === 'start') return inputs.consent && inputs.name?.trim() && inputs.address?.trim();
if (step.id === 'address') return !!inputs.address?.trim();
if (step.id === 'options') return !!session.selectedPackage;
if (step.type === 'cards' && step.required) return inputs[step.inputKey] != null;
if (step.type === 'slider' && step.required) return inputs[step.inputKey] != null;
return true;
@@ -75,7 +78,7 @@ const WizardShell = () => {
} = useEstimateWizard();
const isLast = stepIndex === totalSteps - 1;
const advance = canAdvance(step, inputs);
const advance = canAdvance(step, inputs, session);
const renderBody = () => {
if (step.type === 'cards') {
@@ -0,0 +1,78 @@
/**
* AddonsStep — Screen 7b "Add-ons" (spec §8).
* Add-ons pre-select from the customer's answers + chosen package (once), then the
* customer can toggle freely. Running total recomputes instantly.
*/
import React, { useEffect, useMemo } from 'react';
import { useEstimateWizard } from '../../EstimateWizardContext';
import AddonToggles from '../AddonToggles';
import {
pricePackage, addonsTotal, deriveAddonTriggers, defaultSelectedAddons, formatUSD,
} from '../../engine/pricing';
const AddonsStep = () => {
const { inputs, session, patchSession, tenantConfig, logEvent } = useEstimateWizard();
const { addons, packages, pricing } = tenantConfig;
const packageType = session.selectedPackage || 'BETTER';
const triggers = useMemo(() => deriveAddonTriggers(inputs, session.hailSnapshot), [inputs, session.hailSnapshot]);
// Pre-select recommended add-ons once (spec §8 default-selected rules).
useEffect(() => {
if (session.addonsInitialized) return;
patchSession({
selectedAddons: defaultSelectedAddons(addons, packageType, triggers),
addonsInitialized: true,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session.addonsInitialized]);
const selectedIds = session.selectedAddons || [];
const handleToggle = (id) => {
const next = selectedIds.includes(id)
? selectedIds.filter((x) => x !== id)
: [...selectedIds, id];
patchSession({ selectedAddons: next });
logEvent('addon_toggled', { id, on: !selectedIds.includes(id) });
};
const pkg = packages.find((p) => p.type === packageType);
const base = pkg ? pricePackage(pkg, pricing).base : 0;
const addonsSum = addonsTotal(addons, selectedIds);
const total = base + addonsSum;
return (
<div className="space-y-4">
<AddonToggles
addons={addons}
selectedIds={selectedIds}
onToggle={handleToggle}
packageType={packageType}
triggers={triggers}
/>
{/* Running total for the selected package */}
<div className="sticky bottom-0 rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4 shadow-sm">
<div className="flex items-center justify-between text-sm text-zinc-500 dark:text-zinc-400">
<span>{pkg?.name} (base estimate)</span>
<span>{formatUSD(base)}</span>
</div>
<div className="flex items-center justify-between text-sm text-zinc-500 dark:text-zinc-400 mt-1">
<span>Selected add-ons ({selectedIds.length})</span>
<span>+{formatUSD(addonsSum)}</span>
</div>
<div className="h-px bg-zinc-200 dark:bg-white/10 my-2.5" />
<div className="flex items-center justify-between font-bold text-zinc-900 dark:text-white">
<span>Estimated total</span>
<span>~{formatUSD(total)}</span>
</div>
<p className="text-[11px] text-zinc-400 mt-1.5">
Preliminary verified at inspection. Add-ons can be revised with your rep.
</p>
</div>
</div>
);
};
export default AddonsStep;
@@ -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 (
<div className="space-y-4">
<GoodBetterBestCards
packages={tenantConfig.packages}
pricing={tenantConfig.pricing}
selectedType={session.selectedPackage}
highlightedType={session.recommendation?.selectedPackage}
onSelect={handleSelect}
/>
<div className="rounded-xl bg-zinc-50 dark:bg-white/5 p-3.5 flex items-start gap-2 text-xs text-zinc-500 dark:text-zinc-400">
<Info size={14} className="mt-0.5 shrink-0" />
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.
</div>
</div>
);
};
export default OptionsStep;
@@ -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 ───────────────
@@ -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')}`;
}