Skip to content

Commit d337086

Browse files
committed
feat: add selections summary panel and improve stepper navigation
- Added SelectionsSummary component to display user's choices in real-time across all steps - Implemented clickable step indicators in QuoteStepper allowing navigation to previously visited steps - Added aria-pressed attributes to all interactive buttons for better accessibility - Enhanced QuoteStepper with optional helper text support for contextual guidance - Minor text improvement: changed "like" to "such as" in budget mismatch message
1 parent c437e5e commit d337086

9 files changed

Lines changed: 181 additions & 20 deletions

File tree

app/hire-me/page.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { TimelineStep } from "src/features/hireme/components/TimelineStep";
1010
import { BudgetStep } from "src/features/hireme/components/BudgetStep";
1111
import { ContactStep } from "src/features/hireme/components/ContactStep";
1212
import { PriceSummary } from "src/features/hireme/components/PriceSummary";
13+
import { SelectionsSummary } from "src/features/hireme/components/SelectionsSummary";
1314

1415
export default function HireMePage() {
1516
const {
@@ -29,6 +30,7 @@ export default function HireMePage() {
2930
buildPayload,
3031
nextStep,
3132
prevStep,
33+
goToStep,
3234
} = useQuoteBuilder();
3335

3436
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -39,6 +41,10 @@ export default function HireMePage() {
3941
return roundedFinalPrice >= range.min && roundedFinalPrice <= max;
4042
}) ?? null;
4143

44+
const projectTypeLabel = state.projectType
45+
? projectTypes.find((type) => type.id === state.projectType)?.label ?? null
46+
: null;
47+
4248
async function handleSubmit() {
4349
try {
4450
setIsSubmitting(true);
@@ -64,6 +70,7 @@ export default function HireMePage() {
6470
estimatedTotal={roundedFinalPrice}
6571
onNext={nextStep}
6672
onPrev={prevStep}
73+
onStepChange={goToStep}
6774
canGoNext={
6875
state.currentStep === 6
6976
? false
@@ -85,6 +92,15 @@ export default function HireMePage() {
8592
budgetMismatch={state.budgetMismatch}
8693
/>
8794

95+
<SelectionsSummary
96+
projectType={state.projectType}
97+
projectTypeLabel={projectTypeLabel}
98+
selectedFeatures={state.selectedFeatures}
99+
complexity={state.complexity}
100+
timeline={state.timeline}
101+
budgetRange={state.budgetRange}
102+
/>
103+
88104
{state.currentStep === 1 && (
89105
<ProjectTypeStep
90106
projectTypes={projectTypes}

src/features/hireme/components/BudgetStep.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export function BudgetStep({
4747
? "border-green-500 bg-green-500/10 text-neutral-100"
4848
: "border-neutral-800 bg-neutral-950/40 text-neutral-200 hover:border-neutral-700"
4949
}`}
50+
aria-pressed={isActive}
5051
>
5152
<span className="font-semibold flex items-center gap-1">
5253
{range.label}
@@ -72,6 +73,7 @@ export function BudgetStep({
7273
className={`flex flex-col items-start rounded-lg border px-4 py-3 text-left text-sm transition focus:outline-none focus:ring-2 focus:ring-green-500 ${
7374
!selectedRange ? "border-green-500 bg-green-500/10 text-neutral-100" : "border-neutral-800 bg-neutral-950/40 text-neutral-200 hover:border-neutral-700"
7475
}`}
76+
aria-pressed={!selectedRange}
7577
>
7678
<span className="font-semibold">Not sure yet</span>
7779
<span className="mt-1 text-xs text-neutral-400">
@@ -82,7 +84,7 @@ export function BudgetStep({
8284

8385
{budgetMismatch && selectedRange && (
8486
<p className="text-xs text-amber-300">
85-
Your selections would typically land above this range. I&apos;ll suggest options like
87+
Your selections would typically land above this range. I&apos;ll suggest options such as
8688
prioritising MVP features or using a more flexible timeline.
8789
</p>
8890
)}

src/features/hireme/components/ComplexityStep.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export function ComplexityStep({ projectType, selected, onSelect }: ComplexitySt
7777
? "border-green-500 bg-green-500/10 text-neutral-100"
7878
: "border-neutral-800 bg-neutral-950/40 text-neutral-200 hover:border-neutral-700"
7979
}`}
80+
aria-pressed={isActive}
8081
>
8182
<span className="font-semibold flex items-center gap-1">
8283
{labels[level]}

src/features/hireme/components/FeaturesStep.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export function FeaturesStep({
7272
? "border-green-500 bg-green-500/10 text-neutral-100"
7373
: "border-neutral-800 bg-neutral-950/40 text-neutral-200 hover:border-neutral-700"
7474
}`}
75+
aria-pressed={checked}
7576
>
7677
<span className="font-medium">{feature.label}</span>
7778
{feature.description && (

src/features/hireme/components/ProjectTypeStep.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export function ProjectTypeStep({ projectTypes, selectedId, onSelect }: ProjectT
2626
? "border-green-500 bg-green-500/10 text-neutral-100"
2727
: "border-neutral-800 bg-neutral-950/40 text-neutral-200 hover:border-neutral-700"
2828
}`}
29+
aria-pressed={isActive}
2930
>
3031
<span className="font-semibold">{type.label}</span>
3132
<span className="mt-1 text-xs text-neutral-400">{type.description}</span>

src/features/hireme/components/QuoteStepper.tsx

Lines changed: 57 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ interface QuoteStepperProps {
1010
onNext: () => void;
1111
onPrev: () => void;
1212
canGoNext: boolean;
13+
helperText?: string | null;
14+
onStepChange?: (step: number) => void;
1315
}
1416

1517
export function QuoteStepper({
@@ -20,6 +22,8 @@ export function QuoteStepper({
2022
onNext,
2123
onPrev,
2224
canGoNext,
25+
helperText,
26+
onStepChange,
2327
}: QuoteStepperProps) {
2428
return (
2529
<section className="space-y-6">
@@ -31,32 +35,67 @@ export function QuoteStepper({
3135
</p>
3236
</header>
3337

34-
<div className="flex items-center justify-between text-xs text-neutral-500">
35-
<span>
36-
Step {currentStep} of {totalSteps}
37-
</span>
38-
<span>Estimated total: €{estimatedTotal.toLocaleString()}</span>
38+
<div className="flex flex-col gap-2 text-xs text-neutral-500">
39+
<div className="flex items-center justify-between">
40+
<span>
41+
Step {currentStep} of {totalSteps}
42+
</span>
43+
<span>Estimated total: €{estimatedTotal.toLocaleString()}</span>
44+
</div>
45+
<div className="flex gap-1" aria-label="Quote builder steps">
46+
{Array.from({ length: totalSteps }, (_, index) => {
47+
const stepNumber = index + 1;
48+
const isCurrent = stepNumber === currentStep;
49+
const isVisited = stepNumber <= currentStep;
50+
51+
const canClick = !!onStepChange && stepNumber <= currentStep;
52+
53+
return (
54+
<button
55+
key={stepNumber}
56+
type="button"
57+
onClick={canClick ? () => onStepChange(stepNumber) : undefined}
58+
disabled={!canClick}
59+
aria-current={isCurrent ? "step" : undefined}
60+
className={`flex-1 rounded-full border px-0.5 py-1 text-[10px] font-medium transition focus:outline-none focus:ring-1 focus:ring-green-500 ${
61+
isCurrent
62+
? "border-green-500 bg-green-500/20 text-green-200"
63+
: isVisited
64+
? "border-neutral-700 bg-neutral-900 text-neutral-200 hover:border-neutral-500"
65+
: "border-neutral-900 bg-neutral-950 text-neutral-600"
66+
}`}
67+
>
68+
{stepNumber}
69+
</button>
70+
);
71+
})}
72+
</div>
3973
</div>
4074

4175
<div>{children}</div>
4276

43-
<footer className="flex justify-between border-t border-neutral-800 pt-4">
44-
<button
77+
<footer className="flex flex-col gap-2 border-t border-neutral-800 pt-4">
78+
{helperText && (
79+
<p className="text-[11px] text-neutral-500">{helperText}</p>
80+
)}
81+
<div className="flex justify-between">
82+
<button
4583
type="button"
4684
onClick={onPrev}
4785
disabled={currentStep === 1}
4886
className="rounded-md border border-neutral-700 px-3 py-2 text-xs font-medium text-neutral-300 transition hover:border-neutral-500 disabled:cursor-not-allowed disabled:border-neutral-900 disabled:text-neutral-600"
49-
>
50-
Back
51-
</button>
52-
<button
53-
type="button"
54-
onClick={onNext}
55-
disabled={!canGoNext}
56-
className="rounded-md bg-green-500 px-4 py-2 text-xs font-semibold text-white shadow-sm transition hover:bg-green-600 disabled:cursor-not-allowed disabled:bg-neutral-700"
57-
>
58-
Continue
59-
</button>
87+
>
88+
Back
89+
</button>
90+
<button
91+
type="button"
92+
onClick={onNext}
93+
disabled={!canGoNext}
94+
className="rounded-md bg-green-500 px-4 py-2 text-xs font-semibold text-white shadow-sm transition hover:bg-green-600 disabled:cursor-not-allowed disabled:bg-neutral-700"
95+
>
96+
Continue
97+
</button>
98+
</div>
6099
</footer>
61100
</section>
62101
);
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"use client";
2+
3+
import { BudgetRangeDefinition, ComplexityLevel, FeatureDefinition, ProjectType, TimelineChoice } from "../types";
4+
5+
interface SelectionsSummaryProps {
6+
projectType: ProjectType | null;
7+
projectTypeLabel: string | null;
8+
selectedFeatures: FeatureDefinition[];
9+
complexity: ComplexityLevel;
10+
timeline: TimelineChoice;
11+
budgetRange: BudgetRangeDefinition | null;
12+
}
13+
14+
function formatTimelineLabel(timeline: TimelineChoice): string {
15+
switch (timeline) {
16+
case "rush":
17+
return "Rush";
18+
case "standard":
19+
return "Standard";
20+
case "flexible":
21+
return "Flexible";
22+
case "extended":
23+
return "Extended";
24+
case "not-sure":
25+
default:
26+
return "Not sure yet";
27+
}
28+
}
29+
30+
export function SelectionsSummary({
31+
projectType,
32+
projectTypeLabel,
33+
selectedFeatures,
34+
complexity,
35+
timeline,
36+
budgetRange,
37+
}: SelectionsSummaryProps) {
38+
const featureCount = selectedFeatures.length;
39+
const visibleFeatures = selectedFeatures
40+
.filter((feature) => !feature.isBase)
41+
.slice(0, 3);
42+
const remainingFeatures = Math.max(0, selectedFeatures.filter((f) => !f.isBase).length - visibleFeatures.length);
43+
44+
return (
45+
<section className="space-y-2 rounded-lg border border-neutral-800 bg-neutral-950/40 p-3 text-[11px] text-neutral-300">
46+
<div className="flex items-center justify-between">
47+
<span className="font-semibold text-neutral-100">Your choices so far</span>
48+
{projectType && (
49+
<span className="rounded-full border border-neutral-700 px-2 py-0.5 text-[10px] uppercase tracking-wide text-neutral-300">
50+
Step preview
51+
</span>
52+
)}
53+
</div>
54+
55+
{!projectType && (
56+
<p className="text-[11px] text-neutral-500">
57+
Start by choosing what you need built. I&apos;ll keep a running summary of your choices here.
58+
</p>
59+
)}
60+
61+
{projectType && (
62+
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
63+
<div className="space-y-1">
64+
<div className="flex items-center justify-between">
65+
<span className="text-neutral-400">Project type</span>
66+
<span className="font-medium text-neutral-100">{projectTypeLabel}</span>
67+
</div>
68+
<div className="flex items-center justify-between">
69+
<span className="text-neutral-400">Features</span>
70+
<span className="font-medium text-neutral-100">{featureCount} selected</span>
71+
</div>
72+
{visibleFeatures.length > 0 && (
73+
<p className="text-[11px] text-neutral-500">
74+
{visibleFeatures.map((feature) => feature.label).join(", ")}
75+
{remainingFeatures > 0 && ` +${remainingFeatures} more`}
76+
</p>
77+
)}
78+
</div>
79+
80+
<div className="space-y-1">
81+
<div className="flex items-center justify-between">
82+
<span className="text-neutral-400">Complexity</span>
83+
<span className="font-medium text-neutral-100 capitalize">{complexity}</span>
84+
</div>
85+
<div className="flex items-center justify-between">
86+
<span className="text-neutral-400">Timeline</span>
87+
<span className="font-medium text-neutral-100">{formatTimelineLabel(timeline)}</span>
88+
</div>
89+
<div className="flex items-center justify-between">
90+
<span className="text-neutral-400">Budget</span>
91+
<span className="font-medium text-neutral-100">
92+
{budgetRange ? budgetRange.label : "Not set yet"}
93+
</span>
94+
</div>
95+
</div>
96+
</div>
97+
)}
98+
</section>
99+
);
100+
}

src/features/hireme/components/TimelineStep.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export function TimelineStep({
8585
? "border-green-500 bg-green-500/10 text-neutral-100"
8686
: "border-neutral-800 bg-neutral-950/40 text-neutral-200 hover:border-neutral-700"
8787
}`}
88+
aria-pressed={isActive}
8889
>
8990
<span className="font-semibold">{option.label}</span>
9091
<span className="mt-1 text-xs text-neutral-400">{option.description}</span>

src/features/hireme/docs/TODO.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ Create reusable components under `src/features/hireme/components`:
3737

3838
- [x] **Step navigation / layout**
3939
- [x] `QuoteStepper` shell that renders the current step and controls navigation.
40-
- [x] Progress indicator ("Step X of 6").
40+
- [x] Progress indicator ("Step X of 6") with clickable step numbers (back/visited steps).
4141
- [x] Live price summary panel (non-sticky version).
4242

4343
- [x] **Step 1 – Project Type**

0 commit comments

Comments
 (0)