@kyro-cms/admin 0.12.20 → 0.12.21
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.cjs +24 -24
- package/dist/index.d.cts +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +19 -19
- package/package.json +6 -6
- package/src/components/ApiKeysManager.tsx +177 -227
- package/src/components/AuditLogsPage.tsx +7 -7
- package/src/components/AuthorDashboard.tsx +235 -0
- package/src/components/AutoForm.tsx +14 -4
- package/src/components/CustomerDashboard.tsx +213 -0
- package/src/components/Dashboard.tsx +1 -1
- package/src/components/DashboardMetrics.tsx +74 -9
- package/src/components/DashboardQuickSections.tsx +237 -0
- package/src/components/ListView.tsx +3 -3
- package/src/components/MediaGallery.tsx +2 -2
- package/src/components/PluginsManager.tsx +1 -1
- package/src/components/SessionsManager.tsx +15 -15
- package/src/components/Sidebar.astro +564 -116
- package/src/components/UserManagement.tsx +43 -9
- package/src/components/UserMenu.tsx +123 -61
- package/src/components/autoform/AutoFormEditView.tsx +4 -4
- package/src/components/autoform/AutoFormHeader.tsx +2 -2
- package/src/components/ui/CommandPalette.tsx +1 -1
- package/src/components/ui/Dropdown.tsx +71 -16
- package/src/components/ui/PageHeader.tsx +1 -1
- package/src/components/ui/Pagination.tsx +1 -1
- package/src/components/ui/Toast.tsx +3 -2
- package/src/components/ui/Toaster.tsx +1 -1
- package/src/components/users/UserDetail.tsx +74 -29
- package/src/components/users/UserForm.tsx +1 -1
- package/src/components/users/UsersList.tsx +2 -2
- package/src/integration.ts +4 -0
- package/src/layouts/AdminLayout.astro +26 -34
- package/src/layouts/AuthLayout.astro +18 -9
- package/src/lib/auth-server.ts +99 -0
- package/src/pages/403.astro +54 -35
- package/src/pages/[collection]/[id].astro +8 -0
- package/src/pages/[collection]/index.astro +7 -0
- package/src/pages/audit/index.astro +4 -1
- package/src/pages/auth/check-email.astro +146 -0
- package/src/pages/auth/forgot-password.astro +95 -0
- package/src/pages/auth/login.astro +29 -12
- package/src/pages/auth/register.astro +38 -12
- package/src/pages/auth/reset-password.astro +125 -0
- package/src/pages/auth/verify-email.astro +87 -0
- package/src/pages/index.astro +220 -121
- package/src/pages/keys.astro +3 -1
- package/src/pages/media.astro +3 -1
- package/src/pages/preview/[collection]/[id].astro +23 -73
- package/src/pages/roles/index.astro +134 -101
- package/src/pages/settings/[slug].astro +43 -3
- package/src/pages/users/[id].astro +5 -1
- package/src/pages/users/index.astro +4 -0
- package/src/pages/users/new.astro +4 -0
- package/src/pages/webhooks.astro +3 -1
- 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
|
-
|
|
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)]
|
|
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)
|
|
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({
|
|
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 || "
|
|
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">
|