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.
- package/README.md +2 -2
- package/dist/detector.js +23 -11
- package/dist/index.js +78 -5
- package/dist/scanner.d.ts +12 -0
- package/dist/scanner.js +501 -0
- package/dist/setup.js +46 -1
- package/dist/types.d.ts +24 -0
- package/dist/ui.js +6 -5
- package/package.json +1 -1
- package/stacks/_shared/config/security-rules.json +27 -5
- package/stacks/_shared/hooks/user-prompt-submit.ts +26 -2
- package/stacks/frontend/react/skills/preline-ui/SKILL.md +31 -35
- package/stacks/frontend/react/skills/react-standards/SKILL.md +20 -20
- package/stacks/frontend/react/skills/react-ui-patterns/SKILL.md +78 -42
- package/stacks/frontend/react/skills/tailwind-patterns/SKILL.md +1 -1
- package/stacks/frontend/react/skills/zod-validation/SKILL.md +84 -18
- package/stacks/frontend/react-inertia/skills/inertia-react/SKILL.md +342 -0
- package/stacks/frontend/react-inertia/skills/react-standards/SKILL.md +267 -0
- package/stacks/nodejs/skills/nextjs-app-router/SKILL.md +101 -0
- package/stacks/nodejs/stack.json +43 -121
- package/stacks/php/skills/laravel-octane/SKILL.md +155 -53
- package/stacks/php/skills/laravel-patterns/SKILL.md +244 -39
- package/stacks/php/skills/php-patterns/SKILL.md +113 -53
- package/stacks/php/skills/security-scan-php/SKILL.md +161 -43
- package/stacks/php/stack.json +19 -6
- package/templates/CLAUDE-nodejs.md +323 -0
- package/templates/CLAUDE-php.md +233 -33
|
@@ -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
|
|
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
|
-
/*
|
|
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:
|
|
34
|
+
### Step 3: Init Preline on Route Changes (MANDATORY)
|
|
35
35
|
|
|
36
|
-
```
|
|
37
|
-
//
|
|
38
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
43
|
+
export function PrelineInit() {
|
|
44
|
+
const pathname = usePathname();
|
|
51
45
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
import
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
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
|
|
84
|
-
- Replace `<a href>` with `<Link href>` (
|
|
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
|
-
-
|
|
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
|
|
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 ./
|
|
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
|
|
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
|
-
##
|
|
8
|
+
## Label Constants Pattern
|
|
9
9
|
|
|
10
10
|
```tsx
|
|
11
|
-
// ✅
|
|
11
|
+
// ✅ Labels as CONST at the top, BEFORE hooks
|
|
12
12
|
const LABELS = {
|
|
13
|
-
title:
|
|
14
|
-
save:
|
|
15
|
-
cancel:
|
|
16
|
-
errorRequired:
|
|
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
|
|
26
|
-
return <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
|
-
-
|
|
31
|
-
-
|
|
32
|
-
- Error strings centralized in
|
|
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.
|
|
68
|
+
// 1. LABELS (before hooks)
|
|
69
69
|
// ═══════════════════════════════════════════
|
|
70
70
|
const LABELS = {
|
|
71
|
-
title:
|
|
72
|
-
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
|
-
//
|
|
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
|
-
//
|
|
192
|
-
import CheckIcon from '@/
|
|
193
|
-
import { CheckIcon, AlertIcon } from '@/
|
|
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: () =>
|
|
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 (
|
|
194
|
+
## Form Pattern (React Hook Form + Zod)
|
|
195
195
|
|
|
196
196
|
```tsx
|
|
197
|
-
import { useForm } from '
|
|
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 {
|
|
201
|
-
|
|
202
|
-
|
|
211
|
+
const {
|
|
212
|
+
register,
|
|
213
|
+
handleSubmit,
|
|
214
|
+
reset,
|
|
215
|
+
formState: { errors },
|
|
216
|
+
} = useForm<CreateUserForm>({
|
|
217
|
+
resolver: zodResolver(CreateUserSchema),
|
|
203
218
|
});
|
|
204
219
|
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
|
|
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={
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
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
|
-
{
|
|
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
|
-
|
|
277
|
+
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
278
|
+
|
|
259
279
|
function ToggleFavorite({ item }: { item: Item }) {
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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={
|
|
275
|
-
{
|
|
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={
|
|
333
|
+
4. **Clickable button during submit** — always `disabled={isPending}` or `disabled={isSubmitting}`
|
|
298
334
|
5. **Console.log-only errors** — user must see feedback
|
|
@@ -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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
|
183
|
+
## Integration with Next.js Server Actions
|
|
164
184
|
|
|
165
185
|
```tsx
|
|
166
|
-
|
|
167
|
-
|
|
186
|
+
'use server';
|
|
187
|
+
|
|
188
|
+
import { z } from 'zod';
|
|
189
|
+
|
|
190
|
+
const CreateLeadSchema = z.object({
|
|
168
191
|
name: z.string().min(2),
|
|
169
|
-
email:
|
|
170
|
-
|
|
192
|
+
email: z.string().email().toLowerCase().trim(),
|
|
193
|
+
domainId: z.string().uuid(),
|
|
171
194
|
});
|
|
172
195
|
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
return;
|
|
204
|
+
return { errors: result.error.flatten().fieldErrors };
|
|
179
205
|
}
|
|
180
|
-
|
|
181
|
-
|
|
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
|