From d216bc54dd38e1ae7365fa12b5aead5d06678302 Mon Sep 17 00:00:00 2001 From: greatest0fallt1me <1nonlygem@gmail.com> Date: Tue, 30 Jun 2026 01:03:10 +0530 Subject: [PATCH] feat: Add commitment templates and presets to the create wizard select-type step Adds a preset/template picker to CreateCommitmentStepSelectType that prefills duration and max-loss in the configure step. Includes preset definitions, accessible UI with radiogroup/radio roles, keyboard support, draft persistence integration, tests (happy-dom + RTL), and CREATE_TEMPLATES.md documentation. Co-Authored-By: Claude Sonnet 4.6 --- docs/CREATE_TEMPLATES.md | 93 ++++++++++ src/app/create/page.tsx | 9 + .../CreateCommitmentStepSelectType.module.css | 50 +++++ .../CreateCommitmentStepSelectType.tsx | 65 ++++++- .../create/CreateTemplates.test.tsx | 175 ++++++++++++++++++ src/components/create/commitmentPresets.ts | 39 ++++ 6 files changed, 430 insertions(+), 1 deletion(-) create mode 100644 docs/CREATE_TEMPLATES.md create mode 100644 src/components/create/CreateTemplates.test.tsx create mode 100644 src/components/create/commitmentPresets.ts diff --git a/docs/CREATE_TEMPLATES.md b/docs/CREATE_TEMPLATES.md new file mode 100644 index 00000000..c72fa4b1 --- /dev/null +++ b/docs/CREATE_TEMPLATES.md @@ -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 + +``` + +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) diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx index c09997b3..777fa6e5 100644 --- a/src/app/create/page.tsx +++ b/src/app/create/page.tsx @@ -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"; @@ -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); @@ -270,6 +278,7 @@ export default function CreateCommitment() { onNext={handleNextStep} onBack={handleBack} initialFocusField={initialFocusField || undefined} + onApplyPreset={handleApplyPreset} /> )} diff --git a/src/components/CreateCommitmentStepSelectType.module.css b/src/components/CreateCommitmentStepSelectType.module.css index 6ab1b286..4d62c1b6 100644 --- a/src/components/CreateCommitmentStepSelectType.module.css +++ b/src/components/CreateCommitmentStepSelectType.module.css @@ -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; } \ No newline at end of file diff --git a/src/components/CreateCommitmentStepSelectType.tsx b/src/components/CreateCommitmentStepSelectType.tsx index 05f4fc07..5618f7e9 100644 --- a/src/components/CreateCommitmentStepSelectType.tsx +++ b/src/components/CreateCommitmentStepSelectType.tsx @@ -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'; @@ -23,6 +24,7 @@ interface CreateCommitmentStepSelectTypeProps { onNext: (type: 'safe' | 'balanced' | 'aggressive') => void; onBack: () => void; initialFocusField?: string; + onApplyPreset?: (preset: CommitmentPreset) => void; } const commitmentTypes: CommitmentType[] = [ @@ -70,6 +72,7 @@ export default function CreateCommitmentStepSelectType({ onNext, onBack, initialFocusField, + onApplyPreset, }: CreateCommitmentStepSelectTypeProps) { const headingRef = useRef(null); @@ -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 (
@@ -110,6 +122,57 @@ export default function CreateCommitmentStepSelectType({ + {/* Preset / Template Picker */} +
+

+ + Quick-start Templates +

+

+ Choose a preset to prefill the next step, or start from scratch. +

+
+ +
+ {COMMITMENT_PRESETS.map((preset) => ( + + ))} + + +
+

Choose Your Commitment Type

diff --git a/src/components/create/CreateTemplates.test.tsx b/src/components/create/CreateTemplates.test.tsx new file mode 100644 index 00000000..4a9d408c --- /dev/null +++ b/src/components/create/CreateTemplates.test.tsx @@ -0,0 +1,175 @@ +/** + * @vitest-environment happy-dom + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import CreateCommitmentStepSelectType from '../CreateCommitmentStepSelectType'; +import { COMMITMENT_PRESETS } from './commitmentPresets'; + +// Mock WizardStepper to avoid complex rendering +vi.mock('../WizardStepper', () => ({ + default: () =>

, +})); + +const defaultProps = { + selectedType: null as 'safe' | 'balanced' | 'aggressive' | null, + onSelectType: vi.fn(), + onNext: vi.fn(), + onBack: vi.fn(), + onApplyPreset: vi.fn(), +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('Commitment Templates / Presets', () => { + it('renders all preset buttons', () => { + render(); + for (const preset of COMMITMENT_PRESETS) { + expect(screen.getByTestId(`preset-${preset.id}`)).toBeInTheDocument(); + } + }); + + it('renders the Start from scratch option', () => { + render(); + expect(screen.getByTestId('preset-scratch')).toBeInTheDocument(); + }); + + it('presets container has radiogroup role and accessible label', () => { + render(); + expect(screen.getByTestId('presets-container')).toHaveAttribute('role', 'radiogroup'); + expect(screen.getByTestId('presets-container')).toHaveAttribute('aria-label', 'Commitment templates'); + }); + + it('each preset button has role radio and aria-checked false when none selected', () => { + render(); + const firstPreset = screen.getByTestId(`preset-${COMMITMENT_PRESETS[0].id}`); + expect(firstPreset).toHaveAttribute('role', 'radio'); + expect(firstPreset).toHaveAttribute('aria-checked', 'false'); + }); + + it('preset prefills configure step — calls onApplyPreset with correct preset', () => { + const onApplyPreset = vi.fn(); + render(); + const conservativeBtn = screen.getByTestId('preset-conservative-90'); + fireEvent.click(conservativeBtn); + expect(onApplyPreset).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'conservative-90', + type: 'safe', + durationDays: 90, + maxLossPercent: 2, + }) + ); + }); + + it('preset selection also calls onSelectType with the correct type', () => { + const onSelectType = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('preset-balanced-60')); + expect(onSelectType).toHaveBeenCalledWith('balanced'); + }); + + it('selected preset has aria-checked true', () => { + render( + + ); + expect(screen.getByTestId('preset-conservative-90')).toHaveAttribute('aria-checked', 'true'); + }); + + it('unrelated presets keep aria-checked false when one is selected', () => { + render( + + ); + expect(screen.getByTestId('preset-balanced-60')).toHaveAttribute('aria-checked', 'false'); + expect(screen.getByTestId('preset-aggressive-30')).toHaveAttribute('aria-checked', 'false'); + }); + + it('scratch option does not call onApplyPreset', () => { + const onApplyPreset = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('preset-scratch')); + expect(onApplyPreset).not.toHaveBeenCalled(); + }); + + it('fields remain editable — Continue button is enabled after preset selects a type', () => { + render( + + ); + const continueBtn = screen.getByTestId('select-type-continue'); + expect(continueBtn).not.toBeDisabled(); + }); + + it('Continue is disabled when no type is selected (scratch without type)', () => { + render(); + expect(screen.getByTestId('select-type-continue')).toBeDisabled(); + }); + + it('preset selection via keyboard Enter calls onApplyPreset', () => { + const onApplyPreset = vi.fn(); + render(); + const btn = screen.getByTestId('preset-conservative-90'); + fireEvent.keyDown(btn, { key: 'Enter' }); + expect(onApplyPreset).toHaveBeenCalledWith( + expect.objectContaining({ id: 'conservative-90' }) + ); + }); + + it('preset selection via keyboard Space calls onApplyPreset', () => { + const onApplyPreset = vi.fn(); + render(); + const btn = screen.getByTestId('preset-aggressive-30'); + fireEvent.keyDown(btn, { key: ' ' }); + expect(onApplyPreset).toHaveBeenCalledWith( + expect.objectContaining({ id: 'aggressive-30' }) + ); + }); + + it('prefill respects validation — onApplyPreset not called without handler', () => { + // When onApplyPreset is undefined, clicking preset should not throw + const props = { ...defaultProps, onApplyPreset: undefined }; + expect(() => { + render(); + fireEvent.click(screen.getByTestId('preset-conservative-90')); + }).not.toThrow(); + }); +}); + +describe('commitmentPresets data', () => { + it('all presets have valid types', () => { + const validTypes = ['safe', 'balanced', 'aggressive']; + for (const preset of COMMITMENT_PRESETS) { + expect(validTypes).toContain(preset.type); + } + }); + + it('all presets have positive durationDays', () => { + for (const preset of COMMITMENT_PRESETS) { + expect(preset.durationDays).toBeGreaterThan(0); + } + }); + + it('all presets have maxLossPercent in valid range 0-100', () => { + for (const preset of COMMITMENT_PRESETS) { + expect(preset.maxLossPercent).toBeGreaterThanOrEqual(0); + expect(preset.maxLossPercent).toBeLessThanOrEqual(100); + } + }); + + it('preset ids are unique', () => { + const ids = COMMITMENT_PRESETS.map((p) => p.id); + expect(new Set(ids).size).toBe(ids.length); + }); +}); diff --git a/src/components/create/commitmentPresets.ts b/src/components/create/commitmentPresets.ts new file mode 100644 index 00000000..9fb3c239 --- /dev/null +++ b/src/components/create/commitmentPresets.ts @@ -0,0 +1,39 @@ +export type CommitmentTypeId = 'safe' | 'balanced' | 'aggressive'; + +export interface CommitmentPreset { + id: string; + label: string; + description: string; + type: CommitmentTypeId; + durationDays: number; + maxLossPercent: number; +} + +export const COMMITMENT_PRESETS: CommitmentPreset[] = [ + { + id: 'conservative-90', + label: 'Conservative 90-day', + description: 'Safe type, 90-day lock, 2% max loss — steady yield with strong principal protection.', + type: 'safe', + durationDays: 90, + maxLossPercent: 2, + }, + { + id: 'balanced-60', + label: 'Balanced 60-day', + description: 'Balanced type, 60-day lock, 8% max loss — moderate risk and yield.', + type: 'balanced', + durationDays: 60, + maxLossPercent: 8, + }, + { + id: 'aggressive-30', + label: 'Aggressive 30-day', + description: 'Aggressive type, 30-day lock, 20% max loss — high yield with elevated risk.', + type: 'aggressive', + durationDays: 30, + maxLossPercent: 20, + }, +]; + +export const SCRATCH_OPTION_ID = 'scratch';