rbin-task-flow 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.cursor/rules/coding_standards.mdc +712 -0
- package/.cursor/rules/git_control.mdc +6 -5
- package/.cursor/rules/task_audit.mdc +158 -0
- package/.cursor/rules/task_execution.mdc +2 -0
- package/.cursor/rules/task_generation.mdc +37 -28
- package/.cursor/rules/task_work.mdc +11 -10
- package/.cursor/settings.json +1 -3
- package/.model-versions.json +1 -17
- package/.task-flow/contexts/example.png.txt +29 -0
- package/.task-flow/tasks.input.txt +6 -5
- package/CLAUDE.md +9 -0
- package/README.md +21 -64
- package/bin/cli.js +2 -2
- package/lib/install.js +6 -47
- package/lib/version.js +0 -4
- package/package.json +5 -8
- package/.gemini/settings.json +0 -5
- package/.task-flow/screens/example.png.txt +0 -23
- package/GEMINI.md +0 -38
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Coding standards and architecture patterns for all projects
|
|
3
|
+
globs: **/*
|
|
4
|
+
alwaysApply: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Coding Standards & Architecture
|
|
8
|
+
|
|
9
|
+
These rules define how code MUST be written when implementing tasks. Always follow these patterns without deviation.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Project Structure
|
|
14
|
+
|
|
15
|
+
All projects use TypeScript and follow this structure:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
src/
|
|
19
|
+
├── app/ # Route definitions only (Next.js App Router or Expo Router)
|
|
20
|
+
├── features/ # Business domain logic, organized by feature
|
|
21
|
+
└── shared/ # Global reusable code
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Tech Stack
|
|
27
|
+
|
|
28
|
+
### Front-end (Next.js)
|
|
29
|
+
- **Framework**: Next.js 15+ with App Router
|
|
30
|
+
- **Language**: TypeScript (strict mode)
|
|
31
|
+
- **Styling**: Tailwind CSS v4 + `clsx` + `tailwind-merge` via `cn()` helper
|
|
32
|
+
- **UI Components**: shadcn/ui + `lucide-react` icons
|
|
33
|
+
- **Data Fetching**: `@tanstack/react-query`
|
|
34
|
+
- **Forms**: `react-hook-form` + `zod` + `@hookform/resolvers/zod`
|
|
35
|
+
- **Notifications**: `sonner`
|
|
36
|
+
- **ESLint**: `@rbinflow/eslint-config`
|
|
37
|
+
- **E2E Testing**: Playwright and/or Cypress
|
|
38
|
+
|
|
39
|
+
### Mobile (Expo / React Native)
|
|
40
|
+
- **Framework**: Expo with Expo Router
|
|
41
|
+
- **Language**: TypeScript (strict mode)
|
|
42
|
+
- **Styling**: NativeWind + `clsx` + `tailwind-merge` via `cn()` helper
|
|
43
|
+
- **Data Fetching**: `@tanstack/react-query`
|
|
44
|
+
- **Forms**: `react-hook-form` + `zod` + `@hookform/resolvers/zod`
|
|
45
|
+
- **Build**: EAS (Expo Application Services)
|
|
46
|
+
- **ESLint**: `@rbinflow/eslint-config`
|
|
47
|
+
- **E2E Testing**: Detox
|
|
48
|
+
|
|
49
|
+
### Backend (NestJS)
|
|
50
|
+
- **Framework**: NestJS 10+
|
|
51
|
+
- **Language**: TypeScript (strict mode)
|
|
52
|
+
- **ORM**: Prisma
|
|
53
|
+
- **Database**: PostgreSQL
|
|
54
|
+
- **Auth**: JWT + Passport
|
|
55
|
+
- **Validation**: Zod (not class-validator/class-transformer)
|
|
56
|
+
- **Structure**: `src/app/` (controllers), `src/features/` (use-cases, repositories), `src/shared/` (guards, pipes, gateways)
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## app/ — Routes Only (Front-end & Mobile) / Controllers Only (Backend)
|
|
61
|
+
|
|
62
|
+
The `app/` directory contains ONLY route definitions. Each file is a thin wrapper that imports and renders the feature page/screen.
|
|
63
|
+
|
|
64
|
+
**Front-end (Next.js App Router):**
|
|
65
|
+
```typescript
|
|
66
|
+
// src/app/(private)/dashboard/page.tsx
|
|
67
|
+
import { DashboardPage } from '@/features/dashboard/pages/dashboard.page'
|
|
68
|
+
|
|
69
|
+
export default function Dashboard() {
|
|
70
|
+
return <DashboardPage />
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**Mobile (Expo Router):**
|
|
75
|
+
```typescript
|
|
76
|
+
// src/app/(private)/dashboard.tsx
|
|
77
|
+
import { DashboardScreen } from '@/features/dashboard/screens/dashboard.screen'
|
|
78
|
+
|
|
79
|
+
export default function Dashboard() {
|
|
80
|
+
return <DashboardScreen />
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**Backend (NestJS):**
|
|
85
|
+
```typescript
|
|
86
|
+
// src/app/account/controllers/create-session.controller.ts
|
|
87
|
+
import { Body, Controller, HttpCode, Post } from '@nestjs/common'
|
|
88
|
+
import { CreateSessionBodySchema, createSessionValidator } from '@/app/account/validators/create-session.validator'
|
|
89
|
+
import { CreateSessionUseCase } from '@/features/account/use-cases/create-session.use-case'
|
|
90
|
+
|
|
91
|
+
@Controller('/session')
|
|
92
|
+
export class CreateSessionController {
|
|
93
|
+
constructor(private createSessionUseCase: CreateSessionUseCase) {}
|
|
94
|
+
|
|
95
|
+
@Post()
|
|
96
|
+
@HttpCode(200)
|
|
97
|
+
async handle(@Body(createSessionValidator) data: CreateSessionBodySchema) {
|
|
98
|
+
return this.createSessionUseCase.execute(data)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
**Rules:**
|
|
104
|
+
- **Front-end/Mobile**: `page.tsx` / route files NEVER contain logic, state, or imports beyond the feature component
|
|
105
|
+
- **Backend**: `app/` contains controllers and validators only — no business logic
|
|
106
|
+
- Group routes with `(public)` and `(private)` folders (front/mobile)
|
|
107
|
+
- `layout.tsx` files may contain auth guards and providers
|
|
108
|
+
- All business logic lives in `features/`
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## features/ — Feature Organization
|
|
113
|
+
|
|
114
|
+
Each feature is self-contained. Structure:
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
features/[feature-name]/
|
|
118
|
+
├── components/ # UI components specific to this feature
|
|
119
|
+
├── hooks/ # Context providers and custom hooks
|
|
120
|
+
├── pages/ # Page component (front-end) — orchestrator
|
|
121
|
+
├── screens/ # Screen component (mobile) — orchestrator
|
|
122
|
+
├── schemas/ # Zod validation schemas
|
|
123
|
+
├── services/ # React Query wrappers (useQuery / useMutation)
|
|
124
|
+
├── types/ # TypeScript types for this feature
|
|
125
|
+
├── use-cases/ # Pure API call logic
|
|
126
|
+
└── utils/ # Feature-specific utilities (optional)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Backend Feature Structure
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
features/[feature-name]/
|
|
133
|
+
├── use-cases/ # Business logic
|
|
134
|
+
│ └── create-account.use-case.ts
|
|
135
|
+
├── repositories/ # Data access (abstract + Prisma implementation)
|
|
136
|
+
│ ├── account.repository.ts # Abstract class
|
|
137
|
+
│ └── prisma-account.repository.ts # Prisma implementation
|
|
138
|
+
├── types/ # TypeScript types
|
|
139
|
+
│ └── account.type.ts
|
|
140
|
+
└── utils/ # Feature-specific utilities (optional)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
**Use Case (backend):**
|
|
144
|
+
```typescript
|
|
145
|
+
// src/features/account/use-cases/create-session.use-case.ts
|
|
146
|
+
import { Injectable, UnauthorizedException } from '@nestjs/common'
|
|
147
|
+
import { AccountRepository } from '@/features/account/repositories/account.repository'
|
|
148
|
+
import { EncryptionGateway } from '@/shared/gateways/encryption.gateway'
|
|
149
|
+
import { JwtGateway } from '@/shared/gateways/jwt.gateway'
|
|
150
|
+
|
|
151
|
+
@Injectable()
|
|
152
|
+
export class CreateSessionUseCase {
|
|
153
|
+
constructor(
|
|
154
|
+
private accountRepository: AccountRepository,
|
|
155
|
+
private encryption: EncryptionGateway,
|
|
156
|
+
private jwt: JwtGateway,
|
|
157
|
+
) {}
|
|
158
|
+
|
|
159
|
+
async execute(data: { email: string; password: string }) {
|
|
160
|
+
const account = await this.accountRepository.findByEmail(data.email)
|
|
161
|
+
if (!account) throw new UnauthorizedException('Invalid credentials')
|
|
162
|
+
|
|
163
|
+
const isValid = await this.encryption.validateHash({
|
|
164
|
+
value: data.password,
|
|
165
|
+
hashedValue: account.password,
|
|
166
|
+
})
|
|
167
|
+
if (!isValid) throw new UnauthorizedException('Invalid credentials')
|
|
168
|
+
|
|
169
|
+
return this.jwt.generateAuthTokens({ accountId: account.id })
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
**Zod validation in NestJS (not class-validator):**
|
|
175
|
+
```typescript
|
|
176
|
+
// src/app/account/validators/create-session.validator.ts
|
|
177
|
+
import { z } from 'zod'
|
|
178
|
+
import { ZodValidationPipe } from '@/shared/pipes/zod-validation.pipe'
|
|
179
|
+
|
|
180
|
+
const createSessionBodySchema = z.object({
|
|
181
|
+
email: z.string().email(),
|
|
182
|
+
password: z.string().min(6),
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
export type CreateSessionBodySchema = z.infer<typeof createSessionBodySchema>
|
|
186
|
+
export const createSessionValidator = new ZodValidationPipe(createSessionBodySchema)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
```typescript
|
|
190
|
+
// src/shared/pipes/zod-validation.pipe.ts
|
|
191
|
+
import { BadRequestException, PipeTransform } from '@nestjs/common'
|
|
192
|
+
import { ZodError, ZodSchema } from 'zod'
|
|
193
|
+
|
|
194
|
+
export class ZodValidationPipe implements PipeTransform {
|
|
195
|
+
constructor(private schema: ZodSchema) {}
|
|
196
|
+
|
|
197
|
+
transform(value: unknown) {
|
|
198
|
+
try {
|
|
199
|
+
return this.schema.parse(value)
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (error instanceof ZodError) {
|
|
202
|
+
throw new BadRequestException({
|
|
203
|
+
message: 'Validation failed',
|
|
204
|
+
errors: error.errors.map((e) => ({ field: e.path.join('.'), message: e.message })),
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
throw new BadRequestException('Validation failed')
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
**Gateways (abstract services in shared/):**
|
|
214
|
+
```typescript
|
|
215
|
+
// src/shared/gateways/encryption.gateway.ts
|
|
216
|
+
export abstract class EncryptionGateway {
|
|
217
|
+
abstract createHash(value: string): Promise<string>
|
|
218
|
+
abstract validateHash(params: { value: string; hashedValue: string }): Promise<boolean>
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Gateways abstract external concerns (encryption, JWT, date, email). Implementations live alongside in `shared/gateways/` and are bound via NestJS modules.
|
|
223
|
+
|
|
224
|
+
### Page / Screen — Orchestrator Pattern (Front-end/Mobile)
|
|
225
|
+
|
|
226
|
+
The page/screen is an orchestrator. It:
|
|
227
|
+
- Calls services (React Query hooks)
|
|
228
|
+
- Manages form state with `useForm`
|
|
229
|
+
- Passes data down to components as props
|
|
230
|
+
- Is NEVER a long file — delegates rendering to components
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
// src/features/dashboard/pages/dashboard.page.tsx
|
|
234
|
+
'use client'
|
|
235
|
+
|
|
236
|
+
import { DashboardRevenueCard } from '@/features/dashboard/components/dashboard-revenue-card'
|
|
237
|
+
import { DashboardActiveCard } from '@/features/dashboard/components/dashboard-active-card'
|
|
238
|
+
import { GetDashboardActiveService } from '@/features/dashboard/services/get-dashboard-active.service'
|
|
239
|
+
|
|
240
|
+
export function DashboardPage() {
|
|
241
|
+
const { data: active, isLoading, isError } = GetDashboardActiveService()
|
|
242
|
+
|
|
243
|
+
return (
|
|
244
|
+
<section className="grid grid-cols-2 gap-5 my-10">
|
|
245
|
+
<DashboardActiveCard data={active} isLoading={isLoading} isError={isError} />
|
|
246
|
+
<DashboardRevenueCard />
|
|
247
|
+
</section>
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
### Components — Single Responsibility
|
|
253
|
+
|
|
254
|
+
Each component has a single responsibility. Components:
|
|
255
|
+
- Receive data via props (no direct service calls unless truly necessary)
|
|
256
|
+
- Are named after the feature: `[feature]-[description].tsx`
|
|
257
|
+
- Stay small and focused
|
|
258
|
+
|
|
259
|
+
```typescript
|
|
260
|
+
// src/features/dashboard/components/dashboard-active-card.tsx
|
|
261
|
+
import { Card } from '@/shared/components/card'
|
|
262
|
+
import { Skeleton } from '@/shared/components/skeleton'
|
|
263
|
+
|
|
264
|
+
type DashboardActiveCardProps = {
|
|
265
|
+
data?: { count: number; trials: number }
|
|
266
|
+
isLoading: boolean
|
|
267
|
+
isError: boolean
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function DashboardActiveCard({ data, isLoading, isError }: DashboardActiveCardProps) {
|
|
271
|
+
if (isLoading) return <Skeleton className="h-[200px]" />
|
|
272
|
+
if (isError) return <ErrorState />
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
<Card title="Active Members">
|
|
276
|
+
<p className="text-2xl font-bold">{data?.count}</p>
|
|
277
|
+
</Card>
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
---
|
|
283
|
+
|
|
284
|
+
## shared/ — Global Reusable Code
|
|
285
|
+
|
|
286
|
+
```
|
|
287
|
+
shared/
|
|
288
|
+
├── components/
|
|
289
|
+
│ ├── button.tsx
|
|
290
|
+
│ ├── card.tsx
|
|
291
|
+
│ ├── data-handler.tsx
|
|
292
|
+
│ ├── skeleton.tsx
|
|
293
|
+
│ ├── dialog.tsx
|
|
294
|
+
│ └── form/
|
|
295
|
+
│ └── inputs/
|
|
296
|
+
│ ├── input-text.tsx
|
|
297
|
+
│ ├── input-select.tsx
|
|
298
|
+
│ └── shared/
|
|
299
|
+
│ ├── input-container.tsx
|
|
300
|
+
│ ├── input-error.tsx
|
|
301
|
+
│ └── input-label.tsx
|
|
302
|
+
├── hooks/
|
|
303
|
+
│ ├── dialog.hook.tsx
|
|
304
|
+
│ └── drawer.hook.tsx
|
|
305
|
+
├── libs/
|
|
306
|
+
│ ├── axios.ts
|
|
307
|
+
│ ├── react-query.ts
|
|
308
|
+
│ └── tw-merge.ts
|
|
309
|
+
├── providers/
|
|
310
|
+
│ └── react-query-provider.tsx
|
|
311
|
+
├── types/
|
|
312
|
+
│ └── shared.ui.type.ts
|
|
313
|
+
├── constants/
|
|
314
|
+
│ └── server-routes.constants.ts
|
|
315
|
+
└── utils/
|
|
316
|
+
├── validation/
|
|
317
|
+
│ ├── required-email.validation.ts
|
|
318
|
+
│ ├── required-string.validation.ts
|
|
319
|
+
│ └── required-phone.validation.ts
|
|
320
|
+
└── error.util.ts
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
## DataHandler — Declarative Loading/Error States
|
|
326
|
+
|
|
327
|
+
Use a `DataHandler` component to handle loading, error, and empty states declaratively instead of repeating `if (isLoading)` / `if (isError)` in every component.
|
|
328
|
+
|
|
329
|
+
```typescript
|
|
330
|
+
// src/shared/components/data-handler.tsx
|
|
331
|
+
import { ReactNode } from 'react'
|
|
332
|
+
import { Skeleton } from '@/shared/components/skeleton'
|
|
333
|
+
|
|
334
|
+
type DataHandlerProps = {
|
|
335
|
+
isLoading: boolean
|
|
336
|
+
isError: boolean
|
|
337
|
+
children: ReactNode
|
|
338
|
+
skeleton?: ReactNode
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function DataHandler({ isLoading, isError, children, skeleton }: DataHandlerProps) {
|
|
342
|
+
if (isLoading) return skeleton ?? <Skeleton />
|
|
343
|
+
if (isError) return <ErrorState />
|
|
344
|
+
return <>{children}</>
|
|
345
|
+
}
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
Usage in pages:
|
|
349
|
+
```typescript
|
|
350
|
+
<DataHandler isLoading={isLoading} isError={isError} skeleton={<DashboardSkeleton />}>
|
|
351
|
+
<DashboardRevenueCard revenue={revenue} />
|
|
352
|
+
</DataHandler>
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
---
|
|
356
|
+
|
|
357
|
+
## Feature `platform` — Cross-Cutting Concerns
|
|
358
|
+
|
|
359
|
+
Every project has a special `features/platform/` feature for app-wide concerns that don't belong in `shared/` (they are app-specific, not reusable across projects):
|
|
360
|
+
|
|
361
|
+
```
|
|
362
|
+
features/platform/
|
|
363
|
+
├── components/ # App shell (menu, sidebar, header)
|
|
364
|
+
├── constants/ # App routes, cookie keys, app name
|
|
365
|
+
│ ├── platform-routes.constants.ts
|
|
366
|
+
│ └── platform-cookies.constants.ts
|
|
367
|
+
├── hooks/ # App-level hooks
|
|
368
|
+
├── providers/ # AppProviders composition
|
|
369
|
+
│ └── app-providers-client.tsx
|
|
370
|
+
└── i18n/ # Internationalization (optional)
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
---
|
|
374
|
+
|
|
375
|
+
## Providers Composition
|
|
376
|
+
|
|
377
|
+
Compose all providers in a dedicated `AppProviders` component instead of nesting them in `layout.tsx`:
|
|
378
|
+
|
|
379
|
+
```typescript
|
|
380
|
+
// src/features/platform/providers/app-providers-client.tsx
|
|
381
|
+
'use client'
|
|
382
|
+
|
|
383
|
+
import { ReactNode } from 'react'
|
|
384
|
+
import { Toaster } from 'sonner'
|
|
385
|
+
import { AuthProvider } from '@/features/auth/hooks/authentication.hook'
|
|
386
|
+
import { DialogProvider } from '@/shared/hooks/dialog.hook'
|
|
387
|
+
|
|
388
|
+
export function AppProvidersClient({ children }: { children: ReactNode }) {
|
|
389
|
+
return (
|
|
390
|
+
<AuthProvider>
|
|
391
|
+
<DialogProvider>
|
|
392
|
+
{children}
|
|
393
|
+
<Toaster position="top-right" />
|
|
394
|
+
</DialogProvider>
|
|
395
|
+
</AuthProvider>
|
|
396
|
+
)
|
|
397
|
+
}
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
Then in the root layout:
|
|
401
|
+
```typescript
|
|
402
|
+
// src/app/layout.tsx
|
|
403
|
+
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
404
|
+
return (
|
|
405
|
+
<html lang="en">
|
|
406
|
+
<body>
|
|
407
|
+
<ReactQueryProvider>
|
|
408
|
+
<AppProvidersClient>{children}</AppProvidersClient>
|
|
409
|
+
</ReactQueryProvider>
|
|
410
|
+
</body>
|
|
411
|
+
</html>
|
|
412
|
+
)
|
|
413
|
+
}
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
---
|
|
417
|
+
|
|
418
|
+
## cn() — Tailwind Class Merging
|
|
419
|
+
|
|
420
|
+
Always use `cn()` for class names. Never use raw string concatenation or template literals with conditional classes.
|
|
421
|
+
|
|
422
|
+
```typescript
|
|
423
|
+
// src/shared/libs/tw-merge.ts
|
|
424
|
+
import { type ClassValue, clsx } from 'clsx'
|
|
425
|
+
import { twMerge } from 'tailwind-merge'
|
|
426
|
+
|
|
427
|
+
export function cn(...inputs: ClassValue[]) {
|
|
428
|
+
return twMerge(clsx(inputs))
|
|
429
|
+
}
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
Usage:
|
|
433
|
+
```typescript
|
|
434
|
+
import { cn } from '@/shared/libs/tw-merge'
|
|
435
|
+
|
|
436
|
+
className={cn(
|
|
437
|
+
'flex flex-col rounded-lg bg-white px-5',
|
|
438
|
+
isActive && 'border-2 border-accent-500',
|
|
439
|
+
className,
|
|
440
|
+
)}
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
---
|
|
444
|
+
|
|
445
|
+
## React Query — Service + Use Case Pattern
|
|
446
|
+
|
|
447
|
+
Split API calls into two layers:
|
|
448
|
+
|
|
449
|
+
**Use Case** — pure function, no React, just the API call:
|
|
450
|
+
```typescript
|
|
451
|
+
// src/features/auth/use-cases/session-create.use-case.ts
|
|
452
|
+
import { api } from '@/shared/libs/axios'
|
|
453
|
+
import { serverRoutes } from '@/shared/constants/server-routes.constants'
|
|
454
|
+
import { LoginSchema } from '@/features/auth/schemas/login.schema'
|
|
455
|
+
import { SessionType } from '@/features/auth/types/session.api.type'
|
|
456
|
+
|
|
457
|
+
export async function sessionCreateUseCase(data: LoginSchema): Promise<SessionType> {
|
|
458
|
+
const response = await api.post(`${serverRoutes.session}/create`, data)
|
|
459
|
+
return response.data
|
|
460
|
+
}
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
**Service** — React Query wrapper used in components:
|
|
464
|
+
```typescript
|
|
465
|
+
// src/features/auth/services/session-create.service.tsx
|
|
466
|
+
'use client'
|
|
467
|
+
|
|
468
|
+
import { useMutation } from '@tanstack/react-query'
|
|
469
|
+
import { sessionCreateUseCase } from '@/features/auth/use-cases/session-create.use-case'
|
|
470
|
+
import { SessionType } from '@/features/auth/types/session.api.type'
|
|
471
|
+
import { ServiceInput } from '@/shared/types/shared.ui.type'
|
|
472
|
+
import { handleError } from '@/shared/utils/error.util'
|
|
473
|
+
|
|
474
|
+
export function SessionCreateService({ onSuccess }: ServiceInput<SessionType>) {
|
|
475
|
+
return useMutation({
|
|
476
|
+
mutationFn: sessionCreateUseCase,
|
|
477
|
+
onSuccess: (data) => onSuccess?.(data),
|
|
478
|
+
onError: (error) => handleError(error),
|
|
479
|
+
})
|
|
480
|
+
}
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
**Query service:**
|
|
484
|
+
```typescript
|
|
485
|
+
// src/features/dashboard/services/get-dashboard-active.service.tsx
|
|
486
|
+
'use client'
|
|
487
|
+
|
|
488
|
+
import { useQuery } from '@tanstack/react-query'
|
|
489
|
+
import { getDashboardActiveUseCase } from '@/features/dashboard/use-cases/get-dashboard-active.use-case'
|
|
490
|
+
import { DashboardActiveType } from '@/features/dashboard/types/dashboard-active.type'
|
|
491
|
+
|
|
492
|
+
export const getDashboardActiveQueryKey = 'getDashboardActiveQueryKey'
|
|
493
|
+
|
|
494
|
+
export function GetDashboardActiveService() {
|
|
495
|
+
return useQuery<DashboardActiveType>({
|
|
496
|
+
queryKey: [getDashboardActiveQueryKey],
|
|
497
|
+
queryFn: getDashboardActiveUseCase,
|
|
498
|
+
})
|
|
499
|
+
}
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
**Shared service input type:**
|
|
503
|
+
```typescript
|
|
504
|
+
// src/shared/types/shared.ui.type.ts
|
|
505
|
+
export type ServiceInput<TData = unknown> = {
|
|
506
|
+
onSuccess?: (data?: TData) => void
|
|
507
|
+
onError?: () => void
|
|
508
|
+
}
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
---
|
|
512
|
+
|
|
513
|
+
## Forms — React Hook Form + Zod
|
|
514
|
+
|
|
515
|
+
**Schema:**
|
|
516
|
+
```typescript
|
|
517
|
+
// src/features/auth/schemas/login.schema.ts
|
|
518
|
+
import { z } from 'zod'
|
|
519
|
+
import { requiredEmail } from '@/shared/utils/validation/required-email.validation'
|
|
520
|
+
import { requiredString } from '@/shared/utils/validation/required-string.validation'
|
|
521
|
+
|
|
522
|
+
export const loginSchema = z.object({
|
|
523
|
+
email: requiredEmail(),
|
|
524
|
+
password: requiredString({ field: 'password' }),
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
export type LoginSchema = z.infer<typeof loginSchema>
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
**Reusable validators in shared/:**
|
|
531
|
+
```typescript
|
|
532
|
+
// src/shared/utils/validation/required-string.validation.ts
|
|
533
|
+
import { z } from 'zod'
|
|
534
|
+
|
|
535
|
+
export const requiredString = ({ field, min = 1 }: { field: string; min?: number }) =>
|
|
536
|
+
z.string({ message: `${field} is required` }).min(min, {
|
|
537
|
+
message: `${field} must be at least ${min} characters`,
|
|
538
|
+
})
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
**Form component:**
|
|
542
|
+
```typescript
|
|
543
|
+
// src/features/auth/components/login-form.tsx
|
|
544
|
+
'use client'
|
|
545
|
+
|
|
546
|
+
import { zodResolver } from '@hookform/resolvers/zod'
|
|
547
|
+
import { useForm } from 'react-hook-form'
|
|
548
|
+
import { LoginSchema, loginSchema } from '@/features/auth/schemas/login.schema'
|
|
549
|
+
import { SessionCreateService } from '@/features/auth/services/session-create.service'
|
|
550
|
+
import { InputText } from '@/shared/components/form/inputs/input-text'
|
|
551
|
+
import { Button } from '@/shared/components/button'
|
|
552
|
+
|
|
553
|
+
export function LoginForm() {
|
|
554
|
+
const { control, handleSubmit } = useForm<LoginSchema>({
|
|
555
|
+
resolver: zodResolver(loginSchema),
|
|
556
|
+
defaultValues: { email: '', password: '' },
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
const { mutate: createSession, isPending } = SessionCreateService({
|
|
560
|
+
onSuccess: () => { /* redirect */ },
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
return (
|
|
564
|
+
<form onSubmit={handleSubmit((data) => createSession(data))} className="flex flex-col gap-4">
|
|
565
|
+
<InputText name="email" control={control} placeholder="Email" />
|
|
566
|
+
<InputText name="password" control={control} type="password" placeholder="Password" />
|
|
567
|
+
<Button type="submit" isLoading={isPending}>Sign in</Button>
|
|
568
|
+
</form>
|
|
569
|
+
)
|
|
570
|
+
}
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
**Input uses `Controller` from RHF and is generic:**
|
|
574
|
+
```typescript
|
|
575
|
+
// src/shared/components/form/inputs/input-text.tsx
|
|
576
|
+
import { Control, Controller, FieldValues, Path, PathValue } from 'react-hook-form'
|
|
577
|
+
import { cn } from '@/shared/libs/tw-merge'
|
|
578
|
+
|
|
579
|
+
type InputTextProps<T extends FieldValues> = {
|
|
580
|
+
name: Path<T>
|
|
581
|
+
control: Control<T>
|
|
582
|
+
defaultValue?: PathValue<T, Path<T>>
|
|
583
|
+
placeholder?: string
|
|
584
|
+
type?: 'text' | 'password' | 'email'
|
|
585
|
+
disabled?: boolean
|
|
586
|
+
className?: string
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
export function InputText<T extends FieldValues>({
|
|
590
|
+
name, control, defaultValue = '' as PathValue<T, Path<T>>,
|
|
591
|
+
placeholder, type = 'text', disabled, className,
|
|
592
|
+
}: InputTextProps<T>) {
|
|
593
|
+
return (
|
|
594
|
+
<Controller
|
|
595
|
+
name={name}
|
|
596
|
+
control={control}
|
|
597
|
+
defaultValue={defaultValue}
|
|
598
|
+
render={({ field, fieldState: { error } }) => (
|
|
599
|
+
<div className={cn('flex flex-col gap-1', className)}>
|
|
600
|
+
<input
|
|
601
|
+
{...field}
|
|
602
|
+
type={type}
|
|
603
|
+
placeholder={placeholder}
|
|
604
|
+
disabled={disabled}
|
|
605
|
+
className={cn('border rounded-lg px-4 h-12 text-sm', error && 'border-red-500')}
|
|
606
|
+
/>
|
|
607
|
+
{error && <span className="text-xs text-red-500">{error.message}</span>}
|
|
608
|
+
</div>
|
|
609
|
+
)}
|
|
610
|
+
/>
|
|
611
|
+
)
|
|
612
|
+
}
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
---
|
|
616
|
+
|
|
617
|
+
## File Naming Conventions
|
|
618
|
+
|
|
619
|
+
Use kebab-case for all filenames. Suffix reflects the file's role:
|
|
620
|
+
|
|
621
|
+
| Role | Suffix | Example |
|
|
622
|
+
|------|--------|---------|
|
|
623
|
+
| Page component | `.page.tsx` | `dashboard.page.tsx` |
|
|
624
|
+
| Screen component (mobile) | `.screen.tsx` | `login.screen.tsx` |
|
|
625
|
+
| UI component | `.tsx` | `dashboard-revenue-card.tsx` |
|
|
626
|
+
| React Query service | `.service.tsx` | `session-create.service.tsx` |
|
|
627
|
+
| API call (pure) | `.use-case.ts` | `session-create.use-case.ts` |
|
|
628
|
+
| Zod schema | `.schema.ts` | `login.schema.ts` |
|
|
629
|
+
| Context/hook | `.hook.tsx` | `authentication.hook.tsx` |
|
|
630
|
+
| API response type | `.api.type.ts` | `session.api.type.ts` |
|
|
631
|
+
| Domain type | `.type.ts` | `dashboard-active.type.ts` |
|
|
632
|
+
| Utility | `.util.ts` | `error.util.ts` |
|
|
633
|
+
| Validation helper | `.validation.ts` | `required-email.validation.ts` |
|
|
634
|
+
| Constants | `.constants.ts` | `server-routes.constants.ts` |
|
|
635
|
+
|
|
636
|
+
---
|
|
637
|
+
|
|
638
|
+
## TypeScript Types
|
|
639
|
+
|
|
640
|
+
- API response types: `[Name]Type` in a `.api.type.ts` file
|
|
641
|
+
- Domain/internal types: `[Name]Type` in a `.type.ts` file
|
|
642
|
+
- All types exported with `export type`
|
|
643
|
+
- No `any` — ever
|
|
644
|
+
|
|
645
|
+
```typescript
|
|
646
|
+
// src/features/auth/types/session.api.type.ts
|
|
647
|
+
export type SessionType = {
|
|
648
|
+
accessToken: string
|
|
649
|
+
refreshToken: string
|
|
650
|
+
}
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
---
|
|
654
|
+
|
|
655
|
+
## Context / Auth Hook Pattern
|
|
656
|
+
|
|
657
|
+
```typescript
|
|
658
|
+
// src/features/auth/hooks/authentication.hook.tsx
|
|
659
|
+
'use client'
|
|
660
|
+
|
|
661
|
+
import { createContext, useContext, ReactNode } from 'react'
|
|
662
|
+
|
|
663
|
+
type AuthContextData = {
|
|
664
|
+
account: AccountType | null
|
|
665
|
+
isAuthenticated: boolean
|
|
666
|
+
signOut: () => Promise<void>
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const AuthContext = createContext<AuthContextData>({} as AuthContextData)
|
|
670
|
+
|
|
671
|
+
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
672
|
+
// state and derived values here via useMemo
|
|
673
|
+
return (
|
|
674
|
+
<AuthContext.Provider value={{ account, isAuthenticated, signOut }}>
|
|
675
|
+
{children}
|
|
676
|
+
</AuthContext.Provider>
|
|
677
|
+
)
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
export const useAuth = () => useContext(AuthContext)
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
---
|
|
684
|
+
|
|
685
|
+
## Testing
|
|
686
|
+
|
|
687
|
+
### Front-end (Next.js)
|
|
688
|
+
- **E2E**: Playwright and/or Cypress
|
|
689
|
+
- Tests live in `e2e/` or `cypress/` at project root
|
|
690
|
+
- Test files: `[feature].spec.ts` (Playwright) or `[feature].cy.ts` (Cypress)
|
|
691
|
+
- Cover critical user flows: auth, main feature interactions, form submissions
|
|
692
|
+
|
|
693
|
+
### Mobile (Expo / React Native)
|
|
694
|
+
- **E2E**: Detox
|
|
695
|
+
- Tests live in `e2e/` at project root
|
|
696
|
+
- Test files: `[feature].test.ts`
|
|
697
|
+
- Cover critical flows: login, main navigation, core feature interactions
|
|
698
|
+
|
|
699
|
+
---
|
|
700
|
+
|
|
701
|
+
## Critical Rules (Non-Negotiable)
|
|
702
|
+
|
|
703
|
+
1. **`app/` is thin**: `page.tsx` only renders the feature page/screen. No logic, no state, no extra imports.
|
|
704
|
+
2. **Pages are orchestrators**: Feature pages compose components — they are short. If the page is getting long, extract a component.
|
|
705
|
+
3. **Components have one job**: If a component grows, split it. Name reflects its single purpose.
|
|
706
|
+
4. **Always use `cn()`** for class merging — never template literals with conditional classes.
|
|
707
|
+
5. **Forms always use `Controller`** from react-hook-form — never uncontrolled inputs.
|
|
708
|
+
6. **API logic in use-cases**: Components and services never call `axios`/`api` directly — always through a use-case function.
|
|
709
|
+
7. **Zod for all validation**: No manual validation — always define a schema and use `zodResolver`.
|
|
710
|
+
8. **Type everything**: No `any`. All props, params, and return values must be typed.
|
|
711
|
+
9. **Shared is truly shared**: Only put in `shared/` what is used by 2+ features.
|
|
712
|
+
10. **Naming reflects role**: File name + suffix must make the file's purpose immediately obvious.
|