Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions docs/CREATE_TEMPLATES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Create Wizard — Commitment Templates & Presets

## Overview

The **Select Type** step of the Create Commitment wizard includes a quick-start preset picker that prefills duration and max-loss values in the Configure step, reducing friction for new users while keeping all fields editable.

## Presets

Presets are defined in `src/components/create/commitmentPresets.ts`:

| Preset ID | Label | Type | Duration | Max Loss |
|---|---|---|---|---|
| `conservative-90` | Conservative 90-day | safe | 90 days | 2% |
| `balanced-60` | Balanced 60-day | balanced | 60 days | 8% |
| `aggressive-30` | Aggressive 30-day | aggressive | 30 days | 20% |

A **Start from scratch** option lets users pick a type and configure every field themselves.

## Props / API

### `CreateCommitmentStepSelectType`

New optional prop:

```ts
onApplyPreset?: (preset: CommitmentPreset) => void;
```

Called when a preset button is activated. The parent (`src/app/create/page.tsx`) uses this to update `durationDays` and `maxLossPercent` state before the user proceeds to the Configure step.

### `CommitmentPreset` (from `commitmentPresets.ts`)

```ts
interface CommitmentPreset {
id: string;
label: string;
description: string;
type: 'safe' | 'balanced' | 'aggressive';
durationDays: number;
maxLossPercent: number;
}
```

## Behavior

1. User sees the preset strip above the existing type cards.
2. Clicking a preset calls `onSelectType(preset.type)` **and** `onApplyPreset(preset)`.
3. The parent applies the preset values to `durationDays` and `maxLossPercent` state.
4. The Configure step opens pre-populated; every field remains editable.
5. Selecting a type card directly (without a preset) leaves Configure fields at their current defaults.
6. Draft persistence (`useDraftPersistence`) captures preset-prefilled values automatically since they flow through the same state variables.

## Accessibility

- The presets container uses `role="radiogroup"` with `aria-label="Commitment templates"`.
- Each preset button uses `role="radio"` and `aria-checked` reflecting selection state.
- Keyboard: `Enter` and `Space` activate a preset (default submit prevented).
- No animation is added, so `prefers-reduced-motion` is unaffected.

## Usage Example

```tsx
<CreateCommitmentStepSelectType
selectedType={selectedType}
onSelectType={handleSelectType}
onNext={handleNextStep}
onBack={handleBack}
onApplyPreset={handleApplyPreset}
/>
```

Where `handleApplyPreset` in the page:

```ts
const handleApplyPreset = (preset: CommitmentPreset) => {
setSelectedType(preset.type);
setCommitmentType(preset.type);
setDurationDays(preset.durationDays);
setMaxLossPercent(preset.maxLossPercent);
};
```

## Testing

Tests live in `src/components/create/CreateTemplates.test.tsx` (Jest/Vitest + React Testing Library, happy-dom). They cover:

- Rendering all presets and the scratch option
- Accessibility roles and aria attributes
- Preset prefill calling `onApplyPreset` with correct values
- Keyboard activation (Enter / Space)
- Scratch option not calling `onApplyPreset`
- Continue button enabled/disabled state
- `commitmentPresets` data validity (types, ranges, uniqueness)
9 changes: 9 additions & 0 deletions src/app/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import ResumeDraftPrompt from "@/components/create/ResumeDraftPrompt";
import { useGuidedTour } from "@/hooks/useGuidedTour";
import { GuidedTour } from "@/components/onboarding/GuidedTour";
import { HelpCircle } from "lucide-react";
import { type CommitmentPreset } from "@/components/create/commitmentPresets";

type CommitmentType = "safe" | "balanced" | "aggressive";

Expand Down Expand Up @@ -180,6 +181,13 @@ export default function CreateCommitment() {
setCommitmentType(type);
};

const handleApplyPreset = (preset: CommitmentPreset) => {
setSelectedType(preset.type);
setCommitmentType(preset.type);
setDurationDays(preset.durationDays);
setMaxLossPercent(preset.maxLossPercent);
};

const handleNextStep = () => {
if (step < 3) {
setStep(step + 1);
Expand Down Expand Up @@ -270,6 +278,7 @@ export default function CreateCommitment() {
onNext={handleNextStep}
onBack={handleBack}
initialFocusField={initialFocusField || undefined}
onApplyPreset={handleApplyPreset}
/>
)}

Expand Down
50 changes: 50 additions & 0 deletions src/components/CreateCommitmentStepSelectType.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -378,4 +378,54 @@
color: #99A1AF;
cursor: not-allowed;
opacity: 0.6;
}

/* Preset / Template Picker */
.presetsContainer {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 2.5rem;
justify-content: center;
}

.presetBtn {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
padding: 0.75rem 1.25rem;
border-radius: 0.625rem;
border: 1.5px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.04);
color: #d1d5db;
cursor: pointer;
font-size: 0.85rem;
text-align: left;
transition: border-color 0.2s, background 0.2s;
min-width: 13rem;
max-width: 18rem;
}

.presetBtn:hover {
border-color: rgba(20, 184, 166, 0.5);
background: rgba(20, 184, 166, 0.07);
}

.presetBtnSelected {
border-color: #14b8a6;
background: rgba(20, 184, 166, 0.12);
color: #ffffff;
}

.presetLabel {
font-weight: 600;
font-size: 0.9rem;
color: #ffffff;
}

.presetDesc {
font-size: 0.78rem;
color: #9ca3af;
line-height: 1.4;
}
65 changes: 64 additions & 1 deletion src/components/CreateCommitmentStepSelectType.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
'use client';
import { useRef, useEffect } from 'react';
import { Shield, TrendingUp, Flame, ArrowRight, ChevronLeft, Info } from 'lucide-react';
import { Shield, TrendingUp, Flame, ArrowRight, ChevronLeft, Info, Zap } from 'lucide-react';
import WizardStepper from './WizardStepper';
import styles from './CreateCommitmentStepSelectType.module.css';
import { COMMITMENT_PRESETS, SCRATCH_OPTION_ID, type CommitmentPreset } from './create/commitmentPresets';

interface CommitmentType {
id: 'safe' | 'balanced' | 'aggressive';
Expand All @@ -23,6 +24,7 @@ interface CreateCommitmentStepSelectTypeProps {
onNext: (type: 'safe' | 'balanced' | 'aggressive') => void;
onBack: () => void;
initialFocusField?: string;
onApplyPreset?: (preset: CommitmentPreset) => void;
}

const commitmentTypes: CommitmentType[] = [
Expand Down Expand Up @@ -70,6 +72,7 @@ export default function CreateCommitmentStepSelectType({
onNext,
onBack,
initialFocusField,
onApplyPreset,
}: CreateCommitmentStepSelectTypeProps) {
const headingRef = useRef<HTMLHeadingElement>(null);

Expand All @@ -93,6 +96,15 @@ export default function CreateCommitmentStepSelectType({
}
};

const handlePresetSelect = (preset: CommitmentPreset) => {
onSelectType(preset.type);
onApplyPreset?.(preset);
};

const handleScratchSelect = () => {
// Just select without prefilling — user configures from defaults
};

return (
<div className={styles.container}>
<div className={styles.contentWrapper}>
Expand All @@ -110,6 +122,57 @@ export default function CreateCommitmentStepSelectType({

<WizardStepper currentStep={1} />

{/* Preset / Template Picker */}
<div className={styles.titleSection}>
<h2 className={styles.sectionTitle} tabIndex={-1}>
<Zap size={18} style={{ display: 'inline', marginRight: '0.4rem', verticalAlign: 'middle' }} />
Quick-start Templates
</h2>
<p className={styles.sectionSubtitle}>
Choose a preset to prefill the next step, or start from scratch.
</p>
</div>

<div
role="radiogroup"
aria-label="Commitment templates"
className={styles.presetsContainer}
data-testid="presets-container"
>
{COMMITMENT_PRESETS.map((preset) => (
<button
key={preset.id}
type="button"
role="radio"
aria-checked={selectedType === preset.type}
data-testid={`preset-${preset.id}`}
className={`${styles.presetBtn} ${selectedType === preset.type ? styles.presetBtnSelected : ''}`}
onClick={() => handlePresetSelect(preset)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handlePresetSelect(preset);
}
}}
>
<span className={styles.presetLabel}>{preset.label}</span>
<span className={styles.presetDesc}>{preset.description}</span>
</button>
))}

<button
type="button"
role="radio"
aria-checked={false}
data-testid={`preset-${SCRATCH_OPTION_ID}`}
className={styles.presetBtn}
onClick={handleScratchSelect}
>
<span className={styles.presetLabel}>Start from scratch</span>
<span className={styles.presetDesc}>Pick a type below and configure every field yourself.</span>
</button>
</div>

<div className={styles.titleSection}>
<h2 ref={headingRef} tabIndex={-1} className={styles.sectionTitle}>Choose Your Commitment Type</h2>
<p className={styles.sectionSubtitle}>
Expand Down
Loading