nextworks 0.1.0-alpha.11 → 0.1.0-alpha.14

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.
Files changed (57) hide show
  1. package/README.md +20 -9
  2. package/dist/kits/auth-core/.nextworks/docs/AUTH_CORE_README.md +3 -3
  3. package/dist/kits/auth-core/.nextworks/docs/AUTH_QUICKSTART.md +264 -244
  4. package/dist/kits/auth-core/app/(protected)/settings/profile/profile-form.tsx +120 -114
  5. package/dist/kits/auth-core/app/api/auth/forgot-password/route.ts +116 -114
  6. package/dist/kits/auth-core/app/api/auth/reset-password/route.ts +66 -63
  7. package/dist/kits/auth-core/app/api/auth/send-verify-email/route.ts +1 -1
  8. package/dist/kits/auth-core/app/api/users/[id]/route.ts +134 -127
  9. package/dist/kits/auth-core/app/auth/reset-password/page.tsx +186 -187
  10. package/dist/kits/auth-core/components/auth/dashboard.tsx +25 -2
  11. package/dist/kits/auth-core/components/auth/forgot-password-form.tsx +90 -90
  12. package/dist/kits/auth-core/components/auth/login-form.tsx +492 -467
  13. package/dist/kits/auth-core/components/auth/signup-form.tsx +28 -29
  14. package/dist/kits/auth-core/lib/auth.ts +46 -15
  15. package/dist/kits/auth-core/lib/forms/map-errors.ts +37 -11
  16. package/dist/kits/auth-core/lib/server/result.ts +45 -45
  17. package/dist/kits/auth-core/lib/validation/forms.ts +1 -2
  18. package/dist/kits/auth-core/package-deps.json +4 -2
  19. package/dist/kits/auth-core/types/next-auth.d.ts +1 -1
  20. package/dist/kits/blocks/.nextworks/docs/BLOCKS_QUICKSTART.md +2 -8
  21. package/dist/kits/blocks/.nextworks/docs/THEME_GUIDE.md +18 -1
  22. package/dist/kits/blocks/app/templates/productlaunch/page.tsx +0 -2
  23. package/dist/kits/blocks/components/sections/FAQ.tsx +0 -1
  24. package/dist/kits/blocks/components/sections/Newsletter.tsx +2 -2
  25. package/dist/kits/blocks/components/ui/switch.tsx +78 -78
  26. package/dist/kits/blocks/components/ui/theme-selector.tsx +1 -1
  27. package/dist/kits/blocks/lib/themes.ts +1 -0
  28. package/dist/kits/blocks/package-deps.json +4 -4
  29. package/dist/kits/data/.nextworks/docs/DATA_QUICKSTART.md +128 -112
  30. package/dist/kits/data/.nextworks/docs/DATA_README.md +2 -1
  31. package/dist/kits/data/app/api/posts/[id]/route.ts +83 -83
  32. package/dist/kits/data/app/api/posts/route.ts +136 -138
  33. package/dist/kits/data/app/api/seed-demo/route.ts +1 -2
  34. package/dist/kits/data/app/api/users/[id]/route.ts +29 -17
  35. package/dist/kits/data/app/api/users/check-email/route.ts +1 -1
  36. package/dist/kits/data/app/api/users/check-unique/route.ts +30 -27
  37. package/dist/kits/data/app/api/users/route.ts +0 -2
  38. package/dist/kits/data/app/examples/demo/create-post-form.tsx +108 -106
  39. package/dist/kits/data/app/examples/demo/page.tsx +2 -1
  40. package/dist/kits/data/app/examples/demo/seed-demo-button.tsx +1 -1
  41. package/dist/kits/data/components/admin/posts-manager.tsx +727 -719
  42. package/dist/kits/data/components/admin/users-manager.tsx +435 -432
  43. package/dist/kits/data/lib/server/result.ts +5 -2
  44. package/dist/kits/data/package-deps.json +1 -1
  45. package/dist/kits/data/scripts/seed-demo.mjs +1 -2
  46. package/dist/kits/forms/app/api/wizard/route.ts +76 -71
  47. package/dist/kits/forms/app/examples/forms/server-action/page.tsx +78 -71
  48. package/dist/kits/forms/components/hooks/useCheckUnique.ts +85 -79
  49. package/dist/kits/forms/components/ui/form/form-control.tsx +28 -28
  50. package/dist/kits/forms/components/ui/form/form-description.tsx +23 -22
  51. package/dist/kits/forms/components/ui/form/form-item.tsx +21 -21
  52. package/dist/kits/forms/components/ui/form/form-label.tsx +24 -24
  53. package/dist/kits/forms/components/ui/form/form-message.tsx +28 -29
  54. package/dist/kits/forms/components/ui/switch.tsx +78 -78
  55. package/dist/kits/forms/lib/forms/map-errors.ts +1 -1
  56. package/dist/kits/forms/lib/validation/forms.ts +1 -2
  57. package/package.json +1 -1
@@ -1,432 +1,435 @@
1
- "use client";
2
-
3
- import React, { useEffect, useMemo, useState, type JSX } from "react";
4
- import { useForm } from "react-hook-form";
5
- import { zodResolver } from "@hookform/resolvers/zod";
6
- import { cn } from "@/lib/utils";
7
- import { toast } from "sonner";
8
-
9
- import { Button } from "@/components/ui/button";
10
- import { Input } from "@/components/ui/input";
11
- import { Card } from "@/components/ui/card";
12
- import {
13
- Table,
14
- TableBody,
15
- TableCell,
16
- TableHead,
17
- TableHeader,
18
- TableRow,
19
- } from "@/components/ui/table";
20
- import {
21
- AlertDialog,
22
- AlertDialogAction,
23
- AlertDialogCancel,
24
- AlertDialogContent,
25
- AlertDialogDescription,
26
- AlertDialogFooter,
27
- AlertDialogHeader,
28
- AlertDialogTitle,
29
- AlertDialogTrigger,
30
- } from "@/components/ui/alert-dialog";
31
- import { Skeleton } from "@/components/ui/skeleton";
32
- import { Form } from "@/components/ui/form/form";
33
- import { FormField } from "@/components/ui/form/form-field";
34
- import { FormItem } from "@/components/ui/form/form-item";
35
- import { FormLabel } from "@/components/ui/form/form-label";
36
- import { FormMessage } from "@/components/ui/form/form-message";
37
- import { FormControl } from "@/components/ui/form/form-control";
38
- import { userSchema, type UserFormValues } from "@/lib/validation/forms";
39
- import { mapApiErrorsToForm } from "@/lib/forms/map-errors";
40
-
41
- export interface UsersManagerSlots {
42
- container?: { className?: string };
43
- formCard?: { className?: string };
44
- listCard?: { className?: string };
45
- leftHeading?: { className?: string };
46
- rightHeading?: { className?: string };
47
- form?: { className?: string };
48
- submitRow?: { className?: string };
49
- actionsRow?: { className?: string };
50
- table?: { className?: string };
51
- }
52
-
53
- export interface UsersManagerProps extends UsersManagerSlots {
54
- className?: string;
55
- }
56
-
57
- type User = {
58
- id: string;
59
- email: string;
60
- name?: string | null;
61
- createdAt?: string;
62
- };
63
-
64
- export function UsersManager({
65
- className,
66
- container = {
67
- className:
68
- "mx-auto grid w-full max-w-5xl gap-6 p-0 sm:gap-6 sm:p-6 md:grid-cols-5 md:gap-1 md:p-1 lg:gap-6 lg:p-6",
69
- },
70
- formCard = { className: "p-2 sm:p-6 md:col-span-2" },
71
- listCard = { className: "p-2 sm:p-6 md:col-span-3" },
72
- leftHeading = { className: "mb-4 text-lg font-semibold" },
73
- rightHeading = { className: "mb-3 font-medium" },
74
- form = { className: "space-y-4" },
75
- submitRow = { className: "flex items-center gap-3" },
76
- actionsRow = { className: "flex flex-col gap-2 lg:flex-row" },
77
- table = { className: "" },
78
- }: UsersManagerProps): JSX.Element {
79
- const [users, setUsers] = useState<User[]>([]);
80
- const [selectedId, setSelectedId] = useState<string | null>(null);
81
- const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
82
- const [loading, setLoading] = useState(false);
83
- const [listLoading, setListLoading] = useState(true);
84
-
85
- const defaultValues = useMemo<UserFormValues>(
86
- () => ({ email: "", name: "" }),
87
- [],
88
- );
89
-
90
- const formMethods = useForm<UserFormValues>({
91
- resolver: zodResolver(userSchema),
92
- defaultValues,
93
- });
94
-
95
- const {
96
- control,
97
- handleSubmit,
98
- reset,
99
- setValue,
100
- setError: setFieldError,
101
- formState: { isSubmitting },
102
- } = formMethods;
103
-
104
- const [page, setPage] = useState<number>(1);
105
- const [perPage, setPerPage] = useState<number>(8);
106
- const [total, setTotal] = useState<number>(0);
107
-
108
- const fetchUsers = async (opts?: { page?: number; perPage?: number }) => {
109
- setListLoading(true);
110
- try {
111
- const p = opts?.page ?? page;
112
- const pp = opts?.perPage ?? perPage;
113
- const params = new URLSearchParams();
114
- params.set("page", String(p));
115
- params.set("perPage", String(pp));
116
-
117
- const res = await fetch(`/api/users?${params.toString()}`, {
118
- cache: "no-store",
119
- });
120
- const payload = await res.json().catch(() => null);
121
-
122
- let data: unknown = payload ?? [];
123
- if (payload && typeof payload === "object" && "success" in payload) {
124
- data = (payload as any).data;
125
- }
126
-
127
- const items = Array.isArray(data)
128
- ? data
129
- : data && typeof data === "object" && "items" in (data as any)
130
- ? (data as any).items
131
- : [];
132
-
133
- setUsers(items as User[]);
134
-
135
- if (data && typeof data === "object") {
136
- const d = data as any;
137
- if (typeof d.total === "number") setTotal(d.total);
138
- if (typeof d.page === "number") setPage(d.page);
139
- if (typeof d.perPage === "number") setPerPage(d.perPage);
140
- } else if (Array.isArray(data)) {
141
- setTotal(items.length);
142
- } else {
143
- setTotal(0);
144
- }
145
-
146
- if (opts?.page) setPage(opts.page);
147
- if (opts?.perPage) setPerPage(opts.perPage);
148
- } finally {
149
- setListLoading(false);
150
- }
151
- };
152
-
153
- useEffect(() => {
154
- fetchUsers();
155
- // eslint-disable-next-line react-hooks/exhaustive-deps
156
- }, [page, perPage]);
157
-
158
- const createUser = async (values: UserFormValues) => {
159
- setLoading(true);
160
- try {
161
- const res = await fetch("/api/users", {
162
- method: "POST",
163
- headers: { "Content-Type": "application/json" },
164
- body: JSON.stringify(values),
165
- });
166
- const payload = await res.json().catch(() => null);
167
- if (!res.ok || !payload?.success) {
168
- const msg = payload
169
- ? mapApiErrorsToForm(formMethods, payload)
170
- : undefined;
171
- toast.error(msg || payload?.message || "Create failed");
172
- return;
173
- }
174
- reset(defaultValues);
175
- setSelectedId(null);
176
- await fetchUsers();
177
- toast.success("User created");
178
- } catch (_e) {
179
- toast.error("Create failed");
180
- } finally {
181
- setLoading(false);
182
- }
183
- };
184
-
185
- const updateUser = async (values: UserFormValues) => {
186
- if (!selectedId) return;
187
- setLoading(true);
188
- try {
189
- const res = await fetch(`/api/users/${selectedId}`, {
190
- method: "PUT",
191
- headers: { "Content-Type": "application/json" },
192
- body: JSON.stringify({ email: values.email, name: values.name }),
193
- });
194
- const payload = await res.json().catch(() => null);
195
- if (!res.ok || !payload?.success) {
196
- const msg = payload
197
- ? mapApiErrorsToForm(formMethods, payload)
198
- : undefined;
199
- toast.error(msg || payload?.message || "Update failed");
200
- return;
201
- }
202
- reset(defaultValues);
203
- setSelectedId(null);
204
- await fetchUsers();
205
- toast.success("User updated");
206
- } catch (_e) {
207
- toast.error("Update failed");
208
- } finally {
209
- setLoading(false);
210
- }
211
- };
212
-
213
- const onEdit = (u: User) => {
214
- setSelectedId(u.id);
215
- setValue("email", u.email);
216
- setValue("name", u.name ?? "");
217
- };
218
-
219
- const confirmDelete = (id: string) => setPendingDeleteId(id);
220
-
221
- const doDelete = async () => {
222
- if (!pendingDeleteId) return;
223
- setLoading(true);
224
- try {
225
- const res = await fetch(`/api/users/${pendingDeleteId}`, {
226
- method: "DELETE",
227
- });
228
- if (!res.ok) throw new Error("Delete failed");
229
- toast.success("User deleted");
230
- await fetchUsers();
231
- } catch (_e) {
232
- toast.error("Delete failed");
233
- } finally {
234
- setLoading(false);
235
- setPendingDeleteId(null);
236
- }
237
- };
238
-
239
- const onCancel = () => {
240
- reset(defaultValues);
241
- setSelectedId(null);
242
- };
243
-
244
- return (
245
- <main className={cn(container.className, className)}>
246
- <Card className={cn(formCard.className)}>
247
- <h2 className={cn(leftHeading.className)}>User Manager</h2>
248
-
249
- <Form<UserFormValues> methods={formMethods}>
250
- <form
251
- onSubmit={handleSubmit(selectedId ? updateUser : createUser)}
252
- className={cn(form.className)}
253
- >
254
- <FormField
255
- control={control}
256
- name="email"
257
- render={({ field }) => (
258
- <FormItem className="space-y-2">
259
- <FormLabel>Email</FormLabel>
260
- <FormControl>
261
- <Input
262
- id="email"
263
- type="email"
264
- placeholder="user@example.com"
265
- autoComplete="off"
266
- {...field}
267
- />
268
- </FormControl>
269
- <FormMessage />
270
- </FormItem>
271
- )}
272
- />
273
-
274
- <FormField
275
- control={control}
276
- name="name"
277
- render={({ field }) => (
278
- <FormItem className="space-y-2">
279
- <FormLabel>Name</FormLabel>
280
- <FormControl>
281
- <Input
282
- id="name"
283
- placeholder="Optional name"
284
- autoComplete="off"
285
- {...field}
286
- />
287
- </FormControl>
288
- <FormMessage />
289
- </FormItem>
290
- )}
291
- />
292
-
293
- <div className={cn(submitRow.className)}>
294
- {!selectedId ? (
295
- <Button type="submit" disabled={isSubmitting || loading}>
296
- Create User
297
- </Button>
298
- ) : (
299
- <>
300
- <Button type="submit" disabled={isSubmitting || loading}>
301
- Update User
302
- </Button>
303
- <Button type="button" variant="ghost" onClick={onCancel}>
304
- Cancel
305
- </Button>
306
- </>
307
- )}
308
- </div>
309
- </form>
310
- </Form>
311
- </Card>
312
-
313
- <Card className={cn(listCard.className)}>
314
- <h3 className={cn(rightHeading.className)}>Users</h3>
315
- <Table className={cn(table.className)}>
316
- <TableHeader>
317
- <TableRow>
318
- <TableHead>Email</TableHead>
319
- <TableHead>Name</TableHead>
320
- <TableHead className="w-[200px]">Actions</TableHead>
321
- </TableRow>
322
- </TableHeader>
323
- <TableBody>
324
- {listLoading ? (
325
- <>
326
- {[0, 1, 2].map((i) => (
327
- <TableRow key={i}>
328
- <TableCell>
329
- <Skeleton className="h-4 w-48" />
330
- </TableCell>
331
- <TableCell>
332
- <Skeleton className="h-4 w-32" />
333
- </TableCell>
334
- <TableCell>
335
- {/* actions cell empty during loading */}
336
- </TableCell>
337
- </TableRow>
338
- ))}
339
- </>
340
- ) : (
341
- <>
342
- {users.map((u) => (
343
- <TableRow key={u.id}>
344
- <TableCell className="font-medium">{u.email}</TableCell>
345
- <TableCell>{u.name || ""}</TableCell>
346
- <TableCell>
347
- <div className={cn(actionsRow.className)}>
348
- <Button
349
- size="sm"
350
- variant="outline"
351
- onClick={() => onEdit(u)}
352
- >
353
- Edit
354
- </Button>
355
- <AlertDialog>
356
- <AlertDialogTrigger asChild>
357
- <Button
358
- size="sm"
359
- variant="destructive"
360
- onClick={() => confirmDelete(u.id)}
361
- >
362
- Delete
363
- </Button>
364
- </AlertDialogTrigger>
365
- <AlertDialogContent>
366
- <AlertDialogHeader>
367
- <AlertDialogTitle>Delete user?</AlertDialogTitle>
368
- <AlertDialogDescription>
369
- This action cannot be undone. The user will be
370
- removed permanently.
371
- </AlertDialogDescription>
372
- </AlertDialogHeader>
373
- <AlertDialogFooter>
374
- <AlertDialogCancel
375
- onClick={() => setPendingDeleteId(null)}
376
- >
377
- Cancel
378
- </AlertDialogCancel>
379
- <AlertDialogAction onClick={doDelete}>
380
- Delete
381
- </AlertDialogAction>
382
- </AlertDialogFooter>
383
- </AlertDialogContent>
384
- </AlertDialog>
385
- </div>
386
- </TableCell>
387
- </TableRow>
388
- ))}
389
- {users.length === 0 && (
390
- <TableRow>
391
- <TableCell colSpan={3} className="text-muted-foreground">
392
- No users yet.
393
- </TableCell>
394
- </TableRow>
395
- )}
396
- </>
397
- )}
398
- </TableBody>
399
- </Table>
400
-
401
- <div className="mt-3 flex items-center justify-between">
402
- <div>
403
- <Button
404
- size="sm"
405
- onClick={() => setPage((p) => Math.max(1, p - 1))}
406
- disabled={page <= 1 || listLoading}
407
- >
408
- Prev
409
- </Button>
410
- <Button
411
- size="sm"
412
- variant="ghost"
413
- onClick={() => setPage((p) => p + 1)}
414
- disabled={
415
- listLoading ||
416
- (total > 0 ? page * perPage >= total : users.length === 0)
417
- }
418
- >
419
- Next
420
- </Button>
421
- </div>
422
- <div className="text-muted-foreground text-sm">
423
- Showing {Math.min((page - 1) * perPage + 1, total || 0)} -{" "}
424
- {Math.min(page * perPage, total || 0)} of {total}
425
- </div>
426
- </div>
427
- </Card>
428
- </main>
429
- );
430
- }
431
-
432
- export default UsersManager;
1
+ "use client";
2
+
3
+ import React, { useEffect, useMemo, useState, type JSX } from "react";
4
+ import { useForm } from "react-hook-form";
5
+ import { zodResolver } from "@hookform/resolvers/zod";
6
+ import { cn } from "@/lib/utils";
7
+ import { toast } from "sonner";
8
+
9
+ import { Button } from "@/components/ui/button";
10
+ import { Input } from "@/components/ui/input";
11
+ import { Card } from "@/components/ui/card";
12
+ import {
13
+ Table,
14
+ TableBody,
15
+ TableCell,
16
+ TableHead,
17
+ TableHeader,
18
+ TableRow,
19
+ } from "@/components/ui/table";
20
+ import {
21
+ AlertDialog,
22
+ AlertDialogAction,
23
+ AlertDialogCancel,
24
+ AlertDialogContent,
25
+ AlertDialogDescription,
26
+ AlertDialogFooter,
27
+ AlertDialogHeader,
28
+ AlertDialogTitle,
29
+ AlertDialogTrigger,
30
+ } from "@/components/ui/alert-dialog";
31
+ import { Skeleton } from "@/components/ui/skeleton";
32
+ import { Form } from "@/components/ui/form/form";
33
+ import { FormField } from "@/components/ui/form/form-field";
34
+ import { FormItem } from "@/components/ui/form/form-item";
35
+ import { FormLabel } from "@/components/ui/form/form-label";
36
+ import { FormMessage } from "@/components/ui/form/form-message";
37
+ import { FormControl } from "@/components/ui/form/form-control";
38
+ import { userSchema, type UserFormValues } from "@/lib/validation/forms";
39
+ import { mapApiErrorsToForm } from "@/lib/forms/map-errors";
40
+
41
+ export interface UsersManagerSlots {
42
+ container?: { className?: string };
43
+ formCard?: { className?: string };
44
+ listCard?: { className?: string };
45
+ leftHeading?: { className?: string };
46
+ rightHeading?: { className?: string };
47
+ form?: { className?: string };
48
+ submitRow?: { className?: string };
49
+ actionsRow?: { className?: string };
50
+ table?: { className?: string };
51
+ }
52
+
53
+ export interface UsersManagerProps extends UsersManagerSlots {
54
+ className?: string;
55
+ }
56
+
57
+ type User = {
58
+ id: string;
59
+ email: string;
60
+ name?: string | null;
61
+ createdAt?: string;
62
+ };
63
+
64
+ export function UsersManager({
65
+ className,
66
+ container = {
67
+ className:
68
+ "mx-auto grid w-full max-w-5xl gap-6 p-0 sm:gap-6 sm:p-6 md:grid-cols-5 md:gap-1 md:p-1 lg:gap-6 lg:p-6",
69
+ },
70
+ formCard = { className: "p-2 sm:p-6 md:col-span-2" },
71
+ listCard = { className: "p-2 sm:p-6 md:col-span-3" },
72
+ leftHeading = { className: "mb-4 text-lg font-semibold" },
73
+ rightHeading = { className: "mb-3 font-medium" },
74
+ form = { className: "space-y-4" },
75
+ submitRow = { className: "flex items-center gap-3" },
76
+ actionsRow = { className: "flex flex-col gap-2 lg:flex-row" },
77
+ table = { className: "" },
78
+ }: UsersManagerProps): JSX.Element {
79
+ const [users, setUsers] = useState<User[]>([]);
80
+ const [selectedId, setSelectedId] = useState<string | null>(null);
81
+ const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
82
+ const [loading, setLoading] = useState(false);
83
+ const [listLoading, setListLoading] = useState(true);
84
+
85
+ const defaultValues = useMemo<UserFormValues>(
86
+ () => ({ email: "", name: "" }),
87
+ [],
88
+ );
89
+
90
+ const formMethods = useForm<UserFormValues>({
91
+ resolver: zodResolver(userSchema),
92
+ defaultValues,
93
+ });
94
+
95
+ const {
96
+ control,
97
+ handleSubmit,
98
+ reset,
99
+ setValue,
100
+ formState: { isSubmitting },
101
+ } = formMethods;
102
+
103
+ const [page, setPage] = useState<number>(1);
104
+ const [perPage, setPerPage] = useState<number>(8);
105
+ const [total, setTotal] = useState<number>(0);
106
+
107
+ const fetchUsers = async (opts?: { page?: number; perPage?: number }) => {
108
+ setListLoading(true);
109
+ try {
110
+ const p = opts?.page ?? page;
111
+ const pp = opts?.perPage ?? perPage;
112
+ const params = new URLSearchParams();
113
+ params.set("page", String(p));
114
+ params.set("perPage", String(pp));
115
+
116
+ const res = await fetch(`/api/users?${params.toString()}`, {
117
+ cache: "no-store",
118
+ });
119
+ const payload = await res.json().catch(() => null);
120
+
121
+ let data: unknown = payload ?? [];
122
+ if (payload && typeof payload === "object" && "success" in payload) {
123
+ data = (payload as { data?: unknown }).data;
124
+ }
125
+
126
+ const items = Array.isArray(data)
127
+ ? data
128
+ : data && typeof data === "object" && "items" in data
129
+ ? (data as { items?: unknown }).items
130
+ : [];
131
+
132
+ setUsers(Array.isArray(items) ? (items as User[]) : []);
133
+
134
+ if (data && typeof data === "object") {
135
+ const d = data as {
136
+ total?: unknown;
137
+ page?: unknown;
138
+ perPage?: unknown;
139
+ };
140
+ if (typeof d.total === "number") setTotal(d.total);
141
+ if (typeof d.page === "number") setPage(d.page);
142
+ if (typeof d.perPage === "number") setPerPage(d.perPage);
143
+ } else if (Array.isArray(items)) {
144
+ setTotal(items.length);
145
+ } else {
146
+ setTotal(0);
147
+ }
148
+
149
+ if (opts?.page) setPage(opts.page);
150
+ if (opts?.perPage) setPerPage(opts.perPage);
151
+ } finally {
152
+ setListLoading(false);
153
+ }
154
+ };
155
+
156
+ useEffect(() => {
157
+ fetchUsers();
158
+ // eslint-disable-next-line react-hooks/exhaustive-deps
159
+ }, [page, perPage]);
160
+
161
+ const createUser = async (values: UserFormValues) => {
162
+ setLoading(true);
163
+ try {
164
+ const res = await fetch("/api/users", {
165
+ method: "POST",
166
+ headers: { "Content-Type": "application/json" },
167
+ body: JSON.stringify(values),
168
+ });
169
+ const payload = await res.json().catch(() => null);
170
+ if (!res.ok || !payload?.success) {
171
+ const msg = payload
172
+ ? mapApiErrorsToForm(formMethods, payload)
173
+ : undefined;
174
+ toast.error(msg || payload?.message || "Create failed");
175
+ return;
176
+ }
177
+ reset(defaultValues);
178
+ setSelectedId(null);
179
+ await fetchUsers();
180
+ toast.success("User created");
181
+ } catch {
182
+ toast.error("Create failed");
183
+ } finally {
184
+ setLoading(false);
185
+ }
186
+ };
187
+
188
+ const updateUser = async (values: UserFormValues) => {
189
+ if (!selectedId) return;
190
+ setLoading(true);
191
+ try {
192
+ const res = await fetch(`/api/users/${selectedId}`, {
193
+ method: "PUT",
194
+ headers: { "Content-Type": "application/json" },
195
+ body: JSON.stringify({ email: values.email, name: values.name }),
196
+ });
197
+ const payload = await res.json().catch(() => null);
198
+ if (!res.ok || !payload?.success) {
199
+ const msg = payload
200
+ ? mapApiErrorsToForm(formMethods, payload)
201
+ : undefined;
202
+ toast.error(msg || payload?.message || "Update failed");
203
+ return;
204
+ }
205
+ reset(defaultValues);
206
+ setSelectedId(null);
207
+ await fetchUsers();
208
+ toast.success("User updated");
209
+ } catch {
210
+ toast.error("Update failed");
211
+ } finally {
212
+ setLoading(false);
213
+ }
214
+ };
215
+
216
+ const onEdit = (u: User) => {
217
+ setSelectedId(u.id);
218
+ setValue("email", u.email);
219
+ setValue("name", u.name ?? "");
220
+ };
221
+
222
+ const confirmDelete = (id: string) => setPendingDeleteId(id);
223
+
224
+ const doDelete = async () => {
225
+ if (!pendingDeleteId) return;
226
+ setLoading(true);
227
+ try {
228
+ const res = await fetch(`/api/users/${pendingDeleteId}`, {
229
+ method: "DELETE",
230
+ });
231
+ if (!res.ok) throw new Error("Delete failed");
232
+ toast.success("User deleted");
233
+ await fetchUsers();
234
+ } catch {
235
+ toast.error("Delete failed");
236
+ } finally {
237
+ setLoading(false);
238
+ setPendingDeleteId(null);
239
+ }
240
+ };
241
+
242
+ const onCancel = () => {
243
+ reset(defaultValues);
244
+ setSelectedId(null);
245
+ };
246
+
247
+ return (
248
+ <main className={cn(container.className, className)}>
249
+ <Card className={cn(formCard.className)}>
250
+ <h2 className={cn(leftHeading.className)}>User Manager</h2>
251
+
252
+ <Form<UserFormValues> methods={formMethods}>
253
+ <form
254
+ onSubmit={handleSubmit(selectedId ? updateUser : createUser)}
255
+ className={cn(form.className)}
256
+ >
257
+ <FormField
258
+ control={control}
259
+ name="email"
260
+ render={({ field }) => (
261
+ <FormItem className="space-y-2">
262
+ <FormLabel>Email</FormLabel>
263
+ <FormControl>
264
+ <Input
265
+ id="email"
266
+ type="email"
267
+ placeholder="user@example.com"
268
+ autoComplete="off"
269
+ {...field}
270
+ />
271
+ </FormControl>
272
+ <FormMessage />
273
+ </FormItem>
274
+ )}
275
+ />
276
+
277
+ <FormField
278
+ control={control}
279
+ name="name"
280
+ render={({ field }) => (
281
+ <FormItem className="space-y-2">
282
+ <FormLabel>Name</FormLabel>
283
+ <FormControl>
284
+ <Input
285
+ id="name"
286
+ placeholder="Optional name"
287
+ autoComplete="off"
288
+ {...field}
289
+ />
290
+ </FormControl>
291
+ <FormMessage />
292
+ </FormItem>
293
+ )}
294
+ />
295
+
296
+ <div className={cn(submitRow.className)}>
297
+ {!selectedId ? (
298
+ <Button type="submit" disabled={isSubmitting || loading}>
299
+ Create User
300
+ </Button>
301
+ ) : (
302
+ <>
303
+ <Button type="submit" disabled={isSubmitting || loading}>
304
+ Update User
305
+ </Button>
306
+ <Button type="button" variant="ghost" onClick={onCancel}>
307
+ Cancel
308
+ </Button>
309
+ </>
310
+ )}
311
+ </div>
312
+ </form>
313
+ </Form>
314
+ </Card>
315
+
316
+ <Card className={cn(listCard.className)}>
317
+ <h3 className={cn(rightHeading.className)}>Users</h3>
318
+ <Table className={cn(table.className)}>
319
+ <TableHeader>
320
+ <TableRow>
321
+ <TableHead>Email</TableHead>
322
+ <TableHead>Name</TableHead>
323
+ <TableHead className="w-[200px]">Actions</TableHead>
324
+ </TableRow>
325
+ </TableHeader>
326
+ <TableBody>
327
+ {listLoading ? (
328
+ <>
329
+ {[0, 1, 2].map((i) => (
330
+ <TableRow key={i}>
331
+ <TableCell>
332
+ <Skeleton className="h-4 w-48" />
333
+ </TableCell>
334
+ <TableCell>
335
+ <Skeleton className="h-4 w-32" />
336
+ </TableCell>
337
+ <TableCell>
338
+ {/* actions cell empty during loading */}
339
+ </TableCell>
340
+ </TableRow>
341
+ ))}
342
+ </>
343
+ ) : (
344
+ <>
345
+ {users.map((u) => (
346
+ <TableRow key={u.id}>
347
+ <TableCell className="font-medium">{u.email}</TableCell>
348
+ <TableCell>{u.name || ""}</TableCell>
349
+ <TableCell>
350
+ <div className={cn(actionsRow.className)}>
351
+ <Button
352
+ size="sm"
353
+ variant="outline"
354
+ onClick={() => onEdit(u)}
355
+ >
356
+ Edit
357
+ </Button>
358
+ <AlertDialog>
359
+ <AlertDialogTrigger asChild>
360
+ <Button
361
+ size="sm"
362
+ variant="destructive"
363
+ onClick={() => confirmDelete(u.id)}
364
+ >
365
+ Delete
366
+ </Button>
367
+ </AlertDialogTrigger>
368
+ <AlertDialogContent>
369
+ <AlertDialogHeader>
370
+ <AlertDialogTitle>Delete user?</AlertDialogTitle>
371
+ <AlertDialogDescription>
372
+ This action cannot be undone. The user will be
373
+ removed permanently.
374
+ </AlertDialogDescription>
375
+ </AlertDialogHeader>
376
+ <AlertDialogFooter>
377
+ <AlertDialogCancel
378
+ onClick={() => setPendingDeleteId(null)}
379
+ >
380
+ Cancel
381
+ </AlertDialogCancel>
382
+ <AlertDialogAction onClick={doDelete}>
383
+ Delete
384
+ </AlertDialogAction>
385
+ </AlertDialogFooter>
386
+ </AlertDialogContent>
387
+ </AlertDialog>
388
+ </div>
389
+ </TableCell>
390
+ </TableRow>
391
+ ))}
392
+ {users.length === 0 && (
393
+ <TableRow>
394
+ <TableCell colSpan={3} className="text-muted-foreground">
395
+ No users yet.
396
+ </TableCell>
397
+ </TableRow>
398
+ )}
399
+ </>
400
+ )}
401
+ </TableBody>
402
+ </Table>
403
+
404
+ <div className="mt-3 flex items-center justify-between">
405
+ <div>
406
+ <Button
407
+ size="sm"
408
+ onClick={() => setPage((p) => Math.max(1, p - 1))}
409
+ disabled={page <= 1 || listLoading}
410
+ >
411
+ Prev
412
+ </Button>
413
+ <Button
414
+ size="sm"
415
+ variant="ghost"
416
+ onClick={() => setPage((p) => p + 1)}
417
+ disabled={
418
+ listLoading ||
419
+ (total > 0 ? page * perPage >= total : users.length === 0)
420
+ }
421
+ >
422
+ Next
423
+ </Button>
424
+ </div>
425
+ <div className="text-muted-foreground text-sm">
426
+ Showing {Math.min((page - 1) * perPage + 1, total || 0)} -{" "}
427
+ {Math.min(page * perPage, total || 0)} of {total}
428
+ </div>
429
+ </div>
430
+ </Card>
431
+ </main>
432
+ );
433
+ }
434
+
435
+ export default UsersManager;