|
| 1 | +--- |
| 2 | +name: frontend-build-components |
| 3 | +description: Step-by-step guide for building front-end React components and pages in this Next.js + Tailwind app. Use when adding a UI primitive, a shared component, a feature component, or a frontend page (anything under src/components or src/app/[locale]/(frontend)). Do NOT use for Payload admin views - use payload-build-modules instead. |
| 4 | +--- |
| 5 | + |
| 6 | +# Front-end — building components and pages |
| 7 | + |
| 8 | +Front-end code lives under `src/components/` (React components) and `src/app/[locale]/(frontend)/` (routed pages). Styling is Tailwind with design tokens; every reusable class string is centralised in `src/lib/class-names.ts`. Text goes through `next-intl`. |
| 9 | + |
| 10 | +The single most important decision is **where a component belongs**. Get that right and the rest follows. |
| 11 | + |
| 12 | +--- |
| 13 | + |
| 14 | +## Where does it go? (decision tree) |
| 15 | + |
| 16 | +``` |
| 17 | +Is it a generic primitive with ZERO domain knowledge |
| 18 | +(Button, Input, Alert, Field, Logo, PageHeader)? |
| 19 | + └── yes → src/components/ui/{name}/ |
| 20 | +
|
| 21 | +Is it app-specific but shared across unrelated features |
| 22 | +(LogoutButton)? |
| 23 | + └── yes → src/components/common/{name}/ |
| 24 | +
|
| 25 | +Is it tied to one domain feature (workout, plan, ...)? |
| 26 | + └── yes → src/components/{domain}/{feature}/ |
| 27 | +
|
| 28 | +Is it a routed screen? |
| 29 | + └── yes → src/app/[locale]/(frontend)/{route}/page.tsx |
| 30 | +``` |
| 31 | + |
| 32 | +Rule of thumb: if it imports a domain type (`TExercise`, `SetLog`, `MetricField`) or a domain helper (`@/lib/metrics`), it does **not** belong in `ui/`. |
| 33 | + |
| 34 | +--- |
| 35 | + |
| 36 | +## Structure per location |
| 37 | + |
| 38 | +### `ui/` and `common/` — one folder + barrel per component |
| 39 | + |
| 40 | +``` |
| 41 | +src/components/ui/{name}/ |
| 42 | +├── {name}.tsx # the component |
| 43 | +└── index.ts # export { Name } from './{name}' |
| 44 | +``` |
| 45 | + |
| 46 | +Real examples: `ui/button`, `ui/input` (exports `Input` and `Select`), `ui/field`, `ui/page-header`, `common/logout-button`. |
| 47 | + |
| 48 | +### `{domain}/{feature}/` — main file + `components/` + hooks |
| 49 | + |
| 50 | +``` |
| 51 | +src/components/{domain}/{feature}/ |
| 52 | +├── {feature}.tsx # main component (composer / orchestrator) |
| 53 | +├── index.ts # export { Feature } from './{feature}' |
| 54 | +├── hooks/ # (optional) feature-level hooks, one file each |
| 55 | +│ └── use-{feature}-{type}.ts |
| 56 | +└── components/ # every sub-component gets its own folder + barrel |
| 57 | + └── {sub-component}/ |
| 58 | + ├── {sub-component}.tsx |
| 59 | + └── index.ts |
| 60 | +``` |
| 61 | + |
| 62 | +Real example — `workout/exercise-card/`: |
| 63 | +``` |
| 64 | +exercise-card.tsx # composer: header + meta + list + actions |
| 65 | +index.ts |
| 66 | +components/ |
| 67 | +├── exercise-header/ |
| 68 | +│ ├── exercise-header.tsx |
| 69 | +│ └── index.ts |
| 70 | +├── add-set-actions/ |
| 71 | +│ ├── add-set-actions.tsx |
| 72 | +│ └── index.ts |
| 73 | +├── series-list/ |
| 74 | +│ ├── series-list.tsx |
| 75 | +│ └── index.ts |
| 76 | +└── meta-line/ |
| 77 | + ├── meta-line.tsx |
| 78 | + └── index.ts |
| 79 | +``` |
| 80 | + |
| 81 | +Real example — `workout/workout-plans/`: |
| 82 | +``` |
| 83 | +workout-plans.tsx |
| 84 | +index.ts |
| 85 | +hooks/use-workout-selection.ts |
| 86 | +components/ |
| 87 | +├── active-context-banner/ |
| 88 | +│ ├── active-context-banner.tsx |
| 89 | +│ └── index.ts |
| 90 | +└── workout-pickers/ # one folder may export a few closely-related components |
| 91 | + ├── workout-pickers.tsx # MicrocyclePicker, WorkoutPicker |
| 92 | + └── index.ts |
| 93 | +``` |
| 94 | + |
| 95 | +**Rule:** every React component gets its own kebab-case folder with an `index.ts` barrel - in `ui/`, in `common/`, and inside a feature's `components/`. The only `.tsx` allowed directly at a feature root is the `{feature}.tsx` entry itself. No flat sub-component files. (Mirrors `payload-build-modules`: one folder per component.) |
| 96 | + |
| 97 | +Sub-component-specific hooks go in `components/{sub-component}/hooks/`; hooks for the feature entry stay in `{feature}/hooks/`. |
| 98 | + |
| 99 | +--- |
| 100 | + |
| 101 | +## Steps |
| 102 | + |
| 103 | +### 1. Pick the location (decision tree above) and create the folder |
| 104 | + |
| 105 | +`ui`/`common`: `{name}/{name}.tsx` + `index.ts`. Feature: add `components/{name}/{name}.tsx` + `index.ts` in the existing feature folder, or a new `{feature}/` folder with `{feature}.tsx` + `index.ts`. |
| 106 | + |
| 107 | +### 2. Style via `class-names.ts`, not inline strings |
| 108 | + |
| 109 | +Reusable class strings live in `src/lib/class-names.ts`. Compose with `joinClasses`. Prefer a typed `variant` prop over passing class strings around. |
| 110 | + |
| 111 | +```tsx |
| 112 | +// src/components/ui/button/button.tsx |
| 113 | +import { joinClasses, primaryButtonClass, secondaryButtonClass } from '@/lib/class-names' |
| 114 | + |
| 115 | +type Variant = 'primary' | 'secondary' |
| 116 | +const variantClass: Record<Variant, string> = { |
| 117 | + primary: primaryButtonClass, |
| 118 | + secondary: secondaryButtonClass, |
| 119 | +} |
| 120 | + |
| 121 | +export function Button({ className, variant = 'primary', ...props }: ButtonProps) { |
| 122 | + return <button className={joinClasses(variantClass[variant], className)} {...props} /> |
| 123 | +} |
| 124 | +``` |
| 125 | + |
| 126 | +When a class string is repeated in 2+ places, promote it to `class-names.ts`. When a layout/markup block repeats, extract a component instead. |
| 127 | + |
| 128 | +### 3. Server page → loader → client component |
| 129 | + |
| 130 | +Pages are **server components**. They fetch through a loader in `src/loaders/`, then pass plain data to client components. Never query inside the JSX. |
| 131 | + |
| 132 | +```tsx |
| 133 | +// src/app/[locale]/(frontend)/page.tsx (server) |
| 134 | +import { getTranslations } from 'next-intl/server' |
| 135 | +import { loadTrainingPlans } from '@/loaders/training-plan-loader' |
| 136 | +import { PageContainer } from '@/components/ui/page-container' |
| 137 | +import { PageHeader } from '@/components/ui/page-header' |
| 138 | +import { WorkoutPlans } from '@/components/workout/workout-plans' |
| 139 | + |
| 140 | +export default async function HomePage() { |
| 141 | + const t = await getTranslations('home') |
| 142 | + const result = await loadTrainingPlans() |
| 143 | + if (!result.user) redirect('/login') |
| 144 | + |
| 145 | + return ( |
| 146 | + <PageContainer> |
| 147 | + <PageHeader title={t('greeting', { name: result.user.name ?? '' })} right={<LogoutButton />} /> |
| 148 | + {result.plans.length > 0 ? ( |
| 149 | + <WorkoutPlans plans={result.plans} /> |
| 150 | + ) : ( |
| 151 | + <div className="py-10 text-center text-sm text-ui-fg-muted">{t('noPlans')}</div> |
| 152 | + )} |
| 153 | + </PageContainer> |
| 154 | + ) |
| 155 | +} |
| 156 | +``` |
| 157 | + |
| 158 | +Loaders are plain async functions returning a typed result (e.g. a discriminated union for auth state). Reuse `PageContainer` / `PageHeader` for every page shell. |
| 159 | + |
| 160 | +### 4. Client components own UI state; mutations go through `sdk` |
| 161 | + |
| 162 | +```tsx |
| 163 | +'use client' |
| 164 | +import { useState } from 'react' |
| 165 | +``` |
| 166 | + |
| 167 | +A component is `'use client'` only when it needs state, effects, or event handlers. Data mutations use `@/lib/sdk`. When a component has complex state or 2+ mutations, extract a hook (step 5). |
| 168 | + |
| 169 | +### 5. Extract a hook when state/mutations get heavy |
| 170 | + |
| 171 | +Place it in `hooks/` inside the feature folder: `components/{domain}/{feature}/hooks/use-{feature}-{type}.ts`. |
| 172 | + |
| 173 | +```ts |
| 174 | +// workout/workout-tracker/hooks/use-workout-session.ts |
| 175 | +'use client' |
| 176 | +export function useWorkoutSession(workout: TWorkout, opts: { readOnly?: boolean; showResults?: boolean }) { |
| 177 | + // session state, derived selectors, addSet/updateSet/deleteSet |
| 178 | + return { session, error, addSet, updateSet, deleteSet /* ... */ } |
| 179 | +} |
| 180 | +``` |
| 181 | + |
| 182 | +Trigger: extract a hook when there are >= 2 mutations sharing loading/error state, or non-trivial derived state. A single piece of `useState` stays inline. |
| 183 | + |
| 184 | +### 6. Text and dates through `next-intl` |
| 185 | + |
| 186 | +- Client component: `const t = useTranslations('namespace')`. |
| 187 | +- Server component: `const t = await getTranslations('namespace')`; dates via `const format = await getFormatter()` (never hardcode a locale like `'pl-PL'`). |
| 188 | +- Add keys to BOTH `messages/pl.json` and `messages/en.json`. |
| 189 | +- Plain ASCII in UI strings (see the `code-style` skill). |
| 190 | + |
| 191 | +--- |
| 192 | + |
| 193 | +## Key rules |
| 194 | + |
| 195 | +| Rule | Detail | |
| 196 | +|---|---| |
| 197 | +| Location first | `ui/` (generic) vs `common/` (shared app) vs `{domain}/{feature}/` (domain) | |
| 198 | +| No domain deps in `ui/` | If it imports a domain type/helper, it is not a UI primitive | |
| 199 | +| One folder + barrel per component | `{name}/{name}.tsx` + `index.ts` everywhere - `ui/`, `common/`, and feature `components/{name}/` | |
| 200 | +| Only `{feature}.tsx` at a feature root | All sub-components live under `{feature}/components/{name}/` - never a flat sub-component `.tsx` | |
| 201 | +| Classes in `class-names.ts` | Promote any class string reused 2+ times; use `joinClasses` + `variant` props | |
| 202 | +| Pages = server + loader | Fetch in `src/loaders/`, render with `PageContainer`/`PageHeader`; never query in JSX | |
| 203 | +| `'use client'` only when needed | State, effects, handlers - otherwise keep it a server component | |
| 204 | +| Hooks inside the feature | `{feature}/hooks/use-*.ts`, not a global hooks folder | |
| 205 | +| i18n both locales | Every key in `messages/pl.json` AND `messages/en.json`; dates via `getFormatter` | |
| 206 | + |
| 207 | +--- |
| 208 | + |
| 209 | +## Naming conventions |
| 210 | + |
| 211 | +| What | Convention | Example | |
| 212 | +|---|---|---| |
| 213 | +| Component folder / file | `kebab-case` | `page-header/`, `exercise-header.tsx` | |
| 214 | +| Exported component | `PascalCase` | `PageHeader`, `ExerciseCard` | |
| 215 | +| Barrel | `index.ts` re-export | `export { Field } from './field'` | |
| 216 | +| Hook file / function | `use-{feature}-{type}.ts` / `use{Feature}{Type}` | `use-workout-session.ts` / `useWorkoutSession` | |
| 217 | +| Loader function | `load{Feature}` camelCase | `loadTrainingPlans` | |
| 218 | +| Variant prop | `variant` union, mapped to class via `Record` | `variant: 'primary' \| 'secondary'` | |
| 219 | +| Class-name export | `{name}Class` / `{name}Class(arg)` | `primaryButtonClass`, `statusBadgeClass(status)` | |
0 commit comments