@kyro-cms/admin 0.12.20 → 0.12.22

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 (56) hide show
  1. package/dist/index.cjs +24 -24
  2. package/dist/index.d.cts +2 -1
  3. package/dist/index.d.ts +2 -1
  4. package/dist/index.js +19 -19
  5. package/package.json +6 -6
  6. package/src/components/ApiKeysManager.tsx +177 -227
  7. package/src/components/AuditLogsPage.tsx +7 -7
  8. package/src/components/AuthorDashboard.tsx +235 -0
  9. package/src/components/AutoForm.tsx +14 -4
  10. package/src/components/CustomerDashboard.tsx +213 -0
  11. package/src/components/Dashboard.tsx +1 -1
  12. package/src/components/DashboardMetrics.tsx +74 -9
  13. package/src/components/DashboardQuickSections.tsx +237 -0
  14. package/src/components/ListView.tsx +3 -3
  15. package/src/components/MediaGallery.tsx +2 -2
  16. package/src/components/PluginsManager.tsx +1 -1
  17. package/src/components/SessionsManager.tsx +15 -15
  18. package/src/components/Sidebar.astro +564 -116
  19. package/src/components/UserManagement.tsx +43 -9
  20. package/src/components/UserMenu.tsx +123 -61
  21. package/src/components/autoform/AutoFormEditView.tsx +4 -4
  22. package/src/components/autoform/AutoFormHeader.tsx +2 -2
  23. package/src/components/ui/CommandPalette.tsx +1 -1
  24. package/src/components/ui/Dropdown.tsx +71 -16
  25. package/src/components/ui/PageHeader.tsx +1 -1
  26. package/src/components/ui/Pagination.tsx +1 -1
  27. package/src/components/ui/Toast.tsx +3 -2
  28. package/src/components/ui/Toaster.tsx +1 -1
  29. package/src/components/users/UserDetail.tsx +74 -29
  30. package/src/components/users/UserForm.tsx +1 -1
  31. package/src/components/users/UsersList.tsx +2 -2
  32. package/src/integration.ts +4 -0
  33. package/src/layouts/AdminLayout.astro +26 -34
  34. package/src/layouts/AuthLayout.astro +18 -9
  35. package/src/lib/auth-server.ts +99 -0
  36. package/src/pages/403.astro +54 -35
  37. package/src/pages/[collection]/[id].astro +8 -0
  38. package/src/pages/[collection]/index.astro +7 -0
  39. package/src/pages/audit/index.astro +4 -1
  40. package/src/pages/auth/check-email.astro +146 -0
  41. package/src/pages/auth/forgot-password.astro +95 -0
  42. package/src/pages/auth/login.astro +29 -12
  43. package/src/pages/auth/register.astro +38 -12
  44. package/src/pages/auth/reset-password.astro +125 -0
  45. package/src/pages/auth/verify-email.astro +87 -0
  46. package/src/pages/index.astro +220 -121
  47. package/src/pages/keys.astro +3 -1
  48. package/src/pages/media.astro +3 -1
  49. package/src/pages/preview/[collection]/[id].astro +23 -73
  50. package/src/pages/roles/index.astro +134 -101
  51. package/src/pages/settings/[slug].astro +43 -3
  52. package/src/pages/users/[id].astro +5 -1
  53. package/src/pages/users/index.astro +4 -0
  54. package/src/pages/users/new.astro +4 -0
  55. package/src/pages/webhooks.astro +3 -1
  56. package/src/routes.ts +16 -0
@@ -157,7 +157,7 @@ function MetadataRow({ label, value }: { label: string; value: unknown }) {
157
157
  }
158
158
 
159
159
  export function AuditLogsPage() {
160
- const { t } = useTranslation();
160
+ const { t } = useTranslation();
161
161
  const [logs, setLogs] = useState<AuditLog[]>([]);
162
162
  const [total, setTotal] = useState(0);
163
163
  const [page, setPage] = useState(1);
@@ -212,7 +212,7 @@ export function AuditLogsPage() {
212
212
  return (
213
213
  <div className="flex-1 overflow-y-auto space-y-6">
214
214
  {/* Header */}
215
- <div className="surface-tile p-6 flex items-center justify-between gap-8">
215
+ <div className="surface-tile p-6 flex items-center justify-between rounded-lg gap-8 rounded-lg">
216
216
  <div>
217
217
  <h1 className="text-xl font-bold tracking-tighter text-[var(--kyro-text-primary)]">
218
218
  Audit Logs
@@ -231,7 +231,7 @@ export function AuditLogsPage() {
231
231
  </div>
232
232
 
233
233
  {/* Filters */}
234
- <div className="surface-tile p-4 flex flex-col md:flex-row flex-wrap items-stretch md:items-center gap-3">
234
+ <div className="surface-tile p-4 rounded-lg flex flex-col md:flex-row flex-wrap items-stretch md:items-center gap-3 rounded-lg">
235
235
  <div className="relative flex-1 min-w-48">
236
236
  <Search className="w-4 h-4" />
237
237
  <input
@@ -311,9 +311,9 @@ export function AuditLogsPage() {
311
311
  color: "text-[var(--kyro-text-primary)]",
312
312
  },
313
313
  ].map(({ label, value, color }) => (
314
- <div key={label} className="surface-tile p-4">
314
+ <div key={label} className="surface-tile p-4 rounded-lg">
315
315
  <div className={`text-2xl font-bold ${color}`}>{value}</div>
316
- <div className="text-[10px] font-bold text-[var(--kyro-text-secondary)] tracking-wider mt-1">
316
+ <div className="text-[10px] font-bold text-[var(--kyro-text-secondary)] tracking-wider mt-1">
317
317
  {label}
318
318
  </div>
319
319
  </div>
@@ -322,7 +322,7 @@ export function AuditLogsPage() {
322
322
  )}
323
323
 
324
324
  {/* Table */}
325
- <div className="surface-tile overflow-x-auto">
325
+ <div className="surface-tile overflow-x-auto rounded-lg">
326
326
  {loading ? (
327
327
  <div className="space-y-2 p-4">
328
328
  <Shimmer variant="table-row" count={5} />
@@ -471,7 +471,7 @@ export function AuditLogsPage() {
471
471
 
472
472
  {/* Pagination */}
473
473
  {totalPages > 1 && (
474
- <div className="flex items-center justify-between">
474
+ <div className="surface-tile rounded-lg p-5 flex items-center justify-between">
475
475
  <p className="text-sm text-[var(--kyro-text-secondary)] font-medium">
476
476
  Page {page} of {totalPages}
477
477
  <span className="ml-2 text-[var(--kyro-text-muted)]">
@@ -0,0 +1,235 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import { apiGet } from "../lib/api";
3
+ import { adminPath, apiPath } from "../lib/paths";
4
+ import { DashboardQuickSections } from "./DashboardQuickSections";
5
+
6
+ interface ContentDoc {
7
+ id: string;
8
+ title?: string;
9
+ name?: string;
10
+ status?: string;
11
+ _status?: string;
12
+ createdAt?: string;
13
+ updatedAt?: string;
14
+ [key: string]: unknown;
15
+ }
16
+
17
+ interface AuthorDashboardProps {
18
+ collections: Record<string, any>;
19
+ userName?: string;
20
+ userAvatar?: string;
21
+ userId?: string;
22
+ }
23
+
24
+ function timeAgo(dateStr: string): string {
25
+ const diff = Date.now() - new Date(dateStr).getTime();
26
+ const mins = Math.floor(diff / 60_000);
27
+ if (mins < 1) return "Just now";
28
+ if (mins < 60) return `${mins}m ago`;
29
+ const hrs = Math.floor(mins / 60);
30
+ if (hrs < 24) return `${hrs}h ago`;
31
+ const days = Math.floor(hrs / 24);
32
+ if (days < 30) return `${days}d ago`;
33
+ return new Date(dateStr).toLocaleDateString();
34
+ }
35
+
36
+ function SkeletonCard() {
37
+ return (
38
+ <div className="surface-tile rounded-lg border border-[var(--kyro-border)] overflow-hidden animate-pulse">
39
+ <div className="h-3 w-20 rounded bg-[var(--kyro-bg-secondary)] mb-3" />
40
+ <div className="h-6 w-16 rounded bg-[var(--kyro-bg-secondary)] mb-1" />
41
+ </div>
42
+ );
43
+ }
44
+
45
+ function SkeletonRow() {
46
+ return (
47
+ <div className="flex items-center gap-4 px-6 py-4 animate-pulse">
48
+ <div className="w-8 h-8 rounded-lg bg-[var(--kyro-bg-secondary)]" />
49
+ <div className="flex-1 space-y-2">
50
+ <div className="h-3 w-40 rounded bg-[var(--kyro-bg-secondary)]" />
51
+ <div className="h-2 w-24 rounded bg-[var(--kyro-bg-secondary)]" />
52
+ </div>
53
+ <div className="h-5 w-16 rounded bg-[var(--kyro-bg-secondary)]" />
54
+ </div>
55
+ );
56
+ }
57
+
58
+ export function AuthorDashboard({ collections, userName, userAvatar }: AuthorDashboardProps) {
59
+ const [recentDocs, setRecentDocs] = useState<(ContentDoc & { _collection: string })[]>([]);
60
+ const [docCounts, setDocCounts] = useState<{ total: number; drafts: number; published: number }>({
61
+ total: 0,
62
+ drafts: 0,
63
+ published: 0,
64
+ });
65
+ const [mediaCount, setMediaCount] = useState(0);
66
+ const [loading, setLoading] = useState(true);
67
+
68
+ const displayName = userName || "Author";
69
+ const firstName = displayName.split(" ")[0];
70
+
71
+ const contentCollections = Object.entries(collections).filter(
72
+ ([slug]) => !["users", "audit_logs", "media", "orders", "customers"].includes(slug)
73
+ );
74
+
75
+ const creatableCollections = contentCollections.slice(0, 4);
76
+
77
+ useEffect(() => {
78
+ const fetches: Promise<any>[] = [];
79
+ const allDocs: (ContentDoc & { _collection: string })[] = [];
80
+
81
+ for (const [slug] of contentCollections.slice(0, 6)) {
82
+ fetches.push(
83
+ apiGet<any>(`${apiPath}/${slug}?limit=3&sort=-updatedAt`, { autoToast: false })
84
+ .then((res) => {
85
+ const docs = (res.docs || []).map((d: ContentDoc) => ({ ...d, _collection: slug }));
86
+ allDocs.push(...docs);
87
+ })
88
+ .catch(() => { })
89
+ );
90
+ }
91
+
92
+ fetches.push(
93
+ apiGet<any>(`${apiPath}/media?limit=0`, { autoToast: false })
94
+ .then((res) => setMediaCount(res.totalDocs || 0))
95
+ .catch(() => { })
96
+ );
97
+
98
+ Promise.all(fetches)
99
+ .then(() => {
100
+ allDocs.sort((a, b) => {
101
+ const aTime = new Date(a.updatedAt || a.createdAt || 0).getTime();
102
+ const bTime = new Date(b.updatedAt || b.createdAt || 0).getTime();
103
+ return bTime - aTime;
104
+ });
105
+ setRecentDocs(allDocs.slice(0, 8));
106
+ setDocCounts({
107
+ total: allDocs.length,
108
+ drafts: allDocs.filter((d) => (d._status || d.status || "").toLowerCase() === "draft").length,
109
+ published: allDocs.filter((d) => (d._status || d.status || "").toLowerCase() === "published").length,
110
+ });
111
+ })
112
+ .finally(() => setLoading(false));
113
+ }, []);
114
+
115
+ return (
116
+ <div className="space-y-6">
117
+ {/* Header */}
118
+ <div className="surface-tile p-6 rounded-xl border border-[var(--kyro-border)] flex items-center justify-between gap-4">
119
+ <div className="flex items-center gap-4">
120
+ <div className="w-12 h-12 rounded-xl bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] flex items-center justify-center overflow-hidden flex-shrink-0">
121
+ {userAvatar ? (
122
+ <img src={userAvatar} alt={displayName} className="w-full h-full object-cover" />
123
+ ) : (
124
+ <svg className="w-6 h-6 text-[var(--kyro-text-secondary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
125
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
126
+ </svg>
127
+ )}
128
+ </div>
129
+ <div>
130
+ <h1 className="text-lg font-semibold text-[var(--kyro-text-primary)]">Welcome back, {firstName}</h1>
131
+ <p className="text-xs text-[var(--kyro-text-secondary)] mt-0.5">Author workspace and recent drafts</p>
132
+ </div>
133
+ </div>
134
+ {creatableCollections.length > 0 && (
135
+ <a
136
+ href={`${adminPath}/${creatableCollections[0][0]}/new`}
137
+ className="hidden sm:inline-flex items-center gap-2 px-3.5 py-1.5 rounded-lg border border-[var(--kyro-border)] bg-[var(--kyro-sidebar-active)] text-xs font-semibold text-[var(--kyro-sidebar-text-active)] hover:opacity-90 transition-opacity"
138
+ >
139
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
140
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 5v14M5 12h14" />
141
+ </svg>
142
+ New Document
143
+ </a>
144
+ )}
145
+ </div>
146
+
147
+ {/* Metrics Row */}
148
+ <div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
149
+ {loading ? (
150
+ <>
151
+ <SkeletonCard />
152
+ <SkeletonCard />
153
+ <SkeletonCard />
154
+ <SkeletonCard />
155
+ </>
156
+ ) : (
157
+ <>
158
+ <div className="surface-tile rounded-lg border border-[var(--kyro-border)] overflow-hidden">
159
+ <p className="text-xs font-medium text-[var(--kyro-text-secondary)] mb-1">Total Items</p>
160
+ <p className="text-2xl font-bold tracking-tight text-[var(--kyro-text-primary)]">{docCounts.total}</p>
161
+ </div>
162
+
163
+ <div className="surface-tile rounded-lg border border-[var(--kyro-border)] overflow-hidden">
164
+ <p className="text-xs font-medium text-[var(--kyro-text-secondary)] mb-1">Published</p>
165
+ <p className="text-2xl font-bold tracking-tight text-[var(--kyro-text-primary)]">{docCounts.published}</p>
166
+ </div>
167
+
168
+ <div className="surface-tile rounded-lg border border-[var(--kyro-border)] overflow-hidden">
169
+ <p className="text-xs font-medium text-[var(--kyro-text-secondary)] mb-1">Drafts</p>
170
+ <p className="text-2xl font-bold tracking-tight text-[var(--kyro-text-primary)]">{docCounts.drafts}</p>
171
+ </div>
172
+
173
+ <div className="surface-tile rounded-lg border border-[var(--kyro-border)] overflow-hidden">
174
+ <p className="text-xs font-medium text-[var(--kyro-text-secondary)] mb-1">Media Assets</p>
175
+ <p className="text-2xl font-bold tracking-tight text-[var(--kyro-text-primary)]">{mediaCount}</p>
176
+ </div>
177
+ </>
178
+ )}
179
+ </div>
180
+
181
+ {/* Recent Content */}
182
+ <div className="surface-tile rounded-lg border border-[var(--kyro-border)] overflow-hidden">
183
+ <div className="px-6 py-4 border-b border-[var(--kyro-border)]">
184
+ <h2 className="text-sm font-semibold text-[var(--kyro-text-primary)]">Recent Documents</h2>
185
+ <p className="text-xs text-[var(--kyro-text-secondary)] mt-0.5">Your recently edited content</p>
186
+ </div>
187
+
188
+ {loading ? (
189
+ <div className="divide-y divide-[var(--kyro-border)]">
190
+ <SkeletonRow />
191
+ <SkeletonRow />
192
+ <SkeletonRow />
193
+ </div>
194
+ ) : recentDocs.length === 0 ? (
195
+ <div className="py-10 text-center text-xs text-[var(--kyro-text-muted)]">No content created yet</div>
196
+ ) : (
197
+ <div className="divide-y divide-[var(--kyro-border)]">
198
+ {recentDocs.map((doc) => {
199
+ const docStatus = (doc._status || doc.status || "draft").toLowerCase();
200
+ const title = doc.title || doc.name || "Untitled";
201
+
202
+ return (
203
+ <a
204
+ key={`${doc._collection}-${doc.id}`}
205
+ href={`${adminPath}/${doc._collection}/${doc.id}`}
206
+ className="flex items-center justify-between px-6 py-3.5 hover:bg-[var(--kyro-surface-accent)] transition-colors"
207
+ >
208
+ <div className="flex items-center gap-3 min-w-0">
209
+ <div className="w-8 h-8 rounded-lg bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] flex items-center justify-center text-[var(--kyro-text-secondary)] flex-shrink-0">
210
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
211
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
212
+ </svg>
213
+ </div>
214
+ <div className="min-w-0">
215
+ <p className="text-xs font-medium text-[var(--kyro-text-primary)] truncate">{title}</p>
216
+ <p className="text-[10px] text-[var(--kyro-text-muted)] capitalize">
217
+ {doc._collection} • {doc.updatedAt ? timeAgo(doc.updatedAt) : "—"}
218
+ </p>
219
+ </div>
220
+ </div>
221
+ <span className="px-2 py-0.5 text-[10px] font-medium rounded border border-[var(--kyro-border)] bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] capitalize">
222
+ {docStatus}
223
+ </span>
224
+ </a>
225
+ );
226
+ })}
227
+ </div>
228
+ )}
229
+ </div>
230
+
231
+ {/* RBAC Quick Actions & Explore Content */}
232
+ <DashboardQuickSections collections={collections} serverUserRole="author" />
233
+ </div>
234
+ );
235
+ }
@@ -571,6 +571,7 @@ export function AutoForm({
571
571
  onActionSuccess?.(
572
572
  isPost ? "Document created successfully" : "Changes saved",
573
573
  );
574
+ toast.success(isPost ? "Document created successfully" : "Changes saved successfully");
574
575
  if (globalSlug) {
575
576
  window.dispatchEvent(new Event("kyro:soft-reload"));
576
577
  }
@@ -638,6 +639,7 @@ export function AutoForm({
638
639
  useAutoFormStore.getState().loadDocument(savedData, savedData);
639
640
  setLocalSaveStatus("saved");
640
641
  onActionSuccess?.("Published successfully");
642
+ toast.success("Published changes successfully");
641
643
 
642
644
  setTimeout(() => {
643
645
  setLocalSaveStatus("idle");
@@ -795,7 +797,10 @@ export function AutoForm({
795
797
  if (!emailValue && rowName && typeof rowName === "string" && emailFieldName) {
796
798
  emailValue = (formData[rowName] as Record<string, unknown>)?.[emailFieldName] as string | undefined;
797
799
  }
798
- if (!emailValue) return;
800
+ if (!emailValue) {
801
+ toast.error("Please enter a test recipient email address.");
802
+ return;
803
+ }
799
804
 
800
805
  setLoadingFields((prev) => ({
801
806
  ...prev,
@@ -805,7 +810,12 @@ export function AutoForm({
805
810
  const response = await fetchWithAuth(resolveUrl(actionUrl), {
806
811
  method: (fAdmin.method as string) || "POST",
807
812
  headers: { "Content-Type": "application/json" },
808
- body: JSON.stringify({ email: emailValue }),
813
+ body: JSON.stringify({
814
+ ...formData,
815
+ email: emailValue,
816
+ testEmail: emailValue,
817
+ testEmailSection: { testEmail: emailValue },
818
+ }),
809
819
  });
810
820
  let result: { success?: boolean; message?: string; error?: string } = {};
811
821
  try {
@@ -813,9 +823,9 @@ export function AutoForm({
813
823
  } catch {
814
824
  result = {};
815
825
  }
816
- if (response.ok && result.success) {
826
+ if (response.ok && (result.success || result.message)) {
817
827
  onActionSuccess?.(
818
- result.message || "Action completed successfully",
828
+ result.message || "Test email sent successfully!",
819
829
  );
820
830
  } else {
821
831
  const errorMsg =
@@ -0,0 +1,213 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import { apiGet } from "../lib/api";
3
+ import { adminPath, apiPath } from "../lib/paths";
4
+ import { DashboardQuickSections } from "./DashboardQuickSections";
5
+
6
+ interface OrderDoc {
7
+ id: string;
8
+ status?: string;
9
+ total?: number;
10
+ createdAt?: string;
11
+ updatedAt?: string;
12
+ [key: string]: unknown;
13
+ }
14
+
15
+ interface CustomerDashboardProps {
16
+ collections: Record<string, any>;
17
+ userName?: string;
18
+ userAvatar?: string;
19
+ userId?: string;
20
+ }
21
+
22
+ function formatCurrency(n: number, currency = "USD") {
23
+ return new Intl.NumberFormat("en-US", {
24
+ style: "currency",
25
+ currency,
26
+ maximumFractionDigits: 2,
27
+ }).format(n);
28
+ }
29
+
30
+ function timeAgo(dateStr: string): string {
31
+ const diff = Date.now() - new Date(dateStr).getTime();
32
+ const mins = Math.floor(diff / 60_000);
33
+ if (mins < 1) return "Just now";
34
+ if (mins < 60) return `${mins}m ago`;
35
+ const hrs = Math.floor(mins / 60);
36
+ if (hrs < 24) return `${hrs}h ago`;
37
+ const days = Math.floor(hrs / 24);
38
+ if (days < 30) return `${days}d ago`;
39
+ return new Date(dateStr).toLocaleDateString();
40
+ }
41
+
42
+ function SkeletonCard() {
43
+ return (
44
+ <div className="surface-tile rounded-lg border border-[var(--kyro-border)] overflow-hidden animate-pulse">
45
+ <div className="h-3 w-20 rounded bg-[var(--kyro-bg-secondary)] mb-3" />
46
+ <div className="h-6 w-16 rounded bg-[var(--kyro-bg-secondary)] mb-1" />
47
+ <div className="h-3 w-24 rounded bg-[var(--kyro-bg-secondary)]" />
48
+ </div>
49
+ );
50
+ }
51
+
52
+ function SkeletonRow() {
53
+ return (
54
+ <div className="flex items-center gap-4 px-6 py-4 animate-pulse">
55
+ <div className="w-8 h-8 rounded-lg bg-[var(--kyro-bg-secondary)]" />
56
+ <div className="flex-1 space-y-2">
57
+ <div className="h-3 w-32 rounded bg-[var(--kyro-bg-secondary)]" />
58
+ <div className="h-2 w-20 rounded bg-[var(--kyro-bg-secondary)]" />
59
+ </div>
60
+ <div className="h-5 w-16 rounded bg-[var(--kyro-bg-secondary)]" />
61
+ </div>
62
+ );
63
+ }
64
+
65
+ export function CustomerDashboard({ collections, userName, userAvatar }: CustomerDashboardProps) {
66
+ const [orders, setOrders] = useState<OrderDoc[]>([]);
67
+ const [orderCount, setOrderCount] = useState(0);
68
+ const [loading, setLoading] = useState(true);
69
+
70
+ const hasOrders = !!collections["orders"];
71
+ const displayName = userName || "User";
72
+ const firstName = displayName.split(" ")[0];
73
+
74
+ useEffect(() => {
75
+ if (hasOrders) {
76
+ apiGet<any>(`${apiPath}/orders?limit=5&sort=-createdAt`, { autoToast: false })
77
+ .then((res) => {
78
+ setOrders(res.docs || []);
79
+ setOrderCount(res.totalDocs || 0);
80
+ })
81
+ .catch(() => {
82
+ setOrders([]);
83
+ setOrderCount(0);
84
+ })
85
+ .finally(() => setLoading(false));
86
+ } else {
87
+ setLoading(false);
88
+ }
89
+ }, [hasOrders]);
90
+
91
+ const totalSpent = orders.reduce((sum, o) => sum + (typeof o.total === "number" ? o.total : 0), 0);
92
+ const pendingOrders = orders.filter((o) => ["pending", "processing"].includes((o.status || "").toLowerCase())).length;
93
+
94
+ return (
95
+ <div className="space-y-6">
96
+ {/* Welcome Header */}
97
+ <div className="surface-tile rounded-xl border border-[var(--kyro-border)] flex items-center justify-between gap-4 p-6">
98
+ <div className="flex items-center gap-4">
99
+ <div className="w-12 h-12 rounded-xl bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] flex items-center justify-center overflow-hidden flex-shrink-0">
100
+ {userAvatar ? (
101
+ <img src={userAvatar} alt={displayName} className="w-full h-full object-cover" />
102
+ ) : (
103
+ <svg className="w-6 h-6 text-[var(--kyro-text-secondary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
104
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
105
+ </svg>
106
+ )}
107
+ </div>
108
+ <div>
109
+ <h1 className="text-lg font-semibold text-[var(--kyro-text-primary)]">Welcome back, {firstName}</h1>
110
+ <p className="text-xs text-[var(--kyro-text-secondary)] mt-0.5">Account overview and recent activity</p>
111
+ </div>
112
+ </div>
113
+ <a
114
+ href={`${adminPath}/profile`}
115
+ className="hidden sm:inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border border-[var(--kyro-border)] bg-[var(--kyro-surface-accent)] text-xs font-medium text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface)] transition-colors"
116
+ >
117
+ My Profile
118
+ </a>
119
+ </div>
120
+
121
+ {/* Stats Grid */}
122
+ {hasOrders && (
123
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
124
+ {loading ? (
125
+ <>
126
+ <SkeletonCard />
127
+ <SkeletonCard />
128
+ <SkeletonCard />
129
+ </>
130
+ ) : (
131
+ <>
132
+ <div className="surface-tile rounded-lg border border-[var(--kyro-border)] overflow-hidden">
133
+ <p className="text-xs font-medium text-[var(--kyro-text-secondary)] mb-1">Total Orders</p>
134
+ <p className="text-2xl font-bold tracking-tight text-[var(--kyro-text-primary)]">{orderCount}</p>
135
+ </div>
136
+
137
+ <div className="surface-tile rounded-lg border border-[var(--kyro-border)] overflow-hidden">
138
+ <p className="text-xs font-medium text-[var(--kyro-text-secondary)] mb-1">Total Spent</p>
139
+ <p className="text-2xl font-bold tracking-tight text-[var(--kyro-text-primary)]">{formatCurrency(totalSpent)}</p>
140
+ </div>
141
+
142
+ <div className="surface-tile rounded-lg border border-[var(--kyro-border)] overflow-hidden">
143
+ <p className="text-xs font-medium text-[var(--kyro-text-secondary)] mb-1">Pending Orders</p>
144
+ <p className="text-2xl font-bold tracking-tight text-[var(--kyro-text-primary)]">{pendingOrders}</p>
145
+ </div>
146
+ </>
147
+ )}
148
+ </div>
149
+ )}
150
+
151
+ {/* Recent Orders */}
152
+ {hasOrders && (
153
+ <div className="surface-tile rounded-lg border border-[var(--kyro-border)] overflow-hidden">
154
+ <div className="flex items-center justify-between px-6 py-4 border-b border-[var(--kyro-border)]">
155
+ <div>
156
+ <h2 className="text-sm font-semibold text-[var(--kyro-text-primary)]">Recent Orders</h2>
157
+ <p className="text-xs text-[var(--kyro-text-secondary)] mt-0.5">Latest order history</p>
158
+ </div>
159
+ <a
160
+ href={`${adminPath}/orders`}
161
+ className="text-xs font-medium text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] transition-colors"
162
+ >
163
+ View All →
164
+ </a>
165
+ </div>
166
+
167
+ {loading ? (
168
+ <div className="divide-y divide-[var(--kyro-border)]">
169
+ <SkeletonRow />
170
+ <SkeletonRow />
171
+ <SkeletonRow />
172
+ </div>
173
+ ) : orders.length === 0 ? (
174
+ <div className="py-10 text-center text-xs text-[var(--kyro-text-muted)]">No orders found</div>
175
+ ) : (
176
+ <div className="divide-y divide-[var(--kyro-border)]">
177
+ {orders.map((order) => (
178
+ <a
179
+ key={order.id}
180
+ href={`${adminPath}/orders/${order.id}`}
181
+ className="flex items-center justify-between px-6 py-3.5 hover:bg-[var(--kyro-surface-accent)] transition-colors"
182
+ >
183
+ <div className="flex items-center gap-3">
184
+ <div className="w-8 h-8 rounded-lg bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] flex items-center justify-center text-[var(--kyro-text-secondary)]">
185
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
186
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
187
+ </svg>
188
+ </div>
189
+ <div>
190
+ <p className="text-xs font-medium text-[var(--kyro-text-primary)]">Order #{order.id?.slice(-6) || order.id}</p>
191
+ <p className="text-[10px] text-[var(--kyro-text-muted)]">{order.createdAt ? timeAgo(order.createdAt) : "—"}</p>
192
+ </div>
193
+ </div>
194
+ <div className="flex items-center gap-4">
195
+ {typeof order.total === "number" && (
196
+ <span className="text-xs font-semibold text-[var(--kyro-text-primary)]">{formatCurrency(order.total)}</span>
197
+ )}
198
+ <span className="px-2 py-0.5 text-[10px] font-medium rounded border border-[var(--kyro-border)] bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] capitalize">
199
+ {order.status || "pending"}
200
+ </span>
201
+ </div>
202
+ </a>
203
+ ))}
204
+ </div>
205
+ )}
206
+ </div>
207
+ )}
208
+
209
+ {/* RBAC Quick Actions & Explore Content */}
210
+ <DashboardQuickSections collections={collections} serverUserRole="customer" />
211
+ </div>
212
+ );
213
+ }
@@ -125,7 +125,7 @@ export function Dashboard({ collections, onNavigate, user }: DashboardProps) {
125
125
  ].map((stat, i) => (
126
126
  <div
127
127
  key={i}
128
- className="surface-tile p-6 flex items-center justify-between group hover:border-[var(--kyro-primary)] transition-all duration-500 cursor-default"
128
+ className="surface-tile p-6 flex items-center justify-between rounded-lg group hover:border-[var(--kyro-primary)] transition-all duration-500 cursor-default"
129
129
  >
130
130
  <div>
131
131
  <p className="text-[10px] font-bold tracking-[0.2em] opacity-40 mb-1">