@86d-app/products 0.0.3

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 (94) hide show
  1. package/AGENTS.md +65 -0
  2. package/COMPONENTS.md +231 -0
  3. package/README.md +201 -0
  4. package/package.json +46 -0
  5. package/src/__tests__/controllers.test.ts +2227 -0
  6. package/src/__tests__/state.test.ts +138 -0
  7. package/src/admin/components/categories-admin.mdx +3 -0
  8. package/src/admin/components/categories-admin.tsx +449 -0
  9. package/src/admin/components/category-form.mdx +9 -0
  10. package/src/admin/components/category-form.tsx +490 -0
  11. package/src/admin/components/category-list.mdx +75 -0
  12. package/src/admin/components/category-list.tsx +168 -0
  13. package/src/admin/components/collections-admin.mdx +3 -0
  14. package/src/admin/components/collections-admin.tsx +771 -0
  15. package/src/admin/components/index.tsx +8 -0
  16. package/src/admin/components/product-detail.mdx +12 -0
  17. package/src/admin/components/product-detail.tsx +790 -0
  18. package/src/admin/components/product-edit.tsx +60 -0
  19. package/src/admin/components/product-form.tsx +793 -0
  20. package/src/admin/components/product-list.mdx +3 -0
  21. package/src/admin/components/product-list.tsx +1125 -0
  22. package/src/admin/components/product-new.tsx +38 -0
  23. package/src/admin/endpoints/add-collection-product.ts +17 -0
  24. package/src/admin/endpoints/bulk-action.ts +43 -0
  25. package/src/admin/endpoints/create-category.ts +52 -0
  26. package/src/admin/endpoints/create-collection.ts +35 -0
  27. package/src/admin/endpoints/create-product.ts +50 -0
  28. package/src/admin/endpoints/create-variant.ts +45 -0
  29. package/src/admin/endpoints/delete-category.ts +27 -0
  30. package/src/admin/endpoints/delete-collection.ts +12 -0
  31. package/src/admin/endpoints/delete-product.ts +27 -0
  32. package/src/admin/endpoints/delete-variant.ts +27 -0
  33. package/src/admin/endpoints/get-product.ts +23 -0
  34. package/src/admin/endpoints/import-products.ts +47 -0
  35. package/src/admin/endpoints/index.ts +43 -0
  36. package/src/admin/endpoints/list-categories.ts +21 -0
  37. package/src/admin/endpoints/list-collections.ts +20 -0
  38. package/src/admin/endpoints/list-products.ts +25 -0
  39. package/src/admin/endpoints/remove-collection-product.ts +15 -0
  40. package/src/admin/endpoints/update-category.ts +82 -0
  41. package/src/admin/endpoints/update-collection.ts +22 -0
  42. package/src/admin/endpoints/update-product.ts +67 -0
  43. package/src/admin/endpoints/update-variant.ts +41 -0
  44. package/src/controllers.ts +1410 -0
  45. package/src/index.ts +120 -0
  46. package/src/markdown.ts +150 -0
  47. package/src/mdx.d.ts +5 -0
  48. package/src/schema.ts +352 -0
  49. package/src/state.ts +84 -0
  50. package/src/store/components/_hooks.ts +78 -0
  51. package/src/store/components/_types.ts +73 -0
  52. package/src/store/components/_utils.ts +14 -0
  53. package/src/store/components/back-in-stock-notify.tsx +97 -0
  54. package/src/store/components/collection-card.mdx +42 -0
  55. package/src/store/components/collection-card.tsx +12 -0
  56. package/src/store/components/collection-detail.mdx +12 -0
  57. package/src/store/components/collection-detail.tsx +149 -0
  58. package/src/store/components/collection-grid.mdx +9 -0
  59. package/src/store/components/collection-grid.tsx +80 -0
  60. package/src/store/components/featured-products.mdx +9 -0
  61. package/src/store/components/featured-products.tsx +75 -0
  62. package/src/store/components/filter-chip.mdx +25 -0
  63. package/src/store/components/filter-chip.tsx +12 -0
  64. package/src/store/components/index.tsx +39 -0
  65. package/src/store/components/product-card.mdx +69 -0
  66. package/src/store/components/product-card.tsx +71 -0
  67. package/src/store/components/product-detail.mdx +30 -0
  68. package/src/store/components/product-detail.tsx +488 -0
  69. package/src/store/components/product-listing.mdx +7 -0
  70. package/src/store/components/product-listing.tsx +423 -0
  71. package/src/store/components/product-reviews-section.mdx +21 -0
  72. package/src/store/components/product-reviews-section.tsx +372 -0
  73. package/src/store/components/recently-viewed.tsx +100 -0
  74. package/src/store/components/related-products.mdx +6 -0
  75. package/src/store/components/related-products.tsx +62 -0
  76. package/src/store/components/star-display.mdx +18 -0
  77. package/src/store/components/star-display.tsx +27 -0
  78. package/src/store/components/star-picker.mdx +21 -0
  79. package/src/store/components/star-picker.tsx +21 -0
  80. package/src/store/components/stock-badge.mdx +12 -0
  81. package/src/store/components/stock-badge.tsx +19 -0
  82. package/src/store/endpoints/get-category.ts +61 -0
  83. package/src/store/endpoints/get-collection.ts +46 -0
  84. package/src/store/endpoints/get-featured.ts +18 -0
  85. package/src/store/endpoints/get-product.ts +52 -0
  86. package/src/store/endpoints/get-related.ts +20 -0
  87. package/src/store/endpoints/index.ts +23 -0
  88. package/src/store/endpoints/list-categories.ts +13 -0
  89. package/src/store/endpoints/list-collections.ts +22 -0
  90. package/src/store/endpoints/list-products.ts +28 -0
  91. package/src/store/endpoints/search-products.ts +18 -0
  92. package/src/store/endpoints/store-search.ts +111 -0
  93. package/tsconfig.json +9 -0
  94. package/vitest.config.ts +7 -0
@@ -0,0 +1,490 @@
1
+ "use client";
2
+
3
+ import { useModuleClient } from "@86d-app/core/client";
4
+ import { useCallback, useRef, useState } from "react";
5
+ import CategoryFormTemplate from "./category-form.mdx";
6
+
7
+ // ─── Types ────────────────────────────────────────────────────────────────────
8
+
9
+ interface Category {
10
+ id: string;
11
+ name: string;
12
+ slug: string;
13
+ description?: string | null;
14
+ parentId?: string | null;
15
+ image?: string | null;
16
+ position: number;
17
+ isVisible: boolean;
18
+ }
19
+
20
+ interface CategoryFormData {
21
+ name: string;
22
+ slug: string;
23
+ description: string;
24
+ parentId: string;
25
+ image: string;
26
+ position: string;
27
+ isVisible: boolean;
28
+ }
29
+
30
+ interface CategoryFormProps {
31
+ categoryId?: string;
32
+ onSuccess?: () => void;
33
+ }
34
+
35
+ interface CategoriesResult {
36
+ categories: Category[];
37
+ total: number;
38
+ }
39
+
40
+ // ─── Module Client ───────────────────────────────────────────────────────────
41
+
42
+ function useCategoriesAdminApi() {
43
+ const client = useModuleClient();
44
+ return {
45
+ listCategories: client.module("products").admin["/admin/categories/list"],
46
+ createCategory: client.module("products").admin["/admin/categories/create"],
47
+ updateCategory:
48
+ client.module("products").admin["/admin/categories/:id/update"],
49
+ };
50
+ }
51
+
52
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
53
+
54
+ function extractError(error: Error | null, fallback: string): string {
55
+ if (!error) return fallback;
56
+ // biome-ignore lint/suspicious/noExplicitAny: accessing HTTP error body property
57
+ const body = (error as any)?.body;
58
+ if (typeof body?.error === "string") return body.error;
59
+ if (typeof body?.error?.message === "string") return body.error.message;
60
+ return fallback;
61
+ }
62
+
63
+ function slugify(str: string): string {
64
+ return str
65
+ .toLowerCase()
66
+ .replace(/[^\w\s-]/g, "")
67
+ .replace(/[\s_]+/g, "-")
68
+ .replace(/^-+|-+$/g, "");
69
+ }
70
+
71
+ const defaultForm: CategoryFormData = {
72
+ name: "",
73
+ slug: "",
74
+ description: "",
75
+ parentId: "",
76
+ image: "",
77
+ position: "0",
78
+ isVisible: true,
79
+ };
80
+
81
+ // ─── CategoryForm ─────────────────────────────────────────────────────────────
82
+
83
+ export function CategoryForm({ categoryId, onSuccess }: CategoryFormProps) {
84
+ const api = useCategoriesAdminApi();
85
+ const isEditing = Boolean(categoryId);
86
+
87
+ const [form, setForm] = useState<CategoryFormData>(defaultForm);
88
+ const [initialized, setInitialized] = useState(false);
89
+ const [error, setError] = useState<string | null>(null);
90
+ const [slugEdited, setSlugEdited] = useState(false);
91
+
92
+ // Fetch all categories (also provides the editing category data)
93
+ const { data: categoriesData, isLoading: loading } =
94
+ api.listCategories.useQuery({
95
+ limit: "100",
96
+ }) as { data: CategoriesResult | undefined; isLoading: boolean };
97
+
98
+ const categories = (categoriesData?.categories ?? []).filter(
99
+ (c) => c.id !== categoryId,
100
+ );
101
+
102
+ // Populate form when editing and data arrives
103
+ if (isEditing && categoryId && categoriesData && !initialized) {
104
+ const c = categoriesData.categories.find(
105
+ (cat: Category) => cat.id === categoryId,
106
+ );
107
+ if (c) {
108
+ setForm({
109
+ name: c.name,
110
+ slug: c.slug,
111
+ description: c.description ?? "",
112
+ parentId: c.parentId ?? "",
113
+ image: c.image ?? "",
114
+ position: String(c.position),
115
+ isVisible: c.isVisible,
116
+ });
117
+ setSlugEdited(true);
118
+ }
119
+ setInitialized(true);
120
+ }
121
+
122
+ const createMutation = api.createCategory.useMutation({
123
+ onSuccess: () => {
124
+ void api.listCategories.invalidate();
125
+ onSuccess?.();
126
+ },
127
+ onError: (err: Error) => {
128
+ setError(extractError(err, "Failed to save category"));
129
+ },
130
+ });
131
+
132
+ const updateMutation = api.updateCategory.useMutation({
133
+ onSuccess: () => {
134
+ void api.listCategories.invalidate();
135
+ onSuccess?.();
136
+ },
137
+ onError: (err: Error) => {
138
+ setError(extractError(err, "Failed to save category"));
139
+ },
140
+ });
141
+
142
+ const saving = createMutation.isPending || updateMutation.isPending;
143
+
144
+ const setField = useCallback(
145
+ <K extends keyof CategoryFormData>(
146
+ field: K,
147
+ value: CategoryFormData[K],
148
+ ) => {
149
+ setForm((prev) => {
150
+ const next = { ...prev, [field]: value };
151
+ if (field === "name" && !slugEdited) {
152
+ next.slug = slugify(value as string);
153
+ }
154
+ return next;
155
+ });
156
+ },
157
+ [slugEdited],
158
+ );
159
+
160
+ const handleSubmit = (e: React.FormEvent) => {
161
+ e.preventDefault();
162
+ setError(null);
163
+
164
+ if (!form.name.trim()) {
165
+ setError("Name is required");
166
+ return;
167
+ }
168
+ if (!form.slug.trim()) {
169
+ setError("Slug is required");
170
+ return;
171
+ }
172
+
173
+ const body = {
174
+ name: form.name.trim(),
175
+ slug: form.slug.trim(),
176
+ description: form.description.trim() || undefined,
177
+ parentId: form.parentId || undefined,
178
+ image: form.image.trim() || undefined,
179
+ position: Number.parseInt(form.position, 10) || 0,
180
+ isVisible: form.isVisible,
181
+ };
182
+
183
+ if (isEditing && categoryId) {
184
+ updateMutation.mutate({ params: { id: categoryId }, ...body });
185
+ } else {
186
+ createMutation.mutate(body);
187
+ }
188
+ };
189
+
190
+ if (loading) {
191
+ return (
192
+ <div className="space-y-4">
193
+ {Array.from({ length: 5 }).map((_, i) => (
194
+ <div key={i} className="h-12 animate-pulse rounded-md bg-muted" />
195
+ ))}
196
+ </div>
197
+ );
198
+ }
199
+
200
+ const formContent = (
201
+ <>
202
+ <div className="rounded-lg border border-border bg-card p-5">
203
+ <h2 className="mb-4 font-semibold text-foreground text-sm">
204
+ {isEditing ? "Edit category" : "New category"}
205
+ </h2>
206
+ <div className="space-y-4">
207
+ {/* Name */}
208
+ <div>
209
+ <label
210
+ htmlFor="cat-name"
211
+ className="mb-1.5 block font-medium text-foreground text-sm"
212
+ >
213
+ Name <span className="text-destructive">*</span>
214
+ </label>
215
+ <input
216
+ id="cat-name"
217
+ type="text"
218
+ value={form.name}
219
+ onChange={(e) => setField("name", e.target.value)}
220
+ placeholder="Category name"
221
+ className="w-full rounded-md border border-border bg-background px-3 py-2 text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
222
+ required
223
+ />
224
+ </div>
225
+
226
+ {/* Slug */}
227
+ <div>
228
+ <label
229
+ htmlFor="cat-slug"
230
+ className="mb-1.5 block font-medium text-foreground text-sm"
231
+ >
232
+ Slug <span className="text-destructive">*</span>
233
+ </label>
234
+ <input
235
+ id="cat-slug"
236
+ type="text"
237
+ value={form.slug}
238
+ onChange={(e) => {
239
+ setSlugEdited(true);
240
+ setField("slug", e.target.value);
241
+ }}
242
+ placeholder="category-slug"
243
+ className="w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
244
+ required
245
+ />
246
+ </div>
247
+
248
+ {/* Description */}
249
+ <div>
250
+ <label
251
+ htmlFor="cat-desc"
252
+ className="mb-1.5 block font-medium text-foreground text-sm"
253
+ >
254
+ Description
255
+ </label>
256
+ <textarea
257
+ id="cat-desc"
258
+ value={form.description}
259
+ onChange={(e) => setField("description", e.target.value)}
260
+ placeholder="Category description"
261
+ rows={3}
262
+ className="w-full resize-y rounded-md border border-border bg-background px-3 py-2 text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
263
+ />
264
+ </div>
265
+
266
+ {/* Parent category */}
267
+ <div>
268
+ <label
269
+ htmlFor="cat-parent"
270
+ className="mb-1.5 block font-medium text-foreground text-sm"
271
+ >
272
+ Parent category
273
+ </label>
274
+ <select
275
+ id="cat-parent"
276
+ value={form.parentId}
277
+ onChange={(e) => setField("parentId", e.target.value)}
278
+ className="w-full rounded-md border border-border bg-background px-3 py-2 text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
279
+ >
280
+ <option value="">None</option>
281
+ {categories.map((c) => (
282
+ <option key={c.id} value={c.id}>
283
+ {c.name}
284
+ </option>
285
+ ))}
286
+ </select>
287
+ </div>
288
+
289
+ {/* Image */}
290
+ <CategoryImageField
291
+ image={form.image}
292
+ onChange={(url) => setField("image", url)}
293
+ />
294
+
295
+ {/* Position */}
296
+ <div>
297
+ <label
298
+ htmlFor="cat-position"
299
+ className="mb-1.5 block font-medium text-foreground text-sm"
300
+ >
301
+ Position
302
+ </label>
303
+ <input
304
+ id="cat-position"
305
+ type="number"
306
+ min="0"
307
+ value={form.position}
308
+ onChange={(e) => setField("position", e.target.value)}
309
+ className="w-full rounded-md border border-border bg-background px-3 py-2 text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
310
+ />
311
+ <p className="mt-1 text-muted-foreground text-xs">
312
+ Lower numbers appear first
313
+ </p>
314
+ </div>
315
+
316
+ {/* Visible */}
317
+ <label className="flex items-center gap-2.5">
318
+ <input
319
+ type="checkbox"
320
+ checked={form.isVisible}
321
+ onChange={(e) => setField("isVisible", e.target.checked)}
322
+ className="h-4 w-4 rounded border-border"
323
+ />
324
+ <span className="text-foreground text-sm">
325
+ Visible on storefront
326
+ </span>
327
+ </label>
328
+ </div>
329
+ </div>
330
+
331
+ {/* Actions */}
332
+ <div className="flex gap-2">
333
+ <button
334
+ type="submit"
335
+ disabled={saving}
336
+ className="rounded-md bg-foreground px-4 py-2.5 font-semibold text-background text-sm transition-opacity hover:opacity-90 disabled:opacity-50"
337
+ >
338
+ {saving
339
+ ? "Saving..."
340
+ : isEditing
341
+ ? "Save changes"
342
+ : "Create category"}
343
+ </button>
344
+ {onSuccess && (
345
+ <button
346
+ type="button"
347
+ onClick={onSuccess}
348
+ className="rounded-md border border-border px-4 py-2.5 font-medium text-foreground text-sm transition-colors hover:bg-muted"
349
+ >
350
+ Cancel
351
+ </button>
352
+ )}
353
+ </div>
354
+ </>
355
+ );
356
+
357
+ return (
358
+ <form onSubmit={(e) => handleSubmit(e)} className="space-y-5">
359
+ <CategoryFormTemplate error={error} formContent={formContent} />
360
+ </form>
361
+ );
362
+ }
363
+
364
+ // ─── Inline image upload for categories ──────────────────────────────────────
365
+
366
+ function CategoryImageField({
367
+ image,
368
+ onChange,
369
+ }: {
370
+ image: string;
371
+ onChange: (url: string) => void;
372
+ }) {
373
+ const [uploading, setUploading] = useState(false);
374
+ const [uploadError, setUploadError] = useState<string | null>(null);
375
+ const inputRef = useRef<HTMLInputElement>(null);
376
+
377
+ const handleFile = async (file: File) => {
378
+ setUploadError(null);
379
+ setUploading(true);
380
+ try {
381
+ const formData = new FormData();
382
+ formData.append("file", file);
383
+ const res = await fetch("/api/upload", {
384
+ method: "POST",
385
+ body: formData,
386
+ });
387
+ if (!res.ok) {
388
+ const data = (await res.json()) as { error?: string };
389
+ throw new Error(data.error ?? "Upload failed");
390
+ }
391
+ const data = (await res.json()) as { url: string };
392
+ onChange(data.url);
393
+ } catch (err) {
394
+ setUploadError(err instanceof Error ? err.message : "Upload failed");
395
+ } finally {
396
+ setUploading(false);
397
+ if (inputRef.current) inputRef.current.value = "";
398
+ }
399
+ };
400
+
401
+ return (
402
+ <div>
403
+ <span className="mb-1.5 block font-medium text-foreground text-sm">
404
+ Image
405
+ </span>
406
+ {image ? (
407
+ <div className="flex items-start gap-3">
408
+ <div className="h-20 w-20 overflow-hidden rounded-md border border-border bg-muted">
409
+ <img
410
+ src={image}
411
+ alt="Category"
412
+ className="h-full w-full object-cover"
413
+ />
414
+ </div>
415
+ <button
416
+ type="button"
417
+ onClick={() => onChange("")}
418
+ className="rounded-md px-2 py-1 text-destructive text-xs hover:bg-destructive/10"
419
+ >
420
+ Remove
421
+ </button>
422
+ </div>
423
+ ) : (
424
+ <button
425
+ type="button"
426
+ onClick={() => inputRef.current?.click()}
427
+ disabled={uploading}
428
+ className="flex h-20 w-20 flex-col items-center justify-center rounded-md border-2 border-border border-dashed text-muted-foreground transition-colors hover:border-muted-foreground hover:bg-muted/30 disabled:opacity-60"
429
+ >
430
+ {uploading ? (
431
+ <svg
432
+ className="h-5 w-5 animate-spin"
433
+ xmlns="http://www.w3.org/2000/svg"
434
+ fill="none"
435
+ viewBox="0 0 24 24"
436
+ aria-hidden="true"
437
+ >
438
+ <circle
439
+ className="opacity-25"
440
+ cx="12"
441
+ cy="12"
442
+ r="10"
443
+ stroke="currentColor"
444
+ strokeWidth="4"
445
+ />
446
+ <path
447
+ className="opacity-75"
448
+ fill="currentColor"
449
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
450
+ />
451
+ </svg>
452
+ ) : (
453
+ <>
454
+ <svg
455
+ xmlns="http://www.w3.org/2000/svg"
456
+ width="18"
457
+ height="18"
458
+ viewBox="0 0 24 24"
459
+ fill="none"
460
+ stroke="currentColor"
461
+ strokeWidth="1.5"
462
+ strokeLinecap="round"
463
+ strokeLinejoin="round"
464
+ aria-hidden="true"
465
+ >
466
+ <rect width="18" height="18" x="3" y="3" rx="2" />
467
+ <circle cx="9" cy="9" r="2" />
468
+ <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
469
+ </svg>
470
+ <span className="mt-0.5 text-2xs">Upload</span>
471
+ </>
472
+ )}
473
+ </button>
474
+ )}
475
+ <input
476
+ ref={inputRef}
477
+ type="file"
478
+ accept="image/jpeg,image/png,image/webp"
479
+ className="hidden"
480
+ onChange={(e) => {
481
+ const file = e.target.files?.[0];
482
+ if (file) void handleFile(file);
483
+ }}
484
+ />
485
+ {uploadError && (
486
+ <p className="mt-1 text-destructive text-xs">{uploadError}</p>
487
+ )}
488
+ </div>
489
+ );
490
+ }
@@ -0,0 +1,75 @@
1
+ <div>
2
+ <div className="mb-6 flex items-center justify-between">
3
+ <div>
4
+ <h1 className="font-semibold text-foreground text-lg">Categories</h1>
5
+ {props.total > 0 && (
6
+ <p className="mt-1 text-muted-foreground text-sm">
7
+ {props.total} {props.total === 1 ? "category" : "categories"} total
8
+ </p>
9
+ )}
10
+ </div>
11
+ <button
12
+ type="button"
13
+ onClick={props.onCreateNew}
14
+ className="rounded-md bg-foreground px-4 py-2 font-semibold text-background text-sm transition-opacity hover:opacity-90"
15
+ >
16
+ New category
17
+ </button>
18
+ </div>
19
+
20
+ <div className="overflow-hidden rounded-lg border border-border bg-card">
21
+ <div className="overflow-x-auto">
22
+ <table className="w-full text-sm">
23
+ <thead>
24
+ <tr className="border-border border-b bg-muted/50">
25
+ <th className="px-4 py-3 text-left font-medium text-muted-foreground text-xs uppercase tracking-wider">
26
+ Name
27
+ </th>
28
+ <th className="px-4 py-3 text-left font-medium text-muted-foreground text-xs uppercase tracking-wider">
29
+ Slug
30
+ </th>
31
+ <th className="px-4 py-3 text-left font-medium text-muted-foreground text-xs uppercase tracking-wider">
32
+ Visible
33
+ </th>
34
+ <th className="px-4 py-3 text-left font-medium text-muted-foreground text-xs uppercase tracking-wider">
35
+ Position
36
+ </th>
37
+ <th className="px-4 py-3 text-left font-medium text-muted-foreground text-xs uppercase tracking-wider">
38
+ Products
39
+ </th>
40
+ <th className="px-4 py-3 text-right font-medium text-muted-foreground text-xs uppercase tracking-wider">
41
+ Actions
42
+ </th>
43
+ </tr>
44
+ </thead>
45
+ <tbody className="divide-y divide-border">
46
+ {props.tableBody}
47
+ </tbody>
48
+ </table>
49
+ </div>
50
+ </div>
51
+
52
+ {props.totalPages > 1 && (
53
+ <div className="mt-4 flex items-center justify-center gap-2">
54
+ <button
55
+ type="button"
56
+ onClick={props.onPrevPage}
57
+ disabled={props.page === 1}
58
+ className="rounded-md border border-border px-3 py-1.5 text-foreground text-sm hover:bg-muted disabled:opacity-50"
59
+ >
60
+ Previous
61
+ </button>
62
+ <span className="text-muted-foreground text-sm">
63
+ Page {props.page} of {props.totalPages}
64
+ </span>
65
+ <button
66
+ type="button"
67
+ onClick={props.onNextPage}
68
+ disabled={props.page === props.totalPages}
69
+ className="rounded-md border border-border px-3 py-1.5 text-foreground text-sm hover:bg-muted disabled:opacity-50"
70
+ >
71
+ Next
72
+ </button>
73
+ </div>
74
+ )}
75
+ </div>