good better best prising plus add ons
This commit is contained in:
@@ -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')}`;
|
||||
}
|
||||
Reference in New Issue
Block a user