start-vibing-stacks 2.8.0 → 2.10.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/dist/index.js +2 -1
- package/dist/setup.js +10 -2
- package/dist/types.d.ts +8 -0
- package/package.json +1 -1
- package/stacks/_shared/agents/.archive/claude-md-compactor.v1.0.0.md +160 -0
- package/stacks/_shared/agents/claude-md-compactor.md +223 -110
- package/stacks/frontend/react-api/skills/axios-laravel-api/SKILL.md +466 -0
- package/stacks/frontend/react-api/skills/react-api-standards/SKILL.md +509 -0
- package/stacks/frontend/react-inertia/skills/inertia-react/SKILL.md +11 -2
- package/stacks/frontend/react-inertia/skills/react-standards/SKILL.md +9 -2
- package/stacks/php/skills/api-design/SKILL.md +281 -47
- package/stacks/php/skills/api-security/SKILL.md +128 -49
- package/stacks/php/skills/inertia-react/SKILL.md +16 -3
- package/stacks/php/skills/laravel-api-architecture/SKILL.md +650 -0
- package/stacks/php/skills/laravel-inertia-i18n/SKILL.md +11 -3
- package/stacks/php/skills/laravel-patterns/SKILL.md +123 -61
- package/stacks/php/stack.json +19 -6
- package/templates/CLAUDE-php.md +202 -101
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: react-api-standards
|
|
3
|
+
version: 1.0.0
|
|
4
|
+
description: React 19 + Vite + Axios standards for a Laravel-backed JSON API SPA
|
|
5
|
+
— Page shell + skeleton, async data via `api.get`, form 422 binding, route
|
|
6
|
+
catch-all, no Inertia. Use for any new React page in a Laravel + Sanctum SPA
|
|
7
|
+
project. Pairs with `axios-laravel-api` and `laravel-api-architecture`.
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# React 19 API-First Standards (Laravel + Axios)
|
|
11
|
+
|
|
12
|
+
**ALWAYS invoke when creating React pages, components, or layouts in a project
|
|
13
|
+
that uses a Laravel JSON API as backend (NOT Inertia.js).**
|
|
14
|
+
|
|
15
|
+
## Version Requirements
|
|
16
|
+
|
|
17
|
+
- **React >= 19** — MANDATORY (`useActionState`, `useOptimistic`, `use`)
|
|
18
|
+
- **Vite >= 5** with `@vitejs/plugin-react`
|
|
19
|
+
- **TailwindCSS >= 4**
|
|
20
|
+
- **Axios >= 1.6** (for `withXSRFToken` support)
|
|
21
|
+
- **TanStack Query >= 5** (recommended for cached lists/details)
|
|
22
|
+
- **react-router-dom >= 6** (client-side routing)
|
|
23
|
+
|
|
24
|
+
## Folder Structure
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
resources/js/
|
|
28
|
+
├── app.jsx # Root: <BrowserRouter> + <QueryClientProvider>
|
|
29
|
+
├── App.jsx # Top-level <Routes> with <ProtectedRoute>
|
|
30
|
+
├── lib/
|
|
31
|
+
│ ├── api.js # Axios instance (see axios-laravel-api skill)
|
|
32
|
+
│ ├── auth.js # login/logout/fetchCurrentUser
|
|
33
|
+
│ ├── queryClient.ts # TanStack QueryClient config
|
|
34
|
+
│ └── utils.ts # cn(), formatters, date helpers
|
|
35
|
+
├── Pages/
|
|
36
|
+
│ ├── Dashboard/
|
|
37
|
+
│ │ └── Index.tsx # Renders shell+skeleton; calls api.get
|
|
38
|
+
│ ├── Users/
|
|
39
|
+
│ │ ├── Index.tsx
|
|
40
|
+
│ │ ├── Show.tsx
|
|
41
|
+
│ │ └── _components/ # Page-private components
|
|
42
|
+
│ │ ├── UsersTable.tsx
|
|
43
|
+
│ │ └── UsersTableSkeleton.tsx
|
|
44
|
+
│ └── Auth/
|
|
45
|
+
│ ├── Login.tsx
|
|
46
|
+
│ └── Register.tsx
|
|
47
|
+
├── Components/ # Reusable cross-page components
|
|
48
|
+
│ ├── ErrorState.tsx
|
|
49
|
+
│ ├── EmptyState.tsx
|
|
50
|
+
│ ├── ProtectedRoute.tsx
|
|
51
|
+
│ └── SectionLoader.tsx
|
|
52
|
+
├── Layouts/
|
|
53
|
+
│ ├── AuthenticatedLayout.tsx
|
|
54
|
+
│ └── GuestLayout.tsx
|
|
55
|
+
├── Icons/ # Each icon is a separate .svg file
|
|
56
|
+
│ ├── index.js # Barrel
|
|
57
|
+
│ ├── CheckIcon.svg
|
|
58
|
+
│ └── AlertIcon.svg
|
|
59
|
+
└── store/ # Zustand / Context for cross-page UI state
|
|
60
|
+
└── auth.ts
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## The Page Contract
|
|
64
|
+
|
|
65
|
+
Every Page component MUST:
|
|
66
|
+
|
|
67
|
+
1. **Render shell + skeleton on first paint** — never block on data.
|
|
68
|
+
2. **Fetch via `api` instance** (or `useQuery`) — never raw `axios`/`fetch`.
|
|
69
|
+
3. **Show error/empty/loading states** before the happy path.
|
|
70
|
+
4. **Define `LABELS` and `STYLES` as `const` ABOVE the component** — stable refs.
|
|
71
|
+
|
|
72
|
+
```tsx
|
|
73
|
+
import { useEffect, useState } from 'react';
|
|
74
|
+
import api from '@/lib/api';
|
|
75
|
+
import ErrorState from '@/Components/ErrorState';
|
|
76
|
+
import EmptyState from '@/Components/EmptyState';
|
|
77
|
+
import OrdersTable from './_components/OrdersTable';
|
|
78
|
+
import OrdersTableSkeleton from './_components/OrdersTableSkeleton';
|
|
79
|
+
|
|
80
|
+
const LABELS = {
|
|
81
|
+
title: 'Orders',
|
|
82
|
+
empty: 'No orders yet',
|
|
83
|
+
emptyHint: 'Orders will appear here once customers start checking out.',
|
|
84
|
+
} as const;
|
|
85
|
+
|
|
86
|
+
const STYLES = {
|
|
87
|
+
page: 'max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8',
|
|
88
|
+
heading: 'text-2xl font-bold text-foreground',
|
|
89
|
+
section: 'mt-6',
|
|
90
|
+
} as const;
|
|
91
|
+
|
|
92
|
+
export default function OrdersIndexPage() {
|
|
93
|
+
const [data, setData] = useState<{ data: Order[]; meta: Meta } | null>(null);
|
|
94
|
+
const [error, setError] = useState<string | null>(null);
|
|
95
|
+
const [loading, setLoading] = useState(true);
|
|
96
|
+
|
|
97
|
+
const load = async () => {
|
|
98
|
+
try {
|
|
99
|
+
setError(null);
|
|
100
|
+
setLoading(true);
|
|
101
|
+
const res = await api.get('/api/orders');
|
|
102
|
+
setData(res.data);
|
|
103
|
+
} catch (e: any) {
|
|
104
|
+
setError(e.response?.data?.message ?? 'Failed to load orders');
|
|
105
|
+
} finally {
|
|
106
|
+
setLoading(false);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
useEffect(() => { load(); }, []);
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div className={STYLES.page}>
|
|
114
|
+
<h1 className={STYLES.heading}>{LABELS.title}</h1>
|
|
115
|
+
|
|
116
|
+
<section className={STYLES.section}>
|
|
117
|
+
{error && <ErrorState error={error} onRetry={load} />}
|
|
118
|
+
{!error && loading && !data && <OrdersTableSkeleton />}
|
|
119
|
+
{!error && data && data.data.length === 0 && (
|
|
120
|
+
<EmptyState title={LABELS.empty} description={LABELS.emptyHint} />
|
|
121
|
+
)}
|
|
122
|
+
{!error && data && data.data.length > 0 && (
|
|
123
|
+
<OrdersTable orders={data.data} meta={data.meta} />
|
|
124
|
+
)}
|
|
125
|
+
</section>
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## TanStack Query Variant (Preferred for any List/Detail)
|
|
132
|
+
|
|
133
|
+
```tsx
|
|
134
|
+
import { useQuery } from '@tanstack/react-query';
|
|
135
|
+
import { useSearchParams } from 'react-router-dom';
|
|
136
|
+
import api from '@/lib/api';
|
|
137
|
+
|
|
138
|
+
const fetchOrders = async (params: { page: number; per_page: number; status?: string }) => {
|
|
139
|
+
const { data } = await api.get('/api/orders', { params });
|
|
140
|
+
return data;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export default function OrdersIndexPage() {
|
|
144
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
145
|
+
const page = Number(searchParams.get('page') ?? 1);
|
|
146
|
+
const status = searchParams.get('status') ?? undefined;
|
|
147
|
+
|
|
148
|
+
const { data, error, isPending, refetch } = useQuery({
|
|
149
|
+
queryKey: ['orders', { page, status }],
|
|
150
|
+
queryFn: () => fetchOrders({ page, per_page: 25, status }),
|
|
151
|
+
placeholderData: (prev) => prev, // smooth pagination
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (error) return <ErrorState error={error} onRetry={refetch} />;
|
|
155
|
+
if (isPending && !data) return <OrdersTableSkeleton />;
|
|
156
|
+
if (!data?.data.length) return <EmptyState title={LABELS.empty} />;
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<OrdersTable
|
|
160
|
+
orders={data.data}
|
|
161
|
+
meta={data.meta}
|
|
162
|
+
onPageChange={(p) => setSearchParams({ page: String(p), status: status ?? '' })}
|
|
163
|
+
/>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
**Rules:**
|
|
169
|
+
|
|
170
|
+
- `queryKey` MUST include all filter inputs that change the response.
|
|
171
|
+
- `placeholderData: (prev) => prev` keeps the old table visible while the new
|
|
172
|
+
page loads — no skeleton flash on pagination.
|
|
173
|
+
- URL state via `useSearchParams` so users can bookmark filtered views.
|
|
174
|
+
|
|
175
|
+
## Mutations + Optimistic UI
|
|
176
|
+
|
|
177
|
+
```tsx
|
|
178
|
+
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
179
|
+
import api from '@/lib/api';
|
|
180
|
+
|
|
181
|
+
function ToggleArchive({ order }: { order: Order }) {
|
|
182
|
+
const qc = useQueryClient();
|
|
183
|
+
const m = useMutation({
|
|
184
|
+
mutationFn: () => api.post(`/api/orders/${order.id}/archive`),
|
|
185
|
+
onMutate: async () => {
|
|
186
|
+
await qc.cancelQueries({ queryKey: ['orders'] });
|
|
187
|
+
const prev = qc.getQueriesData<any>({ queryKey: ['orders'] });
|
|
188
|
+
qc.setQueriesData({ queryKey: ['orders'] }, (old: any) => ({
|
|
189
|
+
...old,
|
|
190
|
+
data: old.data.map((o: Order) =>
|
|
191
|
+
o.id === order.id ? { ...o, archived: !o.archived } : o,
|
|
192
|
+
),
|
|
193
|
+
}));
|
|
194
|
+
return { prev };
|
|
195
|
+
},
|
|
196
|
+
onError: (_err, _vars, ctx) => {
|
|
197
|
+
ctx?.prev?.forEach(([key, value]) => qc.setQueryData(key, value));
|
|
198
|
+
},
|
|
199
|
+
onSettled: () => qc.invalidateQueries({ queryKey: ['orders'] }),
|
|
200
|
+
});
|
|
201
|
+
return <button onClick={() => m.mutate()} disabled={m.isPending}>Archive</button>;
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## Forms — 422 Validation Binding
|
|
206
|
+
|
|
207
|
+
```tsx
|
|
208
|
+
import { useState } from 'react';
|
|
209
|
+
import api from '@/lib/api';
|
|
210
|
+
|
|
211
|
+
const STYLES = {
|
|
212
|
+
form: 'space-y-4 max-w-md',
|
|
213
|
+
label: 'block text-sm font-medium text-foreground mb-1',
|
|
214
|
+
input: 'w-full h-10 px-3 rounded-md border border-border bg-background',
|
|
215
|
+
inputError: 'w-full h-10 px-3 rounded-md border border-destructive bg-background',
|
|
216
|
+
error: 'mt-1 text-sm text-destructive',
|
|
217
|
+
btn: 'px-4 py-2 bg-primary text-primary-foreground rounded-lg disabled:opacity-50',
|
|
218
|
+
} as const;
|
|
219
|
+
|
|
220
|
+
export default function CreateOrderForm({ onCreated }: { onCreated: () => void }) {
|
|
221
|
+
const [form, setForm] = useState({ product_id: '', quantity: 1 });
|
|
222
|
+
const [errors, setErrors] = useState<Record<string, string[]>>({});
|
|
223
|
+
const [submitting, setSubmitting] = useState(false);
|
|
224
|
+
|
|
225
|
+
const submit = async (e: React.FormEvent) => {
|
|
226
|
+
e.preventDefault();
|
|
227
|
+
setErrors({});
|
|
228
|
+
setSubmitting(true);
|
|
229
|
+
try {
|
|
230
|
+
const { data } = await api.post('/api/orders', form);
|
|
231
|
+
onCreated();
|
|
232
|
+
} catch (err: any) {
|
|
233
|
+
if (err.validation) setErrors(err.validation);
|
|
234
|
+
} finally {
|
|
235
|
+
setSubmitting(false);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const cls = (field: string) =>
|
|
240
|
+
errors[field]?.length ? STYLES.inputError : STYLES.input;
|
|
241
|
+
|
|
242
|
+
return (
|
|
243
|
+
<form className={STYLES.form} onSubmit={submit}>
|
|
244
|
+
<div>
|
|
245
|
+
<label className={STYLES.label}>Product ID</label>
|
|
246
|
+
<input
|
|
247
|
+
className={cls('product_id')}
|
|
248
|
+
value={form.product_id}
|
|
249
|
+
onChange={(e) => setForm({ ...form, product_id: e.target.value })}
|
|
250
|
+
/>
|
|
251
|
+
{errors.product_id?.[0] && (
|
|
252
|
+
<p className={STYLES.error}>{errors.product_id[0]}</p>
|
|
253
|
+
)}
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
<button type="submit" disabled={submitting} className={STYLES.btn}>
|
|
257
|
+
{submitting ? 'Creating…' : 'Create Order'}
|
|
258
|
+
</button>
|
|
259
|
+
</form>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
## Authentication Layer
|
|
265
|
+
|
|
266
|
+
```tsx
|
|
267
|
+
// resources/js/store/auth.ts
|
|
268
|
+
import { create } from 'zustand';
|
|
269
|
+
import api from '@/lib/api';
|
|
270
|
+
import { fetchCurrentUser, login as doLogin, logout as doLogout } from '@/lib/auth';
|
|
271
|
+
|
|
272
|
+
interface AuthState {
|
|
273
|
+
user: User | null;
|
|
274
|
+
booted: boolean;
|
|
275
|
+
boot: () => Promise<void>;
|
|
276
|
+
login: (email: string, password: string) => Promise<void>;
|
|
277
|
+
logout: () => Promise<void>;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export const useAuth = create<AuthState>((set) => ({
|
|
281
|
+
user: null,
|
|
282
|
+
booted: false,
|
|
283
|
+
boot: async () => {
|
|
284
|
+
try {
|
|
285
|
+
const user = await fetchCurrentUser();
|
|
286
|
+
set({ user, booted: true });
|
|
287
|
+
} catch {
|
|
288
|
+
set({ user: null, booted: true });
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
login: async (email, password) => {
|
|
292
|
+
await doLogin(email, password);
|
|
293
|
+
const user = await fetchCurrentUser();
|
|
294
|
+
set({ user });
|
|
295
|
+
},
|
|
296
|
+
logout: async () => {
|
|
297
|
+
await doLogout();
|
|
298
|
+
set({ user: null });
|
|
299
|
+
},
|
|
300
|
+
}));
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
```tsx
|
|
304
|
+
// resources/js/Components/ProtectedRoute.tsx
|
|
305
|
+
import { Navigate, useLocation } from 'react-router-dom';
|
|
306
|
+
import { useAuth } from '@/store/auth';
|
|
307
|
+
import SectionLoader from './SectionLoader';
|
|
308
|
+
|
|
309
|
+
export default function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
|
310
|
+
const { user, booted } = useAuth();
|
|
311
|
+
const location = useLocation();
|
|
312
|
+
|
|
313
|
+
if (!booted) return <SectionLoader />;
|
|
314
|
+
if (!user) return <Navigate to="/login" state={{ from: location }} replace />;
|
|
315
|
+
|
|
316
|
+
return <>{children}</>;
|
|
317
|
+
}
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
```tsx
|
|
321
|
+
// resources/js/App.tsx
|
|
322
|
+
import { useEffect } from 'react';
|
|
323
|
+
import { Routes, Route } from 'react-router-dom';
|
|
324
|
+
import { useAuth } from './store/auth';
|
|
325
|
+
import ProtectedRoute from './Components/ProtectedRoute';
|
|
326
|
+
import AuthenticatedLayout from './Layouts/AuthenticatedLayout';
|
|
327
|
+
import LoginPage from './Pages/Auth/Login';
|
|
328
|
+
import DashboardPage from './Pages/Dashboard/Index';
|
|
329
|
+
|
|
330
|
+
export default function App() {
|
|
331
|
+
const boot = useAuth((s) => s.boot);
|
|
332
|
+
useEffect(() => { boot(); }, [boot]);
|
|
333
|
+
|
|
334
|
+
return (
|
|
335
|
+
<Routes>
|
|
336
|
+
<Route path="/login" element={<LoginPage />} />
|
|
337
|
+
<Route element={<ProtectedRoute><AuthenticatedLayout /></ProtectedRoute>}>
|
|
338
|
+
<Route path="/" element={<DashboardPage />} />
|
|
339
|
+
{/* ... */}
|
|
340
|
+
</Route>
|
|
341
|
+
</Routes>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
## Skeletons — One per Component, Match the Shape
|
|
347
|
+
|
|
348
|
+
```tsx
|
|
349
|
+
// resources/js/Pages/Orders/_components/OrdersTableSkeleton.tsx
|
|
350
|
+
const STYLES = {
|
|
351
|
+
table: 'w-full',
|
|
352
|
+
headerRow: 'border-b border-border',
|
|
353
|
+
headerCell: 'h-4 w-24 bg-muted rounded animate-pulse my-3 mx-4',
|
|
354
|
+
row: 'border-b border-border',
|
|
355
|
+
cell: 'h-4 w-full bg-muted rounded animate-pulse my-4 mx-4',
|
|
356
|
+
} as const;
|
|
357
|
+
|
|
358
|
+
export default function OrdersTableSkeleton({ rows = 8 }: { rows?: number }) {
|
|
359
|
+
return (
|
|
360
|
+
<div role="status" aria-label="Loading orders" className={STYLES.table}>
|
|
361
|
+
<div className={STYLES.headerRow}>
|
|
362
|
+
{Array.from({ length: 4 }).map((_, i) => (
|
|
363
|
+
<div key={i} className={STYLES.headerCell} />
|
|
364
|
+
))}
|
|
365
|
+
</div>
|
|
366
|
+
{Array.from({ length: rows }).map((_, r) => (
|
|
367
|
+
<div key={r} className={STYLES.row}>
|
|
368
|
+
{Array.from({ length: 4 }).map((_, c) => (
|
|
369
|
+
<div key={c} className={STYLES.cell} />
|
|
370
|
+
))}
|
|
371
|
+
</div>
|
|
372
|
+
))}
|
|
373
|
+
</div>
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
## Error & Empty States (Reusable)
|
|
379
|
+
|
|
380
|
+
```tsx
|
|
381
|
+
// resources/js/Components/ErrorState.tsx
|
|
382
|
+
const STYLES = {
|
|
383
|
+
wrap: 'flex flex-col items-center justify-center py-12 text-center',
|
|
384
|
+
icon: 'h-12 w-12 rounded-full bg-destructive/10 flex items-center justify-center mb-4',
|
|
385
|
+
title: 'text-lg font-semibold text-foreground',
|
|
386
|
+
msg: 'mt-1 text-sm text-muted-foreground max-w-md',
|
|
387
|
+
btn: 'mt-4 px-4 py-2 bg-primary text-primary-foreground rounded-lg',
|
|
388
|
+
} as const;
|
|
389
|
+
|
|
390
|
+
export default function ErrorState({
|
|
391
|
+
error, onRetry, title = 'Something went wrong',
|
|
392
|
+
}: { error: unknown; onRetry?: () => void; title?: string }) {
|
|
393
|
+
const message = typeof error === 'string'
|
|
394
|
+
? error
|
|
395
|
+
: (error as any)?.message ?? 'Please try again.';
|
|
396
|
+
return (
|
|
397
|
+
<div className={STYLES.wrap}>
|
|
398
|
+
<div className={STYLES.icon}>!</div>
|
|
399
|
+
<h3 className={STYLES.title}>{title}</h3>
|
|
400
|
+
<p className={STYLES.msg}>{message}</p>
|
|
401
|
+
{onRetry && <button className={STYLES.btn} onClick={onRetry}>Try again</button>}
|
|
402
|
+
</div>
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
## TypeScript — Shape Types from Resources
|
|
408
|
+
|
|
409
|
+
```ts
|
|
410
|
+
// resources/js/types/index.d.ts
|
|
411
|
+
|
|
412
|
+
export interface PaginatedResponse<T> {
|
|
413
|
+
data: T[];
|
|
414
|
+
meta: {
|
|
415
|
+
current_page: number;
|
|
416
|
+
last_page: number;
|
|
417
|
+
per_page: number;
|
|
418
|
+
total: number;
|
|
419
|
+
};
|
|
420
|
+
links: {
|
|
421
|
+
first: string | null;
|
|
422
|
+
last: string | null;
|
|
423
|
+
next: string | null;
|
|
424
|
+
prev: string | null;
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export interface User {
|
|
429
|
+
id: string;
|
|
430
|
+
name: string;
|
|
431
|
+
email: string;
|
|
432
|
+
role: 'user' | 'admin' | 'superadmin';
|
|
433
|
+
created_at: string;
|
|
434
|
+
updated_at: string;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export interface Order {
|
|
438
|
+
id: string;
|
|
439
|
+
product_id: string;
|
|
440
|
+
quantity: number;
|
|
441
|
+
status: 'pending' | 'paid' | 'shipped' | 'cancelled';
|
|
442
|
+
created_at: string;
|
|
443
|
+
}
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
**Rule:** Types live in `resources/js/types/`, NOT scattered in components.
|
|
447
|
+
Match the field names from your Laravel API Resources exactly.
|
|
448
|
+
|
|
449
|
+
## Path Aliases (`@/...`)
|
|
450
|
+
|
|
451
|
+
```js
|
|
452
|
+
// vite.config.js
|
|
453
|
+
import path from 'path';
|
|
454
|
+
|
|
455
|
+
resolve: {
|
|
456
|
+
alias: {
|
|
457
|
+
'@': path.resolve(__dirname, 'resources/js'),
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
```json
|
|
463
|
+
// tsconfig.json
|
|
464
|
+
{
|
|
465
|
+
"compilerOptions": {
|
|
466
|
+
"baseUrl": ".",
|
|
467
|
+
"paths": {
|
|
468
|
+
"@/*": ["resources/js/*"]
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
## Checklist — Before Shipping a Page
|
|
475
|
+
|
|
476
|
+
- [ ] Renders shell + heading on first paint (no waterfall blocking)
|
|
477
|
+
- [ ] Skeleton matches the shape of the loaded content
|
|
478
|
+
- [ ] Error state with retry handler
|
|
479
|
+
- [ ] Empty state for collections
|
|
480
|
+
- [ ] All API calls go through `@/lib/api` (interceptors apply)
|
|
481
|
+
- [ ] Form errors bound from `error.validation` (422)
|
|
482
|
+
- [ ] LABELS + STYLES defined as `const` above the component
|
|
483
|
+
- [ ] Page is wrapped in `<ProtectedRoute>` if auth required
|
|
484
|
+
- [ ] Filters reflected in URL via `useSearchParams`
|
|
485
|
+
|
|
486
|
+
## FORBIDDEN
|
|
487
|
+
|
|
488
|
+
| Pattern | Reason | Use Instead |
|
|
489
|
+
|--------|--------|-------------|
|
|
490
|
+
| `Inertia::render()` for new pages | Blocks first paint on DB | `api.get()` from React |
|
|
491
|
+
| `fetch()` in components | Bypasses CSRF/interceptors | `api.get/post/...` |
|
|
492
|
+
| `axios.get(...)` direct | Bypasses interceptors | `import api from '@/lib/api'` |
|
|
493
|
+
| `useEffect` to derive state | Anti-pattern | `useMemo` |
|
|
494
|
+
| `<a href>` for internal links | Full reload | `<Link>` from react-router-dom |
|
|
495
|
+
| `window.location.assign` for SPA nav | Full reload | `useNavigate()` |
|
|
496
|
+
| `localStorage` for tokens | XSS-readable | HttpOnly session cookie (Sanctum) |
|
|
497
|
+
| Skipping skeleton/empty/error states | Bad UX | All three are mandatory per page |
|
|
498
|
+
| Inline Tailwind class soup | Unstable refs | `STYLES` const at top |
|
|
499
|
+
| `__()`-style i18n inside JSX | React Hook violation | LABELS const at top |
|
|
500
|
+
| `dd()`-style debug | n/a in JS | Controlled debug constant pattern |
|
|
501
|
+
| Storing API tokens in `VITE_*` | Bundled = public | Server-side via session/Sanctum |
|
|
502
|
+
|
|
503
|
+
## See Also
|
|
504
|
+
|
|
505
|
+
- `axios-laravel-api` — the `api.js` instance and Sanctum CSRF flow
|
|
506
|
+
- `laravel-api-architecture` — the backend Controller→Service→Resource pipeline
|
|
507
|
+
- `react-patterns` — generic React 19 hooks (`useActionState`, `useOptimistic`)
|
|
508
|
+
- `react-ui-patterns` — loading/error/empty patterns and decision tree
|
|
509
|
+
- `tailwind-patterns` — utility class composition with `cn()`
|
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: inertia-react
|
|
3
|
-
version:
|
|
3
|
+
version: 2.0.0
|
|
4
|
+
description: LEGACY skill — Inertia.js + React frontend integration with
|
|
5
|
+
Laravel-rendered pages. Use ONLY in pre-existing Inertia projects. For NEW
|
|
6
|
+
projects use the `react-api` frontend stack (`axios-laravel-api` +
|
|
7
|
+
`react-api-standards`) which decouples render from data fetching.
|
|
4
8
|
---
|
|
5
9
|
|
|
6
|
-
# Inertia.js + React Integration
|
|
10
|
+
# Inertia.js + React Integration (LEGACY)
|
|
11
|
+
|
|
12
|
+
> **STATUS: LEGACY.** New projects use the **React API SPA** stack — Laravel
|
|
13
|
+
> serves a Vite shell, React owns routing client-side, and data is fetched via
|
|
14
|
+
> Axios with Sanctum cookie auth. See `axios-laravel-api` and
|
|
15
|
+
> `react-api-standards`.
|
|
7
16
|
|
|
8
17
|
## Architecture Overview
|
|
9
18
|
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: react-standards
|
|
3
|
-
version:
|
|
3
|
+
version: 2.0.0
|
|
4
|
+
description: LEGACY — React 19 standards for Inertia.js projects (controller
|
|
5
|
+
props, useForm, Inertia router). Use ONLY in pre-existing Inertia projects.
|
|
6
|
+
For NEW projects use `react-api-standards` (Axios + TanStack Query + React
|
|
7
|
+
Router) instead.
|
|
4
8
|
---
|
|
5
9
|
|
|
6
|
-
# React 19+ Standards
|
|
10
|
+
# React 19+ Standards with Inertia.js (LEGACY)
|
|
11
|
+
|
|
12
|
+
> **STATUS: LEGACY.** New projects use `react-api-standards` (API-first SPA).
|
|
13
|
+
> Keep this loaded only for legacy Inertia codebases.
|
|
7
14
|
|
|
8
15
|
## Version Requirements
|
|
9
16
|
|