start-vibing-stacks 2.2.0 → 2.4.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.
@@ -5,17 +5,39 @@
5
5
  },
6
6
  "sensitivePatterns": {
7
7
  "forbidden": [
8
- "eval(",
9
- "exec(",
10
- "system(",
11
- "passthru(",
12
- "shell_exec(",
13
8
  "password:",
14
9
  "passwordHash",
15
10
  "apiKey:",
16
11
  "secret:"
17
12
  ]
18
13
  },
14
+ "envExposure": {
15
+ "$comment": "NEXT_PUBLIC_ vars are embedded in browser JS bundle. These patterns indicate secrets being exposed to the client.",
16
+ "forbiddenPublicEnvPatterns": [
17
+ "NEXT_PUBLIC_.*SECRET",
18
+ "NEXT_PUBLIC_.*TOKEN",
19
+ "NEXT_PUBLIC_.*PRIVATE",
20
+ "NEXT_PUBLIC_.*PASSWORD",
21
+ "NEXT_PUBLIC_.*CREDENTIAL"
22
+ ],
23
+ "forbiddenPublicEnvExact": [
24
+ "NEXT_PUBLIC_OPENAI_KEY",
25
+ "NEXT_PUBLIC_OPENAI_API_KEY",
26
+ "NEXT_PUBLIC_STRIPE_SECRET",
27
+ "NEXT_PUBLIC_STRIPE_SECRET_KEY",
28
+ "NEXT_PUBLIC_DATABASE_URL",
29
+ "NEXT_PUBLIC_SUPABASE_SERVICE_KEY",
30
+ "NEXT_PUBLIC_FIREBASE_ADMIN_KEY"
31
+ ],
32
+ "safePublicEnvPatterns": [
33
+ "NEXT_PUBLIC_.*URL",
34
+ "NEXT_PUBLIC_.*ID",
35
+ "NEXT_PUBLIC_STRIPE_KEY",
36
+ "NEXT_PUBLIC_GA_",
37
+ "NEXT_PUBLIC_SENTRY_DSN"
38
+ ],
39
+ "rule": "API keys, secrets, and tokens MUST stay server-side. Use Route Handlers or Server Actions as proxy."
40
+ },
19
41
  "cookies": {
20
42
  "httpOnly": true,
21
43
  "secure": true,
@@ -11,6 +11,7 @@ import { join } from 'path';
11
11
 
12
12
  const PROJECT_DIR = process.env['CLAUDE_PROJECT_DIR'] || process.cwd();
13
13
  const ACTIVE_PROJECT = join(PROJECT_DIR, '.claude', 'config', 'active-project.json');
14
+ const STANDARDS_REVIEW = join(PROJECT_DIR, '.claude', 'config', 'standards-review.json');
14
15
 
15
16
  let stackName = 'Unknown';
16
17
  let qualityCmd = 'Run quality gates';
@@ -18,7 +19,6 @@ try {
18
19
  if (existsSync(ACTIVE_PROJECT)) {
19
20
  const config = JSON.parse(readFileSync(ACTIVE_PROJECT, 'utf8'));
20
21
  stackName = config.stack || 'Unknown';
21
- // Read quality gates command from stack config
22
22
  const stackConfig = join(PROJECT_DIR, '.claude', 'config', 'quality-gates.json');
23
23
  if (existsSync(stackConfig)) {
24
24
  const gates = JSON.parse(readFileSync(stackConfig, 'utf8'));
@@ -27,6 +27,30 @@ try {
27
27
  }
28
28
  } catch {}
29
29
 
30
+ interface ReviewFile {
31
+ status: string;
32
+ sources?: string[];
33
+ patterns?: { category: string; name: string }[];
34
+ }
35
+
36
+ let standardsContext = '';
37
+ try {
38
+ if (!existsSync(STANDARDS_REVIEW)) {
39
+ standardsContext = `\n\nSTANDARDS REVIEW NEEDED: No standards-review.json found. ` +
40
+ `This project may have existing coding standards (.cursorrules, composer.json configs). ` +
41
+ `Ask the user: "I noticed this project hasn't been scanned for existing standards. ` +
42
+ `Would you like me to review your codebase patterns and adapt my behavior, ` +
43
+ `or should I use the default standards?"`;
44
+ } else {
45
+ const review: ReviewFile = JSON.parse(readFileSync(STANDARDS_REVIEW, 'utf8'));
46
+ if (review.status === 'adapted' && review.patterns && review.patterns.length > 0) {
47
+ const patternList = review.patterns.map(p => `- [${p.category}] ${p.name}`).join('\n');
48
+ standardsContext = `\n\nPROJECT STANDARDS (scanned from ${(review.sources || []).join(', ')}):\n${patternList}\n` +
49
+ `Follow these project-specific patterns. They take priority over generic defaults.`;
50
+ }
51
+ }
52
+ } catch {}
53
+
30
54
  async function main(): Promise<void> {
31
55
  let hookInput: any = {};
32
56
  try {
@@ -65,7 +89,7 @@ async function main(): Promise<void> {
65
89
  a. "## Last Change" (date: ${today}, branch, summary)
66
90
  b. Update ALL affected rule/flow sections
67
91
 
68
- 6. Run stop-validator before finishing.`;
92
+ 6. Run stop-validator before finishing.${standardsContext}`;
69
93
 
70
94
  console.log(JSON.stringify({ continue: true, systemMessage }));
71
95
  process.exit(0);
@@ -10,7 +10,7 @@ Preline is a **semantic token-based design system** built on TailwindCSS. It pro
10
10
  - Theme generator for custom color schemes
11
11
  - Light + dark mode via `data-theme` + `.dark`
12
12
 
13
- ## Installation (Laravel + Inertia)
13
+ ## Installation
14
14
 
15
15
  ### Step 1: Install
16
16
 
@@ -21,7 +21,7 @@ npm install preline @tailwindcss/forms
21
21
  ### Step 2: CSS Config
22
22
 
23
23
  ```css
24
- /* resources/css/app.css */
24
+ /* src/app/globals.css (Next.js) or styles/globals.css */
25
25
  @import "tailwindcss";
26
26
 
27
27
  /* Preline — MUST be in this order */
@@ -31,39 +31,35 @@ npm install preline @tailwindcss/forms
31
31
  @import "./node_modules/preline/themes/theme.css"; /* Base theme */
32
32
  ```
33
33
 
34
- ### Step 3: Vite Config
34
+ ### Step 3: Init Preline on Route Changes (MANDATORY)
35
35
 
36
- ```js
37
- // vite.config.js
38
- import { defineConfig } from 'vite';
39
- import laravel from 'laravel-vite-plugin';
40
- import react from '@vitejs/plugin-react';
36
+ ```tsx
37
+ // src/components/PrelineInit.tsx
38
+ 'use client';
41
39
 
42
- export default defineConfig({
43
- plugins: [
44
- laravel({ input: ['resources/css/app.css', 'resources/js/app.tsx'], refresh: true }),
45
- react(),
46
- ],
47
- });
48
- ```
40
+ import { usePathname } from 'next/navigation';
41
+ import { useEffect } from 'react';
49
42
 
50
- ### Step 4: Init Preline in Inertia (MANDATORY)
43
+ export function PrelineInit() {
44
+ const pathname = usePathname();
51
45
 
52
- ```tsx
53
- // resources/js/app.tsx
54
- import { router } from '@inertiajs/react';
55
-
56
- // Re-init Preline components after every SPA navigation
57
- router.on('navigate', () => {
58
- setTimeout(() => {
59
- import('preline/preline').then(({ HSStaticMethods }) => {
60
- HSStaticMethods.autoInit();
61
- });
62
- }, 100);
63
- });
46
+ useEffect(() => {
47
+ const timer = setTimeout(() => {
48
+ import('preline/preline').then(({ HSStaticMethods }) => {
49
+ HSStaticMethods.autoInit();
50
+ });
51
+ }, 100);
52
+ return () => clearTimeout(timer);
53
+ }, [pathname]);
54
+
55
+ return null;
56
+ }
57
+
58
+ // Add to root layout:
59
+ // <PrelineInit />
64
60
  ```
65
61
 
66
- **Rule:** Without `HSStaticMethods.autoInit()`, dropdowns, modals, and accordions will NOT work after Inertia navigation.
62
+ **Rule:** Without `HSStaticMethods.autoInit()`, dropdowns, modals, and accordions will NOT work after client-side navigation.
67
63
 
68
64
  ## Templates & Components (840+ free)
69
65
 
@@ -80,11 +76,11 @@ router.on('navigate', () => {
80
76
 
81
77
  1. Browse https://preline.co/examples.html
82
78
  2. Click a block → copy the HTML/JSX
83
- 3. Adapt to React + Inertia:
84
- - Replace `<a href>` with `<Link href>` (Inertia)
79
+ 3. Adapt to React:
80
+ - Replace `<a href>` with Next.js `<Link href>` (from `next/link`)
85
81
  - Replace `class=` with `className=`
86
82
  - Add Preline `data-*` attributes for interactive components
87
- - Use `usePage().props` for dynamic data
83
+ - Pass data via props or fetch with TanStack Query / Server Components
88
84
 
89
85
  ### Example: Copy a Hero Block
90
86
 
@@ -245,7 +241,7 @@ function ThemeToggle() {
245
241
  }
246
242
  ```
247
243
 
248
- ## Using Components with React (Inertia)
244
+ ## Using Components with React
249
245
 
250
246
  ### Navbar
251
247
 
@@ -327,7 +323,7 @@ function ThemeToggle() {
327
323
 
328
324
  ```bash
329
325
  # Generate theme from config
330
- npx preline-theme-generator /tmp/config.json ./resources/css/themes/brand.css
326
+ npx preline-theme-generator /tmp/config.json ./src/styles/themes/brand.css
331
327
 
332
328
  # Config format:
333
329
  {
@@ -359,4 +355,4 @@ npx preline-theme-generator /tmp/config.json ./resources/css/themes/brand.css
359
355
  | Force HTML class changes | Theme activation via `data-theme` only |
360
356
  | Invent token names | Follow Preline's naming system |
361
357
  | `@apply` for component styles | React components with token classes |
362
- | Skip `HSStaticMethods.autoInit()` | Always re-init after Inertia navigation |
358
+ | Skip `HSStaticMethods.autoInit()` | Always re-init after client-side navigation |
@@ -5,16 +5,16 @@
5
5
  - **ReactJS >= 19** — MANDATORY
6
6
  - **TailwindCSS >= 4** — MANDATORY
7
7
 
8
- ## Translation Pattern
8
+ ## Label Constants Pattern
9
9
 
10
10
  ```tsx
11
- // ✅ Translations as CONST at the top, BEFORE hooks
11
+ // ✅ Labels as CONST at the top, BEFORE hooks
12
12
  const LABELS = {
13
- title: __('dashboard.title'),
14
- save: __('common.save'),
15
- cancel: __('common.cancel'),
16
- errorRequired: __('errors.field_required'),
17
- };
13
+ title: 'Dashboard',
14
+ save: 'Save',
15
+ cancel: 'Cancel',
16
+ errorRequired: 'This field is required',
17
+ } as const;
18
18
 
19
19
  export default function Dashboard() {
20
20
  const [data, setData] = useState(null);
@@ -22,14 +22,14 @@ export default function Dashboard() {
22
22
  return <h1>{LABELS.title}</h1>;
23
23
  }
24
24
 
25
- // ❌ NEVER call __() inside JSX (Hook violations)
26
- return <h1>{__('dashboard.title')}</h1>; // ❌
25
+ // ❌ NEVER scatter string literals across JSX
26
+ return <h1>Dashboard</h1>; // ❌ Duplicated, hard to maintain
27
27
  ```
28
28
 
29
29
  **Rules:**
30
- - Translations in `CONST` variables before state hooks
31
- - New strings add to `lang/en/*.php` and `lang/pt/*.php`
32
- - Error strings centralized in `lang/*/errors.php`
30
+ - Labels in `CONST` objects before state hooks for stable references
31
+ - For i18n projects, use `next-intl` or `i18next` same CONST pattern applies with `t()` calls
32
+ - Error strings centralized in a shared constants file
33
33
 
34
34
  ## Debug Logging
35
35
 
@@ -65,11 +65,11 @@ export default function Dashboard() {
65
65
 
66
66
  ```tsx
67
67
  // ═══════════════════════════════════════════
68
- // 1. TRANSLATIONS (before hooks)
68
+ // 1. LABELS (before hooks)
69
69
  // ═══════════════════════════════════════════
70
70
  const LABELS = {
71
- title: __('dashboard.title'),
72
- save: __('common.save'),
71
+ title: 'Dashboard',
72
+ save: 'Save',
73
73
  } as const;
74
74
 
75
75
  // ═══════════════════════════════════════════
@@ -156,7 +156,7 @@ import { cn } from '@/lib/utils';
156
156
  5. **Shared styles** — create a `styles.ts` file for cross-component constants
157
157
 
158
158
  ```tsx
159
- // resources/js/styles.ts — shared across components
159
+ // src/styles.ts — shared across components
160
160
  export const SHARED_STYLES = {
161
161
  page: 'max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8',
162
162
  btnPrimary: 'px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary-hover ...',
@@ -187,10 +187,10 @@ export default function Bad() {
187
187
  ## SVG Icons
188
188
 
189
189
  ```tsx
190
- // ✅ Separate files, import with ?react
191
- // resources/js/Icons/CheckIcon.svg
192
- import CheckIcon from '@/Icons/CheckIcon.svg?react';
193
- import { CheckIcon, AlertIcon } from '@/Icons';
190
+ // ✅ Separate files, import with ?react (Vite) or as components
191
+ // src/components/icons/CheckIcon.svg
192
+ import CheckIcon from '@/components/icons/CheckIcon.svg?react';
193
+ import { CheckIcon, AlertIcon } from '@/components/icons';
194
194
 
195
195
  // ❌ Inline SVG
196
196
  <svg viewBox="0 0 24 24">...</svg> // ❌ Bloats JSX
@@ -138,7 +138,7 @@ function UserList({ users }: { users: User[] }) {
138
138
  icon={<Users className="h-8 w-8" />}
139
139
  title="No users yet"
140
140
  description="Invite your first team member"
141
- action={{ label: 'Invite User', onClick: () => router.visit('/users/invite') }}
141
+ action={{ label: 'Invite User', onClick: () => window.location.assign('/users/invite') }}
142
142
  />
143
143
  );
144
144
  }
@@ -191,57 +191,76 @@ function EmptyState({ icon, title, description, action }: {
191
191
  </button>
192
192
  ```
193
193
 
194
- ## Form Pattern (Inertia.js)
194
+ ## Form Pattern (React Hook Form + Zod)
195
195
 
196
196
  ```tsx
197
- import { useForm } from '@inertiajs/react';
197
+ import { useForm } from 'react-hook-form';
198
+ import { zodResolver } from '@hookform/resolvers/zod';
199
+ import { useMutation } from '@tanstack/react-query';
200
+ import { z } from 'zod';
201
+ import { toast } from 'sonner';
202
+
203
+ const CreateUserSchema = z.object({
204
+ name: z.string().min(2, 'Name is required'),
205
+ email: z.string().email('Invalid email'),
206
+ });
207
+
208
+ type CreateUserForm = z.infer<typeof CreateUserSchema>;
198
209
 
199
210
  export default function CreateUser() {
200
- const { data, setData, post, processing, errors, reset } = useForm({
201
- name: '',
202
- email: '',
211
+ const {
212
+ register,
213
+ handleSubmit,
214
+ reset,
215
+ formState: { errors },
216
+ } = useForm<CreateUserForm>({
217
+ resolver: zodResolver(CreateUserSchema),
203
218
  });
204
219
 
205
- const submit = (e: React.FormEvent) => {
206
- e.preventDefault();
207
- post('/users', {
208
- onSuccess: () => {
209
- toast.success('User created!');
210
- reset();
211
- },
212
- onError: () => toast.error('Failed to create user'),
213
- });
214
- };
220
+ const mutation = useMutation({
221
+ mutationFn: (data: CreateUserForm) =>
222
+ fetch('/api/users', {
223
+ method: 'POST',
224
+ headers: { 'Content-Type': 'application/json' },
225
+ body: JSON.stringify(data),
226
+ }).then((res) => {
227
+ if (!res.ok) throw new Error('Failed to create user');
228
+ return res.json();
229
+ }),
230
+ onSuccess: () => {
231
+ toast.success('User created!');
232
+ reset();
233
+ },
234
+ onError: () => toast.error('Failed to create user'),
235
+ });
215
236
 
216
237
  return (
217
- <form onSubmit={submit} className="space-y-4">
238
+ <form onSubmit={handleSubmit((data) => mutation.mutate(data))} className="space-y-4">
218
239
  <div>
219
240
  <label className="block text-sm font-medium text-foreground mb-1">Name</label>
220
241
  <input
221
- value={data.name}
222
- onChange={e => setData('name', e.target.value)}
242
+ {...register('name')}
223
243
  className="w-full h-10 px-3 rounded-md border border-border bg-background text-foreground"
224
244
  />
225
- {errors.name && <p className="mt-1 text-sm text-destructive">{errors.name}</p>}
245
+ {errors.name && <p className="mt-1 text-sm text-destructive">{errors.name.message}</p>}
226
246
  </div>
227
247
 
228
248
  <div>
229
249
  <label className="block text-sm font-medium text-foreground mb-1">Email</label>
230
250
  <input
231
251
  type="email"
232
- value={data.email}
233
- onChange={e => setData('email', e.target.value)}
252
+ {...register('email')}
234
253
  className="w-full h-10 px-3 rounded-md border border-border bg-background text-foreground"
235
254
  />
236
- {errors.email && <p className="mt-1 text-sm text-destructive">{errors.email}</p>}
255
+ {errors.email && <p className="mt-1 text-sm text-destructive">{errors.email.message}</p>}
237
256
  </div>
238
257
 
239
258
  <button
240
259
  type="submit"
241
- disabled={processing}
260
+ disabled={mutation.isPending}
242
261
  className="bg-primary text-primary-foreground px-6 py-2 rounded-lg hover:bg-primary-hover disabled:opacity-50 transition-colors"
243
262
  >
244
- {processing ? (
263
+ {mutation.isPending ? (
245
264
  <span className="flex items-center gap-2">
246
265
  <Loader className="h-4 w-4 animate-spin" /> Creating...
247
266
  </span>
@@ -252,27 +271,44 @@ export default function CreateUser() {
252
271
  }
253
272
  ```
254
273
 
255
- ## Optimistic Updates
274
+ ## Optimistic Updates (TanStack Query)
256
275
 
257
276
  ```tsx
258
- // Show result immediately, rollback on error
277
+ import { useMutation, useQueryClient } from '@tanstack/react-query';
278
+
259
279
  function ToggleFavorite({ item }: { item: Item }) {
260
- const [optimistic, setOptimistic] = useState(item.isFavorite);
261
-
262
- const toggle = () => {
263
- setOptimistic(!optimistic); // Instant UI
264
- router.post(`/items/${item.id}/favorite`, {}, {
265
- preserveState: true,
266
- onError: () => {
267
- setOptimistic(item.isFavorite); // Rollback
268
- toast.error('Failed to update');
269
- },
270
- });
271
- };
280
+ const queryClient = useQueryClient();
281
+
282
+ const mutation = useMutation({
283
+ mutationFn: () =>
284
+ fetch(`/api/items/${item.id}/favorite`, { method: 'POST' }).then((res) => {
285
+ if (!res.ok) throw new Error('Failed');
286
+ return res.json();
287
+ }),
288
+ onMutate: async () => {
289
+ await queryClient.cancelQueries({ queryKey: ['items'] });
290
+ const previous = queryClient.getQueryData<Item[]>(['items']);
291
+
292
+ queryClient.setQueryData<Item[]>(['items'], (old) =>
293
+ old?.map((i) =>
294
+ i.id === item.id ? { ...i, isFavorite: !i.isFavorite } : i
295
+ )
296
+ );
297
+
298
+ return { previous };
299
+ },
300
+ onError: (_err, _vars, context) => {
301
+ queryClient.setQueryData(['items'], context?.previous);
302
+ toast.error('Failed to update');
303
+ },
304
+ onSettled: () => {
305
+ queryClient.invalidateQueries({ queryKey: ['items'] });
306
+ },
307
+ });
272
308
 
273
309
  return (
274
- <button onClick={toggle} className="text-xl">
275
- {optimistic ? '❤️' : '🤍'}
310
+ <button onClick={() => mutation.mutate()} className="text-xl">
311
+ {item.isFavorite ? '❤️' : '🤍'}
276
312
  </button>
277
313
  );
278
314
  }
@@ -294,5 +330,5 @@ function ToggleFavorite({ item }: { item: Item }) {
294
330
  1. **`if (loading) return <Spinner />`** — check `loading && !data` instead
295
331
  2. **Silent catch** — always toast/display errors to user
296
332
  3. **No empty state** — every list needs one
297
- 4. **Clickable button during submit** — always `disabled={processing}`
333
+ 4. **Clickable button during submit** — always `disabled={isPending}` or `disabled={isSubmitting}`
298
334
  5. **Console.log-only errors** — user must see feedback
@@ -15,7 +15,7 @@
15
15
  ## Setup (v4)
16
16
 
17
17
  ```css
18
- /* resources/css/app.css (Laravel + Inertia) */
18
+ /* src/app/globals.css */
19
19
  @import "tailwindcss";
20
20
 
21
21
  @theme {
@@ -113,18 +113,38 @@ if (result.success) {
113
113
 
114
114
  ### Environment Variables
115
115
 
116
+ > **Split server and client env schemas.** `NEXT_PUBLIC_*` is embedded in the browser bundle — NEVER put secrets there.
117
+
116
118
  ```tsx
117
- const EnvSchema = z.object({
118
- NEXT_PUBLIC_API_URL: z.string().url(),
119
- NEXT_PUBLIC_STRIPE_KEY: z.string().startsWith('pk_'),
119
+ // lib/env.server.ts Server-only secrets (NEVER import from client components)
120
+ const ServerEnvSchema = z.object({
120
121
  DATABASE_URL: z.string().min(1),
122
+ OPENAI_KEY: z.string().startsWith('sk-'),
123
+ STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
121
124
  NODE_ENV: z.enum(['development', 'production', 'test']),
122
125
  });
123
126
 
124
- // Validate at app startup
125
- export const env = EnvSchema.parse(process.env);
127
+ export const serverEnv = ServerEnvSchema.parse({
128
+ DATABASE_URL: process.env['DATABASE_URL'],
129
+ OPENAI_KEY: process.env['OPENAI_KEY'],
130
+ STRIPE_SECRET_KEY: process.env['STRIPE_SECRET_KEY'],
131
+ NODE_ENV: process.env['NODE_ENV'],
132
+ });
133
+
134
+ // lib/env.client.ts — Public vars only (safe for browser)
135
+ const ClientEnvSchema = z.object({
136
+ NEXT_PUBLIC_APP_URL: z.string().url(),
137
+ NEXT_PUBLIC_STRIPE_KEY: z.string().startsWith('pk_'),
138
+ });
139
+
140
+ export const clientEnv = ClientEnvSchema.parse({
141
+ NEXT_PUBLIC_APP_URL: process.env['NEXT_PUBLIC_APP_URL'],
142
+ NEXT_PUBLIC_STRIPE_KEY: process.env['NEXT_PUBLIC_STRIPE_KEY'],
143
+ });
126
144
  ```
127
145
 
146
+ **Rule:** If a variable contains a key, secret, token, or password, it MUST be in `ServerEnvSchema` without `NEXT_PUBLIC_` prefix.
147
+
128
148
  ### Reusable Schemas
129
149
 
130
150
  ```tsx
@@ -160,26 +180,72 @@ const ContactSchema = z.object({
160
180
  });
161
181
  ```
162
182
 
163
- ## Integration with Laravel (Inertia.js)
183
+ ## Integration with Next.js Server Actions
164
184
 
165
185
  ```tsx
166
- // Frontend validates BEFORE sending to Laravel
167
- const StoreLeadSchema = z.object({
186
+ 'use server';
187
+
188
+ import { z } from 'zod';
189
+
190
+ const CreateLeadSchema = z.object({
168
191
  name: z.string().min(2),
169
- email: EmailSchema,
170
- domain_id: UUIDSchema,
192
+ email: z.string().email().toLowerCase().trim(),
193
+ domainId: z.string().uuid(),
171
194
  });
172
195
 
173
- // In component
174
- const handleSubmit = (formData: unknown) => {
175
- const result = StoreLeadSchema.safeParse(formData);
196
+ export async function createLead(formData: FormData) {
197
+ const result = CreateLeadSchema.safeParse({
198
+ name: formData.get('name'),
199
+ email: formData.get('email'),
200
+ domainId: formData.get('domainId'),
201
+ });
202
+
176
203
  if (!result.success) {
177
- setErrors(result.error.flatten().fieldErrors);
178
- return;
204
+ return { errors: result.error.flatten().fieldErrors };
179
205
  }
180
- // Send validated data to Laravel
181
- router.post('/leads', result.data);
182
- };
206
+
207
+ await db.lead.create({ data: result.data });
208
+ return { success: true };
209
+ }
210
+ ```
211
+
212
+ ### Client-Side with React Hook Form
213
+
214
+ ```tsx
215
+ 'use client';
216
+
217
+ import { useForm } from 'react-hook-form';
218
+ import { zodResolver } from '@hookform/resolvers/zod';
219
+
220
+ const CreateLeadSchema = z.object({
221
+ name: z.string().min(2),
222
+ email: z.string().email(),
223
+ domainId: z.string().uuid(),
224
+ });
225
+
226
+ type CreateLeadForm = z.infer<typeof CreateLeadSchema>;
227
+
228
+ export function LeadForm() {
229
+ const { register, handleSubmit, formState: { errors } } = useForm<CreateLeadForm>({
230
+ resolver: zodResolver(CreateLeadSchema),
231
+ });
232
+
233
+ const onSubmit = async (data: CreateLeadForm) => {
234
+ await fetch('/api/leads', {
235
+ method: 'POST',
236
+ headers: { 'Content-Type': 'application/json' },
237
+ body: JSON.stringify(data),
238
+ });
239
+ };
240
+
241
+ return (
242
+ <form onSubmit={handleSubmit(onSubmit)}>
243
+ <input {...register('name')} />
244
+ {errors.name && <span>{errors.name.message}</span>}
245
+ {/* ... */}
246
+ </form>
247
+ );
248
+ }
183
249
  ```
184
250
 
185
251
  ## FORBIDDEN