@utopia-studio-design/design-system-cli 0.8.0 → 0.8.1
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/bin/utopia-ds-mcp.mjs +5 -15
- package/bin/utopia-ds.mjs +3 -16
- package/data/docs/create-a-brandbook.md +11 -1
- package/data/manifests/blocks.json +21 -230
- package/data/manifests/patterns.json +0 -330
- package/lib/api.mjs +6 -193
- package/lib/brandbook-validation.mjs +112 -0
- package/package.json +2 -2
- package/data/blocks/semantic-pattern-parts/AuthParts.tsx +0 -48
- package/data/blocks/semantic-pattern-parts/ChoiceCards.tsx +0 -7
- package/data/blocks/semantic-pattern-parts/ListFilters.tsx +0 -9
- package/data/blocks/semantic-pattern-parts/OutcomeNotice.tsx +0 -7
- package/data/blocks/semantic-pattern-parts/PatternParts.tsx +0 -7
- package/data/blocks/semantic-pattern-parts/ProductParts.tsx +0 -38
- package/data/blocks/semantic-pattern-parts/ProgressSteps.tsx +0 -6
- package/data/blocks/semantic-pattern-parts/ReviewList.tsx +0 -7
- package/data/blocks/semantic-pattern-parts/ValidatedField.tsx +0 -8
- package/data/blocks/semantic-pattern-parts/WorkflowParts.tsx +0 -339
- package/data/blocks/semantic-pattern-parts/auth-parts.css +0 -27
- package/data/blocks/semantic-pattern-parts/pattern-parts.css +0 -25
- package/data/blocks/semantic-pattern-parts/product-parts.css +0 -25
- package/data/blocks/semantic-pattern-parts/types.ts +0 -3
- package/data/blocks/semantic-pattern-parts/workflow-parts.css +0 -118
- package/data/blocks/ux-pattern-examples/AccessExamples.tsx +0 -46
- package/data/blocks/ux-pattern-examples/AccountExamples.tsx +0 -133
- package/data/blocks/ux-pattern-examples/OnboardingExample.tsx +0 -15
- package/data/blocks/ux-pattern-examples/ProductExamples.tsx +0 -79
- package/data/blocks/ux-pattern-examples/ReviewExample.tsx +0 -14
- package/data/blocks/ux-pattern-examples/SignupExample.tsx +0 -22
- package/data/blocks/ux-pattern-examples/WorkflowExample.tsx +0 -1761
- package/data/blocks/ux-pattern-examples/WorkflowExamples.tsx +0 -190
- package/data/blocks/ux-pattern-examples/copy.ts +0 -6
- package/data/blocks/ux-pattern-examples/ux-examples.css +0 -18
- package/data/blocks/ux-pattern-examples/workflow-copy.ts +0 -76
- package/data/blocks/ux-pattern-examples/workflow-definitions.ts +0 -1291
- package/data/docs/ui-parts-qa.md +0 -172
- package/data/docs/ui-parts.md +0 -105
- package/data/docs/ux-pattern-adaptation-qa.md +0 -38
- package/data/docs/ux-pattern-adaptation.md +0 -41
- package/data/docs/ux-pattern-migration.md +0 -111
- package/data/docs/ux-pattern-release-2026-09-13.md +0 -26
- package/data/docs/ux-source-evidence/account-auth.json +0 -41
- package/data/docs/ux-source-evidence/account-forgot-password.json +0 -36
- package/data/docs/ux-source-evidence/account-invite.json +0 -63
- package/data/docs/ux-source-evidence/account-locked.json +0 -63
- package/data/docs/ux-source-evidence/account-login.json +0 -28
- package/data/docs/ux-source-evidence/account-mfa.json +0 -62
- package/data/docs/ux-source-evidence/account-onboarding-credentials.json +0 -52
- package/data/docs/ux-source-evidence/account-onboarding-legal.json +0 -56
- package/data/docs/ux-source-evidence/account-onboarding-orientation.json +0 -66
- package/data/docs/ux-source-evidence/account-onboarding-profile.json +0 -50
- package/data/docs/ux-source-evidence/account-onboarding.json +0 -69
- package/data/docs/ux-source-evidence/account-reset-password.json +0 -27
- package/data/docs/ux-source-evidence/account-session-expired.json +0 -62
- package/data/docs/ux-source-evidence/account-signup.json +0 -33
- package/data/docs/ux-source-evidence/account-verify-email.json +0 -34
- package/data/docs/ux-source-evidence/account-welcome.json +0 -41
- package/data/docs/ux-source-evidence/admin-analytics.json +0 -54
- package/data/docs/ux-source-evidence/admin-audit.json +0 -31
- package/data/docs/ux-source-evidence/admin-broadcasts-new.json +0 -36
- package/data/docs/ux-source-evidence/admin-broadcasts.json +0 -41
- package/data/docs/ux-source-evidence/admin-closures.json +0 -52
- package/data/docs/ux-source-evidence/admin-devices.json +0 -51
- package/data/docs/ux-source-evidence/admin-escalations.json +0 -35
- package/data/docs/ux-source-evidence/admin-incidents.json +0 -54
- package/data/docs/ux-source-evidence/admin-loading.json +0 -17
- package/data/docs/ux-source-evidence/admin-onboarding-detail.json +0 -80
- package/data/docs/ux-source-evidence/admin-onboarding.json +0 -62
- package/data/docs/ux-source-evidence/admin-payments.json +0 -45
- package/data/docs/ux-source-evidence/admin-providers.json +0 -38
- package/data/docs/ux-source-evidence/admin-settings.json +0 -18
- package/data/docs/ux-source-evidence/admin-subscriptions-detail-allocate.json +0 -42
- package/data/docs/ux-source-evidence/admin-subscriptions-detail.json +0 -48
- package/data/docs/ux-source-evidence/admin-subscriptions-new.json +0 -43
- package/data/docs/ux-source-evidence/admin-subscriptions.json +0 -41
- package/data/docs/ux-source-evidence/admin-transactions.json +0 -41
- package/data/docs/ux-source-evidence/error.json +0 -30
- package/data/docs/ux-source-evidence/loading.json +0 -19
- package/data/docs/ux-source-evidence/marketing-blog.json +0 -48
- package/data/docs/ux-source-evidence/marketing-contact.json +0 -36
- package/data/docs/ux-source-evidence/marketing.json +0 -64
- package/data/docs/ux-source-evidence/not-found.json +0 -28
- package/data/docs/ux-source-evidence/portal-loading.json +0 -17
- package/data/docs/ux-source-evidence/portal-mentor-messages.json +0 -31
- package/data/docs/ux-source-evidence/portal-mentor-profile.json +0 -23
- package/data/docs/ux-source-evidence/portal-mentor-requests-detail.json +0 -54
- package/data/docs/ux-source-evidence/portal-mentor-sessions-detail-assess.json +0 -42
- package/data/docs/ux-source-evidence/portal-mentor-settings.json +0 -18
- package/data/docs/ux-source-evidence/portal-mentor.json +0 -38
- package/data/docs/ux-source-evidence/portal-sessions-detail-live.json +0 -48
- package/data/docs/ux-source-evidence/portal-sponsor-billing.json +0 -28
- package/data/docs/ux-source-evidence/portal-sponsor-profile.json +0 -22
- package/data/docs/ux-source-evidence/portal-sponsor-sessions.json +0 -28
- package/data/docs/ux-source-evidence/portal-sponsor-settings.json +0 -18
- package/data/docs/ux-source-evidence/portal-sponsor-trainees.json +0 -36
- package/data/docs/ux-source-evidence/portal-sponsor.json +0 -52
- package/data/docs/ux-source-evidence/portal-trainee-competency.json +0 -42
- package/data/docs/ux-source-evidence/portal-trainee-credits.json +0 -40
- package/data/docs/ux-source-evidence/portal-trainee-mentors.json +0 -48
- package/data/docs/ux-source-evidence/portal-trainee-messages.json +0 -32
- package/data/docs/ux-source-evidence/portal-trainee-notifications.json +0 -35
- package/data/docs/ux-source-evidence/portal-trainee-profile.json +0 -26
- package/data/docs/ux-source-evidence/portal-trainee-request.json +0 -54
- package/data/docs/ux-source-evidence/portal-trainee-sessions-detail-feedback.json +0 -36
- package/data/docs/ux-source-evidence/portal-trainee-sessions-detail-precheck.json +0 -42
- package/data/docs/ux-source-evidence/portal-trainee-sessions-detail.json +0 -55
- package/data/docs/ux-source-evidence/portal-trainee-sessions.json +0 -36
- package/data/docs/ux-source-evidence/portal-trainee-settings-notifications.json +0 -38
- package/data/docs/ux-source-evidence/portal-trainee-settings.json +0 -18
- package/data/docs/ux-source-evidence/portal-trainee.json +0 -44
- package/data/docs/ux-source-evidence/portal-tv-detail.json +0 -39
- package/data/docs/ux-source-evidence/portal-tv.json +0 -44
- package/data/docs/ux-source-evidence/shared-admin-admin-shell.json +0 -35
- package/data/docs/ux-source-evidence/shared-auth-auth-primitives.json +0 -105
- package/data/docs/ux-source-evidence/shared-auth-auth-shell.json +0 -25
- package/data/docs/ux-source-evidence/shared-auth-onboarding-primitives.json +0 -52
- package/data/docs/ux-source-evidence/shared-auth-onboarding-shell.json +0 -28
- package/data/docs/ux-source-evidence/shared-auth-status-indicator.json +0 -41
- package/data/docs/ux-source-evidence/shared-portal-empty-state.json +0 -24
- package/data/docs/ux-source-evidence/shared-portal-loading-skeleton.json +0 -27
- package/data/docs/ux-source-evidence/shared-portal-notifications-dropdown.json +0 -39
- package/data/docs/ux-source-evidence/shared-portal-portal-shell.json +0 -36
- package/data/docs/ux-source-evidence/shared-portal-profile-form.json +0 -32
- package/data/docs/ux-source-evidence/shared-portal-settings-view.json +0 -81
- package/data/docs/ux-source-evidence/shared-portal-usage-sparkline.json +0 -18
- package/data/docs/ux-source-evidence/shared-portal-user-menu.json +0 -34
- package/data/manifests/ui-patterns.json +0 -1029
- package/data/manifests/ux-examples.json +0 -2177
- package/data/manifests/ux-patterns.json +0 -1135
- package/data/manifests/ux-source-inventory.json +0 -4669
- package/data/ui-parts/blocks/analytics-dashboard/AnalyticsMetricBlocks.tsx +0 -87
- package/data/ui-parts/blocks/analytics-dashboard/analytics-dashboard.css +0 -92
- package/data/ui-parts/blocks/semantic-pattern-parts/AuthParts.tsx +0 -48
- package/data/ui-parts/blocks/semantic-pattern-parts/ChoiceCards.tsx +0 -7
- package/data/ui-parts/blocks/semantic-pattern-parts/ListFilters.tsx +0 -9
- package/data/ui-parts/blocks/semantic-pattern-parts/OutcomeNotice.tsx +0 -7
- package/data/ui-parts/blocks/semantic-pattern-parts/ProductParts.tsx +0 -38
- package/data/ui-parts/blocks/semantic-pattern-parts/ProgressSteps.tsx +0 -6
- package/data/ui-parts/blocks/semantic-pattern-parts/ReviewList.tsx +0 -7
- package/data/ui-parts/blocks/semantic-pattern-parts/ValidatedField.tsx +0 -8
- package/data/ui-parts/blocks/semantic-pattern-parts/WorkflowParts.tsx +0 -339
- package/data/ui-parts/blocks/semantic-pattern-parts/auth-parts.css +0 -27
- package/data/ui-parts/blocks/semantic-pattern-parts/pattern-parts.css +0 -25
- package/data/ui-parts/blocks/semantic-pattern-parts/product-parts.css +0 -25
- package/data/ui-parts/blocks/semantic-pattern-parts/types.ts +0 -3
- package/data/ui-parts/blocks/semantic-pattern-parts/workflow-parts.css +0 -118
- package/data/ui-parts/blocks/ux-pattern-examples/AccessExamples.tsx +0 -46
- package/data/ui-parts/blocks/ux-pattern-examples/AccountExamples.tsx +0 -133
- package/data/ui-parts/blocks/ux-pattern-examples/ProductExamples.tsx +0 -79
- package/data/ui-parts/blocks/ux-pattern-examples/WorkflowExample.tsx +0 -1761
- package/data/ui-parts/blocks/ux-pattern-examples/WorkflowExamples.tsx +0 -190
- package/data/ui-parts/blocks/ux-pattern-examples/copy.ts +0 -6
- package/data/ui-parts/blocks/ux-pattern-examples/ux-examples.css +0 -18
- package/data/ui-parts/blocks/ux-pattern-examples/workflow-copy.ts +0 -76
- package/data/ui-parts/blocks/ux-pattern-examples/workflow-definitions.ts +0 -1291
- package/data/ux-source-evidence/account-auth.json +0 -41
- package/data/ux-source-evidence/account-forgot-password.json +0 -36
- package/data/ux-source-evidence/account-invite.json +0 -63
- package/data/ux-source-evidence/account-locked.json +0 -63
- package/data/ux-source-evidence/account-login.json +0 -28
- package/data/ux-source-evidence/account-mfa.json +0 -62
- package/data/ux-source-evidence/account-onboarding-credentials.json +0 -52
- package/data/ux-source-evidence/account-onboarding-legal.json +0 -56
- package/data/ux-source-evidence/account-onboarding-orientation.json +0 -66
- package/data/ux-source-evidence/account-onboarding-profile.json +0 -50
- package/data/ux-source-evidence/account-onboarding.json +0 -69
- package/data/ux-source-evidence/account-reset-password.json +0 -27
- package/data/ux-source-evidence/account-session-expired.json +0 -62
- package/data/ux-source-evidence/account-signup.json +0 -33
- package/data/ux-source-evidence/account-verify-email.json +0 -34
- package/data/ux-source-evidence/account-welcome.json +0 -41
- package/data/ux-source-evidence/admin-analytics.json +0 -54
- package/data/ux-source-evidence/admin-audit.json +0 -31
- package/data/ux-source-evidence/admin-broadcasts-new.json +0 -36
- package/data/ux-source-evidence/admin-broadcasts.json +0 -41
- package/data/ux-source-evidence/admin-closures.json +0 -52
- package/data/ux-source-evidence/admin-devices.json +0 -51
- package/data/ux-source-evidence/admin-escalations.json +0 -35
- package/data/ux-source-evidence/admin-incidents.json +0 -54
- package/data/ux-source-evidence/admin-loading.json +0 -17
- package/data/ux-source-evidence/admin-onboarding-detail.json +0 -80
- package/data/ux-source-evidence/admin-onboarding.json +0 -62
- package/data/ux-source-evidence/admin-payments.json +0 -45
- package/data/ux-source-evidence/admin-providers.json +0 -38
- package/data/ux-source-evidence/admin-settings.json +0 -18
- package/data/ux-source-evidence/admin-subscriptions-detail-allocate.json +0 -42
- package/data/ux-source-evidence/admin-subscriptions-detail.json +0 -48
- package/data/ux-source-evidence/admin-subscriptions-new.json +0 -43
- package/data/ux-source-evidence/admin-subscriptions.json +0 -41
- package/data/ux-source-evidence/admin-transactions.json +0 -41
- package/data/ux-source-evidence/error.json +0 -30
- package/data/ux-source-evidence/loading.json +0 -19
- package/data/ux-source-evidence/marketing-blog.json +0 -48
- package/data/ux-source-evidence/marketing-contact.json +0 -36
- package/data/ux-source-evidence/marketing.json +0 -64
- package/data/ux-source-evidence/not-found.json +0 -28
- package/data/ux-source-evidence/portal-loading.json +0 -17
- package/data/ux-source-evidence/portal-mentor-messages.json +0 -31
- package/data/ux-source-evidence/portal-mentor-profile.json +0 -23
- package/data/ux-source-evidence/portal-mentor-requests-detail.json +0 -54
- package/data/ux-source-evidence/portal-mentor-sessions-detail-assess.json +0 -42
- package/data/ux-source-evidence/portal-mentor-settings.json +0 -18
- package/data/ux-source-evidence/portal-mentor.json +0 -38
- package/data/ux-source-evidence/portal-sessions-detail-live.json +0 -48
- package/data/ux-source-evidence/portal-sponsor-billing.json +0 -28
- package/data/ux-source-evidence/portal-sponsor-profile.json +0 -22
- package/data/ux-source-evidence/portal-sponsor-sessions.json +0 -28
- package/data/ux-source-evidence/portal-sponsor-settings.json +0 -18
- package/data/ux-source-evidence/portal-sponsor-trainees.json +0 -36
- package/data/ux-source-evidence/portal-sponsor.json +0 -52
- package/data/ux-source-evidence/portal-trainee-competency.json +0 -42
- package/data/ux-source-evidence/portal-trainee-credits.json +0 -40
- package/data/ux-source-evidence/portal-trainee-mentors.json +0 -48
- package/data/ux-source-evidence/portal-trainee-messages.json +0 -32
- package/data/ux-source-evidence/portal-trainee-notifications.json +0 -35
- package/data/ux-source-evidence/portal-trainee-profile.json +0 -26
- package/data/ux-source-evidence/portal-trainee-request.json +0 -54
- package/data/ux-source-evidence/portal-trainee-sessions-detail-feedback.json +0 -36
- package/data/ux-source-evidence/portal-trainee-sessions-detail-precheck.json +0 -42
- package/data/ux-source-evidence/portal-trainee-sessions-detail.json +0 -55
- package/data/ux-source-evidence/portal-trainee-sessions.json +0 -36
- package/data/ux-source-evidence/portal-trainee-settings-notifications.json +0 -38
- package/data/ux-source-evidence/portal-trainee-settings.json +0 -18
- package/data/ux-source-evidence/portal-trainee.json +0 -44
- package/data/ux-source-evidence/portal-tv-detail.json +0 -39
- package/data/ux-source-evidence/portal-tv.json +0 -44
- package/data/ux-source-evidence/shared-admin-admin-shell.json +0 -35
- package/data/ux-source-evidence/shared-auth-auth-primitives.json +0 -105
- package/data/ux-source-evidence/shared-auth-auth-shell.json +0 -25
- package/data/ux-source-evidence/shared-auth-onboarding-primitives.json +0 -52
- package/data/ux-source-evidence/shared-auth-onboarding-shell.json +0 -28
- package/data/ux-source-evidence/shared-auth-status-indicator.json +0 -41
- package/data/ux-source-evidence/shared-portal-empty-state.json +0 -24
- package/data/ux-source-evidence/shared-portal-loading-skeleton.json +0 -27
- package/data/ux-source-evidence/shared-portal-notifications-dropdown.json +0 -39
- package/data/ux-source-evidence/shared-portal-portal-shell.json +0 -36
- package/data/ux-source-evidence/shared-portal-profile-form.json +0 -32
- package/data/ux-source-evidence/shared-portal-settings-view.json +0 -81
- package/data/ux-source-evidence/shared-portal-usage-sparkline.json +0 -18
- package/data/ux-source-evidence/shared-portal-user-menu.json +0 -34
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
|
|
3
|
-
"file": "components/portal/loading-skeleton.tsx",
|
|
4
|
-
"sha256": "c8144df2a17b7983868ef9175cf184c9943db095eb1229e2285dbe0db81dcaa9",
|
|
5
|
-
"imports": [
|
|
6
|
-
"framer-motion"
|
|
7
|
-
],
|
|
8
|
-
"tokens": [
|
|
9
|
-
"--color-bg-default",
|
|
10
|
-
"--color-bg-subtle",
|
|
11
|
-
"--color-bg-ui",
|
|
12
|
-
"--color-border-default"
|
|
13
|
-
],
|
|
14
|
-
"fragments": [
|
|
15
|
-
{
|
|
16
|
-
"id": "LoadingSkeleton",
|
|
17
|
-
"line": 5,
|
|
18
|
-
"code": "export function LoadingSkeleton() {\n return (\n <div className=\"min-h-screen flex\" style={{ background: \"var(--color-bg-default)\" }}>\n {/* Sidebar skeleton */}\n <aside className=\"hidden lg:flex w-60 flex-col h-screen border-r shrink-0\" style={{ background: \"var(--color-bg-subtle)\", borderColor: \"var(--color-border-default)\" }}>\n <div className=\"h-14 border-b flex items-center px-5\" style={{ borderColor: \"var(--color-border-default)\" }}>\n <Pulse className=\"w-32 h-5\" />\n </div>\n <div className=\"p-3 space-y-1\">\n {Array.from({ length: 5 }).map((_, i) => <Pulse key={i} className=\"w-full h-8 rounded-md\" />)}\n </div>\n </aside>\n\n <main className=\"flex-1 min-h-screen\">\n <div className=\"h-14 border-b px-8 flex items-center justify-between\" style={{ borderColor: \"var(--color-border-default)\" }}>\n <Pulse className=\"w-24 h-4\" />\n <div className=\"flex items-center gap-2\"><Pulse className=\"w-8 h-8 rounded-md\" /><Pulse className=\"w-8 h-8 rounded-full\" /></div>\n </div>\n\n <div className=\"p-8\">\n <Pulse className=\"w-64 h-9 mb-2\" />\n <Pulse className=\"w-96 h-4 mb-6\" />\n\n <div className=\"grid grid-cols-1 sm:grid-cols-3 gap-3 mb-6\">\n {Array.from({ length: 3 }).map((_, i) => <Pulse key={i} className=\"h-24 rounded-xl\" />)}\n </div>\n\n <div className=\"space-y-3\">\n <Pulse className=\"h-12 rounded-lg\" />\n <Pulse className=\"h-12 rounded-lg\" />\n <Pulse className=\"h-12 rounded-lg\" />\n </div>\n </div>\n </main>\n </div>\n );\n}"
|
|
19
|
-
},
|
|
20
|
-
{
|
|
21
|
-
"id": "Pulse",
|
|
22
|
-
"line": 43,
|
|
23
|
-
"code": "function Pulse({ className }: { className: string }) {\n return (\n <motion.div\n className={className}\n style={{ background: \"var(--color-bg-ui)\" }}\n animate={{ opacity: [0.5, 1, 0.5] }}\n transition={{ duration: 1.4, repeat: Infinity, ease: \"easeInOut\" }}\n />\n );\n}"
|
|
24
|
-
}
|
|
25
|
-
],
|
|
26
|
-
"content": "\"use client\";\n\nimport { motion } from \"framer-motion\";\n\nexport function LoadingSkeleton() {\n return (\n <div className=\"min-h-screen flex\" style={{ background: \"var(--color-bg-default)\" }}>\n {/* Sidebar skeleton */}\n <aside className=\"hidden lg:flex w-60 flex-col h-screen border-r shrink-0\" style={{ background: \"var(--color-bg-subtle)\", borderColor: \"var(--color-border-default)\" }}>\n <div className=\"h-14 border-b flex items-center px-5\" style={{ borderColor: \"var(--color-border-default)\" }}>\n <Pulse className=\"w-32 h-5\" />\n </div>\n <div className=\"p-3 space-y-1\">\n {Array.from({ length: 5 }).map((_, i) => <Pulse key={i} className=\"w-full h-8 rounded-md\" />)}\n </div>\n </aside>\n\n <main className=\"flex-1 min-h-screen\">\n <div className=\"h-14 border-b px-8 flex items-center justify-between\" style={{ borderColor: \"var(--color-border-default)\" }}>\n <Pulse className=\"w-24 h-4\" />\n <div className=\"flex items-center gap-2\"><Pulse className=\"w-8 h-8 rounded-md\" /><Pulse className=\"w-8 h-8 rounded-full\" /></div>\n </div>\n\n <div className=\"p-8\">\n <Pulse className=\"w-64 h-9 mb-2\" />\n <Pulse className=\"w-96 h-4 mb-6\" />\n\n <div className=\"grid grid-cols-1 sm:grid-cols-3 gap-3 mb-6\">\n {Array.from({ length: 3 }).map((_, i) => <Pulse key={i} className=\"h-24 rounded-xl\" />)}\n </div>\n\n <div className=\"space-y-3\">\n <Pulse className=\"h-12 rounded-lg\" />\n <Pulse className=\"h-12 rounded-lg\" />\n <Pulse className=\"h-12 rounded-lg\" />\n </div>\n </div>\n </main>\n </div>\n );\n}\n\nfunction Pulse({ className }: { className: string }) {\n return (\n <motion.div\n className={className}\n style={{ background: \"var(--color-bg-ui)\" }}\n animate={{ opacity: [0.5, 1, 0.5] }}\n transition={{ duration: 1.4, repeat: Infinity, ease: \"easeInOut\" }}\n />\n );\n}\n"
|
|
27
|
-
}
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
|
|
3
|
-
"file": "components/portal/notifications-dropdown.tsx",
|
|
4
|
-
"sha256": "b242bfde4892a808a4fe716285cf8bf3a3ac8f0c614ab6830510c0a46eeb10c3",
|
|
5
|
-
"imports": [
|
|
6
|
-
"next/link",
|
|
7
|
-
"react",
|
|
8
|
-
"lucide-react",
|
|
9
|
-
"@/components/ui/button"
|
|
10
|
-
],
|
|
11
|
-
"tokens": [
|
|
12
|
-
"--color-bg-brand-muted",
|
|
13
|
-
"--color-bg-default",
|
|
14
|
-
"--color-bg-subtle",
|
|
15
|
-
"--color-border-default",
|
|
16
|
-
"--color-border-muted",
|
|
17
|
-
"--color-status-info-bg",
|
|
18
|
-
"--color-status-info-text",
|
|
19
|
-
"--color-status-success-bg",
|
|
20
|
-
"--color-status-success-text",
|
|
21
|
-
"--color-status-warning-bg",
|
|
22
|
-
"--color-status-warning-text",
|
|
23
|
-
"--color-text-brand",
|
|
24
|
-
"--color-text-muted",
|
|
25
|
-
"--color-text-primary",
|
|
26
|
-
"--color-text-secondary",
|
|
27
|
-
"--gray-0",
|
|
28
|
-
"--red-500",
|
|
29
|
-
"--teal-500"
|
|
30
|
-
],
|
|
31
|
-
"fragments": [
|
|
32
|
-
{
|
|
33
|
-
"id": "NotificationsDropdown",
|
|
34
|
-
"line": 27,
|
|
35
|
-
"code": "export function NotificationsDropdown({ items = [] as Notif[], notifHref = \"/notifications\" }: { items?: Notif[]; notifHref?: string }) {\n const [open, setOpen] = useState(false);\n const wrapperRef = useRef<HTMLDivElement>(null);\n const unread = items.filter((n) => n.unread).length;\n\n useEffect(() => {\n if (!open) return;\n const handler = (e: MouseEvent) => {\n if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) setOpen(false);\n };\n document.addEventListener(\"mousedown\", handler);\n return () => document.removeEventListener(\"mousedown\", handler);\n }, [open]);\n\n return (\n <div ref={wrapperRef} className=\"relative\">\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={`Notifications${unread ? ` (${unread} unread)` : \"\"}`}\n onClick={() => setOpen((s) => !s)}\n className=\"relative\"\n style={{ color: \"var(--color-text-secondary)\" }}\n >\n <Bell size={15} strokeWidth={1.8} />\n {unread > 0 && (\n <span\n className=\"absolute top-0.5 right-0.5 min-w-[14px] h-[14px] rounded-full text-[9px] font-semibold flex items-center justify-center px-1\"\n style={{ background: \"var(--red-500)\", color: \"var(--gray-0)\" }}\n >\n {unread > 9 ? \"9+\" : unread}\n </span>\n )}\n </Button>\n\n {open && (\n <div\n className=\"absolute right-0 mt-2 w-80 sm:w-96 rounded-xl shadow-xl z-50 overflow-hidden\"\n style={{ background: \"var(--color-bg-default)\", border: \"1px solid var(--color-border-default)\" }}\n >\n <div className=\"px-4 py-3 flex items-center justify-between border-b\" style={{ borderColor: \"var(--color-border-muted)\" }}>\n <div>\n <div className=\"text-[13px] font-semibold\">Notifications</div>\n <div className=\"text-[11px]\" style={{ color: \"var(--color-text-muted)\" }}>\n {unread === 0 ? \"All caught up\" : `${unread} unread`}\n </div>\n </div>\n <Button variant=\"ghost\" size=\"icon-sm\" aria-label=\"Close\" onClick={() => setOpen(false)}>\n <X size={13} />\n </Button>\n </div>\n\n {items.length === 0 ? (\n <div className=\"px-6 py-10 text-center\">\n <div className=\"w-10 h-10 rounded-full flex items-center justify-center mx-auto mb-3\" style={{ background: \"var(--color-bg-subtle)\", color: \"var(--color-text-muted)\" }}>\n <Bell size={16} strokeWidth={1.6} />\n </div>\n <p className=\"text-[12px]\" style={{ color: \"var(--color-text-secondary)\" }}>You're all caught up.</p>\n </div>\n ) : (\n <div className=\"max-h-[420px] overflow-y-auto\">\n {items.slice(0, 6).map((n) => {\n const cfg = ICON_BY_KIND[n.kind];\n const Icon = cfg.icon;\n const inner = (\n <>\n <div className=\"w-8 h-8 rounded-md flex items-center justify-center shrink-0 mt-0.5\" style={{ background: cfg.bg, color: cfg.fg }}>\n <Icon size={14} strokeWidth={1.8} />\n </div>\n <div className=\"flex-1 min-w-0\">\n <div className=\"text-[12.5px] font-medium leading-snug\" style={{ color: \"var(--color-text-primary)\" }}>{n.title}</div>\n {n.body && <div className=\"text-[11px] mt-0.5 line-clamp-2\" style={{ color: \"var(--color-text-secondary)\" }}>{n.body}</div>}\n <div className=\"text-[10px] mt-1\" style={{ color: \"var(--color-text-muted)\" }}>{n.time}</div>\n </div>\n {n.unread && <span className=\"w-1.5 h-1.5 rounded-full mt-2 shrink-0\" style={{ background: \"var(--teal-500)\" }} aria-label=\"Unread\" />}\n </>\n );\n const klass = \"flex items-start gap-3 px-4 py-3 transition-colors hover:bg-[var(--color-bg-subtle)]\";\n if (n.href) {\n return (\n <Link key={n.id} href={n.href} className={klass} onClick={() => setOpen(false)}>\n {inner}\n </Link>\n );\n }\n return <div key={n.id} className={klass}>{inner}</div>;\n })}\n </div>\n )}\n\n <div className=\"px-3 py-2.5 border-t flex items-center justify-between\" style={{ borderColor: \"var(--color-border-muted)\" }}>\n <button\n type=\"button\"\n className=\"text-[11px] font-medium transition-opacity hover:opacity-70\"\n style={{ color: \"var(--color-text-secondary)\" }}\n onClick={() => setOpen(false)}\n >\n Mark all read\n </button>\n <Link\n href={notifHref}\n className=\"text-[11px] font-medium hover:underline underline-offset-4\"\n style={{ color: \"var(--color-text-brand)\" }}\n onClick={() => setOpen(false)}\n >\n View all →\n </Link>\n </div>\n </div>\n )}\n </div>\n );\n}"
|
|
36
|
-
}
|
|
37
|
-
],
|
|
38
|
-
"content": "\"use client\";\n\nimport Link from \"next/link\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { Bell, X, CheckCircle2, AlertTriangle, Mail, Calendar, MessageSquare } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\n\nexport type NotifKind = \"session\" | \"message\" | \"system\" | \"alert\";\n\nexport interface Notif {\n id: string;\n kind: NotifKind;\n title: string;\n body?: string;\n time: string;\n unread: boolean;\n href?: string;\n}\n\nconst ICON_BY_KIND: Record<NotifKind, { icon: typeof Bell; bg: string; fg: string }> = {\n session: { icon: Calendar, bg: \"var(--color-bg-brand-muted)\", fg: \"var(--color-text-brand)\" },\n message: { icon: MessageSquare, bg: \"var(--color-status-info-bg)\", fg: \"var(--color-status-info-text)\" },\n system: { icon: CheckCircle2, bg: \"var(--color-status-success-bg)\", fg: \"var(--color-status-success-text)\" },\n alert: { icon: AlertTriangle, bg: \"var(--color-status-warning-bg)\", fg: \"var(--color-status-warning-text)\" },\n};\n\nexport function NotificationsDropdown({ items = [] as Notif[], notifHref = \"/notifications\" }: { items?: Notif[]; notifHref?: string }) {\n const [open, setOpen] = useState(false);\n const wrapperRef = useRef<HTMLDivElement>(null);\n const unread = items.filter((n) => n.unread).length;\n\n useEffect(() => {\n if (!open) return;\n const handler = (e: MouseEvent) => {\n if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) setOpen(false);\n };\n document.addEventListener(\"mousedown\", handler);\n return () => document.removeEventListener(\"mousedown\", handler);\n }, [open]);\n\n return (\n <div ref={wrapperRef} className=\"relative\">\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={`Notifications${unread ? ` (${unread} unread)` : \"\"}`}\n onClick={() => setOpen((s) => !s)}\n className=\"relative\"\n style={{ color: \"var(--color-text-secondary)\" }}\n >\n <Bell size={15} strokeWidth={1.8} />\n {unread > 0 && (\n <span\n className=\"absolute top-0.5 right-0.5 min-w-[14px] h-[14px] rounded-full text-[9px] font-semibold flex items-center justify-center px-1\"\n style={{ background: \"var(--red-500)\", color: \"var(--gray-0)\" }}\n >\n {unread > 9 ? \"9+\" : unread}\n </span>\n )}\n </Button>\n\n {open && (\n <div\n className=\"absolute right-0 mt-2 w-80 sm:w-96 rounded-xl shadow-xl z-50 overflow-hidden\"\n style={{ background: \"var(--color-bg-default)\", border: \"1px solid var(--color-border-default)\" }}\n >\n <div className=\"px-4 py-3 flex items-center justify-between border-b\" style={{ borderColor: \"var(--color-border-muted)\" }}>\n <div>\n <div className=\"text-[13px] font-semibold\">Notifications</div>\n <div className=\"text-[11px]\" style={{ color: \"var(--color-text-muted)\" }}>\n {unread === 0 ? \"All caught up\" : `${unread} unread`}\n </div>\n </div>\n <Button variant=\"ghost\" size=\"icon-sm\" aria-label=\"Close\" onClick={() => setOpen(false)}>\n <X size={13} />\n </Button>\n </div>\n\n {items.length === 0 ? (\n <div className=\"px-6 py-10 text-center\">\n <div className=\"w-10 h-10 rounded-full flex items-center justify-center mx-auto mb-3\" style={{ background: \"var(--color-bg-subtle)\", color: \"var(--color-text-muted)\" }}>\n <Bell size={16} strokeWidth={1.6} />\n </div>\n <p className=\"text-[12px]\" style={{ color: \"var(--color-text-secondary)\" }}>You're all caught up.</p>\n </div>\n ) : (\n <div className=\"max-h-[420px] overflow-y-auto\">\n {items.slice(0, 6).map((n) => {\n const cfg = ICON_BY_KIND[n.kind];\n const Icon = cfg.icon;\n const inner = (\n <>\n <div className=\"w-8 h-8 rounded-md flex items-center justify-center shrink-0 mt-0.5\" style={{ background: cfg.bg, color: cfg.fg }}>\n <Icon size={14} strokeWidth={1.8} />\n </div>\n <div className=\"flex-1 min-w-0\">\n <div className=\"text-[12.5px] font-medium leading-snug\" style={{ color: \"var(--color-text-primary)\" }}>{n.title}</div>\n {n.body && <div className=\"text-[11px] mt-0.5 line-clamp-2\" style={{ color: \"var(--color-text-secondary)\" }}>{n.body}</div>}\n <div className=\"text-[10px] mt-1\" style={{ color: \"var(--color-text-muted)\" }}>{n.time}</div>\n </div>\n {n.unread && <span className=\"w-1.5 h-1.5 rounded-full mt-2 shrink-0\" style={{ background: \"var(--teal-500)\" }} aria-label=\"Unread\" />}\n </>\n );\n const klass = \"flex items-start gap-3 px-4 py-3 transition-colors hover:bg-[var(--color-bg-subtle)]\";\n if (n.href) {\n return (\n <Link key={n.id} href={n.href} className={klass} onClick={() => setOpen(false)}>\n {inner}\n </Link>\n );\n }\n return <div key={n.id} className={klass}>{inner}</div>;\n })}\n </div>\n )}\n\n <div className=\"px-3 py-2.5 border-t flex items-center justify-between\" style={{ borderColor: \"var(--color-border-muted)\" }}>\n <button\n type=\"button\"\n className=\"text-[11px] font-medium transition-opacity hover:opacity-70\"\n style={{ color: \"var(--color-text-secondary)\" }}\n onClick={() => setOpen(false)}\n >\n Mark all read\n </button>\n <Link\n href={notifHref}\n className=\"text-[11px] font-medium hover:underline underline-offset-4\"\n style={{ color: \"var(--color-text-brand)\" }}\n onClick={() => setOpen(false)}\n >\n View all →\n </Link>\n </div>\n </div>\n )}\n </div>\n );\n}\n"
|
|
39
|
-
}
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
|
|
3
|
-
"file": "components/portal/portal-shell.tsx",
|
|
4
|
-
"sha256": "cf3ef465c150b70e6c7f1b1b25434bb70261af0c2329cb065d6ff99ec28d1d91",
|
|
5
|
-
"imports": [
|
|
6
|
-
"next/link",
|
|
7
|
-
"next/navigation",
|
|
8
|
-
"react",
|
|
9
|
-
"framer-motion",
|
|
10
|
-
"lucide-react",
|
|
11
|
-
"@/components/ui/button",
|
|
12
|
-
"@/components/design-system/theme-toggle",
|
|
13
|
-
"@/components/portal/notifications-dropdown",
|
|
14
|
-
"@/components/portal/user-menu"
|
|
15
|
-
],
|
|
16
|
-
"tokens": [
|
|
17
|
-
"--color-bg-brand-muted",
|
|
18
|
-
"--color-bg-default",
|
|
19
|
-
"--color-bg-subtle",
|
|
20
|
-
"--color-border-default",
|
|
21
|
-
"--color-text-brand",
|
|
22
|
-
"--color-text-muted",
|
|
23
|
-
"--color-text-primary",
|
|
24
|
-
"--color-text-secondary",
|
|
25
|
-
"--gray-950",
|
|
26
|
-
"--teal-500"
|
|
27
|
-
],
|
|
28
|
-
"fragments": [
|
|
29
|
-
{
|
|
30
|
-
"id": "PortalShell",
|
|
31
|
-
"line": 78,
|
|
32
|
-
"code": "export function PortalShell({ kind, account, notifications, children }: PortalShellProps) {\n const pathname = usePathname();\n const nav = NAV[kind];\n const user = account ?? SAMPLE_USERS[kind];\n const notifs = notifications ?? SAMPLE_NOTIFS[kind];\n\n // Desktop collapsed (icon-only) + mobile drawer open\n const [collapsed, setCollapsed] = useState(false);\n const [mobileOpen, setMobileOpen] = useState(false);\n\n // Persist collapsed state\n useEffect(() => {\n try {\n const saved = localStorage.getItem(\"mentix.sidebar.collapsed\");\n if (saved === \"1\") setCollapsed(true);\n } catch {}\n }, []);\n const toggleCollapsed = () => {\n setCollapsed((c) => {\n const next = !c;\n try { localStorage.setItem(\"mentix.sidebar.collapsed\", next ? \"1\" : \"0\"); } catch {}\n return next;\n });\n };\n\n // Close mobile drawer on route change\n useEffect(() => { setMobileOpen(false); }, [pathname]);\n\n const sidebarWidth = collapsed ? \"w-[60px]\" : \"w-60\";\n const mainMargin = collapsed ? \"lg:ml-[60px]\" : \"lg:ml-60\";\n\n return (\n <div className=\"min-h-screen flex\" style={{ background: \"var(--color-bg-default)\", color: \"var(--color-text-primary)\" }}>\n {/* Mobile overlay */}\n {mobileOpen && (\n <button\n aria-label=\"Close menu\"\n className=\"fixed inset-0 z-30 lg:hidden\"\n style={{ background: \"color-mix(in srgb, var(--gray-950) 50%, transparent)\" }}\n onClick={() => setMobileOpen(false)}\n />\n )}\n\n {/* Sidebar */}\n <aside\n className={`fixed top-0 left-0 h-screen ${sidebarWidth} flex flex-col border-r z-40 transition-all duration-200\n ${mobileOpen ? \"translate-x-0\" : \"-translate-x-full\"} lg:translate-x-0`}\n style={{ background: \"var(--color-bg-subtle)\", borderColor: \"var(--color-border-default)\" }}\n >\n <div className={`h-14 flex items-center justify-between border-b shrink-0 ${collapsed ? \"px-2.5\" : \"px-5\"}`} style={{ borderColor: \"var(--color-border-default)\" }}>\n {!collapsed ? (\n <>\n <Link href=\"/marketing\" className=\"flex items-center gap-2.5 group min-w-0\">\n <img src=\"/mentix-logo.png\" alt=\"\" style={{ height: 22, width: 22 }} />\n <span className=\"text-[13px] font-semibold tracking-tight truncate\">\n Mentix <span style={{ color: \"var(--color-text-brand)\" }}>{kind === \"sponsor\" ? \"Sponsor\" : kind === \"mentor\" ? \"Mentor\" : \"Mentee\"}</span>\n </span>\n </Link>\n <Button variant=\"ghost\" size=\"icon-sm\" onClick={toggleCollapsed} aria-label=\"Collapse sidebar\" className=\"hidden lg:inline-flex\">\n <PanelLeftClose size={14} strokeWidth={1.7} />\n </Button>\n </>\n ) : (\n <button\n type=\"button\"\n onClick={toggleCollapsed}\n aria-label=\"Expand sidebar\"\n title=\"Expand sidebar\"\n className=\"mx-auto flex items-center justify-center w-9 h-9 rounded-md transition-colors hover:bg-[var(--color-bg-default)]\"\n >\n <img src=\"/mentix-logo.png\" alt=\"Mentix\" style={{ height: 22, width: 22 }} />\n </button>\n )}\n </div>\n\n <nav className={`flex-1 overflow-y-auto py-4 space-y-0.5 ${collapsed ? \"px-2\" : \"px-3\"}`}>\n {nav.map((item) => {\n const Icon = item.icon;\n const active = pathname === item.href;\n return (\n <Link\n key={item.label}\n href={item.href}\n className={`flex items-center rounded-md text-[13px] transition-colors ${collapsed ? \"justify-center w-10 h-10\" : \"gap-2.5 px-2.5 py-2\"}`}\n style={{\n color: active ? \"var(--color-text-brand)\" : \"var(--color-text-secondary)\",\n background: active ? \"var(--color-bg-brand-muted)\" : \"transparent\",\n fontWeight: active ? 500 : 400,\n }}\n title={collapsed ? item.label : undefined}\n >\n <Icon size={15} strokeWidth={1.7} />\n {!collapsed && <span className=\"truncate\">{item.label}</span>}\n </Link>\n );\n })}\n </nav>\n\n {/* Account chip (expanded only) */}\n {!collapsed && (\n <div className=\"px-3 py-3 border-t\" style={{ borderColor: \"var(--color-border-default)\" }}>\n <Link\n href={user.profileHref ?? \"#\"}\n className=\"w-full flex items-center gap-2.5 px-2 py-2 rounded-md transition-colors hover:bg-[var(--color-bg-default)]\"\n >\n <div className=\"w-7 h-7 rounded-full flex items-center justify-center text-[10px] font-semibold shrink-0\" style={{ background: \"var(--teal-500)\", color: \"var(--gray-950)\" }}>\n {user.initials}\n </div>\n <div className=\"flex-1 min-w-0 text-left\">\n <div className=\"text-[12px] font-medium truncate\">{user.name}</div>\n <div className=\"text-[10px] truncate\" style={{ color: \"var(--color-text-muted)\" }}>{user.subtitle ?? `${user.role ?? \"\"}${user.id ? ` · ${user.id}` : \"\"}`}</div>\n </div>\n </Link>\n </div>\n )}\n </aside>\n\n {/* Main */}\n <main className={`flex-1 min-w-0 min-h-screen ${mainMargin} transition-[margin] duration-200`}>\n <header\n className=\"h-14 sticky top-0 z-20 flex items-center justify-between px-4 sm:px-6 lg:px-8 border-b\"\n style={{ background: \"var(--color-bg-default)\", borderColor: \"var(--color-border-default)\" }}\n >\n <div className=\"flex items-center gap-2\">\n {/* Mobile hamburger */}\n <Button variant=\"ghost\" size=\"icon-sm\" onClick={() => setMobileOpen(true)} aria-label=\"Open menu\" className=\"lg:hidden\">\n <Menu size={16} strokeWidth={1.8} />\n </Button>\n {/* Desktop expand button (when collapsed) */}\n {collapsed && (\n <Button variant=\"ghost\" size=\"icon-sm\" onClick={toggleCollapsed} aria-label=\"Expand sidebar\" className=\"hidden lg:inline-flex\">\n <PanelLeftOpen size={16} strokeWidth={1.8} />\n </Button>\n )}\n <div className=\"hidden sm:flex items-center gap-3 text-[12px]\" style={{ color: \"var(--color-text-muted)\" }}>\n <span style={{ color: \"var(--color-text-secondary)\" }}>{KIND_LABEL[kind]}</span>\n </div>\n </div>\n\n <div className=\"flex items-center gap-1.5\">\n <ThemeToggle />\n <NotificationsDropdown items={notifs} notifHref={`/portal/${kind}/notifications`} />\n <UserMenu user={user} />\n </div>\n </header>\n\n <AnimatePresence mode=\"wait\">\n <motion.div\n key={pathname}\n initial={{ opacity: 0, y: 8 }}\n animate={{ opacity: 1, y: 0 }}\n exit={{ opacity: 0, y: -4 }}\n transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}\n className=\"px-4 sm:px-6 lg:px-8 py-6 lg:py-8\"\n >\n {children}\n </motion.div>\n </AnimatePresence>\n </main>\n </div>\n );\n}"
|
|
33
|
-
}
|
|
34
|
-
],
|
|
35
|
-
"content": "\"use client\";\n\nimport Link from \"next/link\";\nimport { usePathname } from \"next/navigation\";\nimport { ReactNode, useEffect, useState } from \"react\";\nimport { motion, AnimatePresence } from \"framer-motion\";\nimport {\n LayoutDashboard, Users, Receipt, Calendar, MessageSquare, Wallet, Tv,\n Menu, X, PanelLeftClose, PanelLeftOpen,\n} from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { ThemeToggle } from \"@/components/design-system/theme-toggle\";\nimport { NotificationsDropdown, type Notif } from \"@/components/portal/notifications-dropdown\";\nimport { UserMenu, type UserMenuItem } from \"@/components/portal/user-menu\";\n\ntype PortalKind = \"sponsor\" | \"trainee\" | \"mentor\";\n\nconst NAV: Record<PortalKind, { label: string; href: string; icon: typeof LayoutDashboard }[]> = {\n sponsor: [\n { label: \"Overview\", href: \"/portal/sponsor\", icon: LayoutDashboard },\n { label: \"Trainees\", href: \"/portal/sponsor/trainees\", icon: Users },\n { label: \"Sessions\", href: \"/portal/sponsor/sessions\", icon: Calendar },\n { label: \"Billing\", href: \"/portal/sponsor/billing\", icon: Receipt },\n { label: \"Mentix TV\", href: \"/portal/tv\", icon: Tv },\n ],\n trainee: [\n { label: \"Dashboard\", href: \"/portal/trainee\", icon: LayoutDashboard },\n { label: \"Sessions\", href: \"/portal/trainee/sessions\", icon: Calendar },\n { label: \"Credits\", href: \"/portal/trainee/credits\", icon: Wallet },\n { label: \"Messages\", href: \"/portal/trainee/messages\", icon: MessageSquare },\n { label: \"Mentix TV\", href: \"/portal/tv\", icon: Tv },\n ],\n mentor: [\n { label: \"Dashboard\", href: \"/portal/mentor\", icon: LayoutDashboard },\n { label: \"Requests\", href: \"/portal/mentor/requests\", icon: Calendar },\n { label: \"Messages\", href: \"/portal/mentor/messages\", icon: MessageSquare },\n { label: \"Mentix TV\", href: \"/portal/tv\", icon: Tv },\n ],\n};\n\nconst KIND_LABEL: Record<PortalKind, string> = {\n sponsor: \"Sponsor Portal\",\n trainee: \"Mentee Portal\",\n mentor: \"Mentor Portal\",\n};\n\nconst SAMPLE_NOTIFS: Record<PortalKind, Notif[]> = {\n trainee: [\n { id: \"n1\", kind: \"session\", title: \"Prof. Amyn Haji accepted your session\", body: \"ESD · 26 May at 10:00\", time: \"12 min ago\", unread: true, href: \"/portal/trainee/sessions/req-001\" },\n { id: \"n2\", kind: \"message\", title: \"New message from Dr. Hiroshi Yamamoto\", body: \"Could you upload the manometry tracing?\", time: \"1 h ago\", unread: true, href: \"/portal/trainee/sessions/req-004\" },\n { id: \"n3\", kind: \"system\", title: \"Credits allocated\", body: \"Olympus Medical UK added 3 credits to your account.\", time: \"Yesterday\", unread: false, href: \"/portal/trainee/credits\" },\n { id: \"n4\", kind: \"alert\", title: \"Connectivity pre-check due in 24h\", body: \"ESD · 27 May at 14:30\", time: \"Yesterday\", unread: false, href: \"/portal/trainee/sessions/req-002/precheck\" },\n ],\n mentor: [\n { id: \"n1\", kind: \"session\", title: \"New request: ESD by Dr. Hannah Liu\", body: \"Specific to you · 26 May\", time: \"5 min ago\", unread: true, href: \"/portal/mentor/requests/req-001\" },\n { id: \"n2\", kind: \"alert\", title: \"Assessment overdue (24h left)\", body: \"Yuki Tanaka · POEM\", time: \"2 h ago\", unread: true, href: \"/portal/mentor/sessions/req-004/assess\" },\n { id: \"n3\", kind: \"message\", title: \"Trainee uploaded HRM tracing\", body: \"Session req-004\", time: \"3 h ago\", unread: false },\n ],\n sponsor: [\n { id: \"n1\", kind: \"system\", title: \"Session closed: Dr. Sarah Whitfield (ESD)\", body: \"1 credit consumed · 2h 45m\", time: \"30 min ago\", unread: true, href: \"/portal/sponsor/sessions\" },\n { id: \"n2\", kind: \"alert\", title: \"Credits running low\", body: \"4 of 20 remaining\", time: \"Today\", unread: true, href: \"/portal/sponsor\" },\n ],\n};\n\nconst SAMPLE_USERS: Record<PortalKind, UserMenuItem> = {\n trainee: { name: \"Dr. Hannah Liu\", initials: \"HL\", role: \"Trainee\", id: \"MX-87A37E\", email: \"h.liu@ucl.ac.uk\", profileHref: \"/portal/trainee/profile\", settingsHref: \"/portal/trainee/settings\" },\n mentor: { name: \"Prof. Amyn Haji\", initials: \"AH\", role: \"Mentor\", id: \"MX-2C9F1B\", email: \"a.haji@kch.nhs.uk\", profileHref: \"/portal/mentor/profile\", settingsHref: \"/portal/mentor/settings\" },\n sponsor: { name: \"Helen Chen\", initials: \"HC\", role: \"Sponsor\", id: \"MX-44D8E2\", email: \"h.chen@olympus.co.uk\", profileHref: \"/portal/sponsor/profile\", settingsHref: \"/portal/sponsor/settings\" },\n};\n\ninterface PortalShellProps {\n kind: PortalKind;\n account?: UserMenuItem;\n notifications?: Notif[];\n children: ReactNode;\n}\n\nexport function PortalShell({ kind, account, notifications, children }: PortalShellProps) {\n const pathname = usePathname();\n const nav = NAV[kind];\n const user = account ?? SAMPLE_USERS[kind];\n const notifs = notifications ?? SAMPLE_NOTIFS[kind];\n\n // Desktop collapsed (icon-only) + mobile drawer open\n const [collapsed, setCollapsed] = useState(false);\n const [mobileOpen, setMobileOpen] = useState(false);\n\n // Persist collapsed state\n useEffect(() => {\n try {\n const saved = localStorage.getItem(\"mentix.sidebar.collapsed\");\n if (saved === \"1\") setCollapsed(true);\n } catch {}\n }, []);\n const toggleCollapsed = () => {\n setCollapsed((c) => {\n const next = !c;\n try { localStorage.setItem(\"mentix.sidebar.collapsed\", next ? \"1\" : \"0\"); } catch {}\n return next;\n });\n };\n\n // Close mobile drawer on route change\n useEffect(() => { setMobileOpen(false); }, [pathname]);\n\n const sidebarWidth = collapsed ? \"w-[60px]\" : \"w-60\";\n const mainMargin = collapsed ? \"lg:ml-[60px]\" : \"lg:ml-60\";\n\n return (\n <div className=\"min-h-screen flex\" style={{ background: \"var(--color-bg-default)\", color: \"var(--color-text-primary)\" }}>\n {/* Mobile overlay */}\n {mobileOpen && (\n <button\n aria-label=\"Close menu\"\n className=\"fixed inset-0 z-30 lg:hidden\"\n style={{ background: \"color-mix(in srgb, var(--gray-950) 50%, transparent)\" }}\n onClick={() => setMobileOpen(false)}\n />\n )}\n\n {/* Sidebar */}\n <aside\n className={`fixed top-0 left-0 h-screen ${sidebarWidth} flex flex-col border-r z-40 transition-all duration-200\n ${mobileOpen ? \"translate-x-0\" : \"-translate-x-full\"} lg:translate-x-0`}\n style={{ background: \"var(--color-bg-subtle)\", borderColor: \"var(--color-border-default)\" }}\n >\n <div className={`h-14 flex items-center justify-between border-b shrink-0 ${collapsed ? \"px-2.5\" : \"px-5\"}`} style={{ borderColor: \"var(--color-border-default)\" }}>\n {!collapsed ? (\n <>\n <Link href=\"/marketing\" className=\"flex items-center gap-2.5 group min-w-0\">\n <img src=\"/mentix-logo.png\" alt=\"\" style={{ height: 22, width: 22 }} />\n <span className=\"text-[13px] font-semibold tracking-tight truncate\">\n Mentix <span style={{ color: \"var(--color-text-brand)\" }}>{kind === \"sponsor\" ? \"Sponsor\" : kind === \"mentor\" ? \"Mentor\" : \"Mentee\"}</span>\n </span>\n </Link>\n <Button variant=\"ghost\" size=\"icon-sm\" onClick={toggleCollapsed} aria-label=\"Collapse sidebar\" className=\"hidden lg:inline-flex\">\n <PanelLeftClose size={14} strokeWidth={1.7} />\n </Button>\n </>\n ) : (\n <button\n type=\"button\"\n onClick={toggleCollapsed}\n aria-label=\"Expand sidebar\"\n title=\"Expand sidebar\"\n className=\"mx-auto flex items-center justify-center w-9 h-9 rounded-md transition-colors hover:bg-[var(--color-bg-default)]\"\n >\n <img src=\"/mentix-logo.png\" alt=\"Mentix\" style={{ height: 22, width: 22 }} />\n </button>\n )}\n </div>\n\n <nav className={`flex-1 overflow-y-auto py-4 space-y-0.5 ${collapsed ? \"px-2\" : \"px-3\"}`}>\n {nav.map((item) => {\n const Icon = item.icon;\n const active = pathname === item.href;\n return (\n <Link\n key={item.label}\n href={item.href}\n className={`flex items-center rounded-md text-[13px] transition-colors ${collapsed ? \"justify-center w-10 h-10\" : \"gap-2.5 px-2.5 py-2\"}`}\n style={{\n color: active ? \"var(--color-text-brand)\" : \"var(--color-text-secondary)\",\n background: active ? \"var(--color-bg-brand-muted)\" : \"transparent\",\n fontWeight: active ? 500 : 400,\n }}\n title={collapsed ? item.label : undefined}\n >\n <Icon size={15} strokeWidth={1.7} />\n {!collapsed && <span className=\"truncate\">{item.label}</span>}\n </Link>\n );\n })}\n </nav>\n\n {/* Account chip (expanded only) */}\n {!collapsed && (\n <div className=\"px-3 py-3 border-t\" style={{ borderColor: \"var(--color-border-default)\" }}>\n <Link\n href={user.profileHref ?? \"#\"}\n className=\"w-full flex items-center gap-2.5 px-2 py-2 rounded-md transition-colors hover:bg-[var(--color-bg-default)]\"\n >\n <div className=\"w-7 h-7 rounded-full flex items-center justify-center text-[10px] font-semibold shrink-0\" style={{ background: \"var(--teal-500)\", color: \"var(--gray-950)\" }}>\n {user.initials}\n </div>\n <div className=\"flex-1 min-w-0 text-left\">\n <div className=\"text-[12px] font-medium truncate\">{user.name}</div>\n <div className=\"text-[10px] truncate\" style={{ color: \"var(--color-text-muted)\" }}>{user.subtitle ?? `${user.role ?? \"\"}${user.id ? ` · ${user.id}` : \"\"}`}</div>\n </div>\n </Link>\n </div>\n )}\n </aside>\n\n {/* Main */}\n <main className={`flex-1 min-w-0 min-h-screen ${mainMargin} transition-[margin] duration-200`}>\n <header\n className=\"h-14 sticky top-0 z-20 flex items-center justify-between px-4 sm:px-6 lg:px-8 border-b\"\n style={{ background: \"var(--color-bg-default)\", borderColor: \"var(--color-border-default)\" }}\n >\n <div className=\"flex items-center gap-2\">\n {/* Mobile hamburger */}\n <Button variant=\"ghost\" size=\"icon-sm\" onClick={() => setMobileOpen(true)} aria-label=\"Open menu\" className=\"lg:hidden\">\n <Menu size={16} strokeWidth={1.8} />\n </Button>\n {/* Desktop expand button (when collapsed) */}\n {collapsed && (\n <Button variant=\"ghost\" size=\"icon-sm\" onClick={toggleCollapsed} aria-label=\"Expand sidebar\" className=\"hidden lg:inline-flex\">\n <PanelLeftOpen size={16} strokeWidth={1.8} />\n </Button>\n )}\n <div className=\"hidden sm:flex items-center gap-3 text-[12px]\" style={{ color: \"var(--color-text-muted)\" }}>\n <span style={{ color: \"var(--color-text-secondary)\" }}>{KIND_LABEL[kind]}</span>\n </div>\n </div>\n\n <div className=\"flex items-center gap-1.5\">\n <ThemeToggle />\n <NotificationsDropdown items={notifs} notifHref={`/portal/${kind}/notifications`} />\n <UserMenu user={user} />\n </div>\n </header>\n\n <AnimatePresence mode=\"wait\">\n <motion.div\n key={pathname}\n initial={{ opacity: 0, y: 8 }}\n animate={{ opacity: 1, y: 0 }}\n exit={{ opacity: 0, y: -4 }}\n transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}\n className=\"px-4 sm:px-6 lg:px-8 py-6 lg:py-8\"\n >\n {children}\n </motion.div>\n </AnimatePresence>\n </main>\n </div>\n );\n}\n"
|
|
36
|
-
}
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
|
|
3
|
-
"file": "components/portal/profile-form.tsx",
|
|
4
|
-
"sha256": "b6156b61fe283eb2bbe88680824ff1661e6b381d5debdfae30902971ba02f4fa",
|
|
5
|
-
"imports": [
|
|
6
|
-
"react",
|
|
7
|
-
"lucide-react",
|
|
8
|
-
"@/components/ui/button",
|
|
9
|
-
"@/components/ui/input",
|
|
10
|
-
"@/components/ui/card",
|
|
11
|
-
"@/components/ui/separator",
|
|
12
|
-
"@/components/auth/auth-primitives"
|
|
13
|
-
],
|
|
14
|
-
"tokens": [
|
|
15
|
-
"--color-bg-brand-muted",
|
|
16
|
-
"--color-border-strong",
|
|
17
|
-
"--color-status-error-bg",
|
|
18
|
-
"--color-status-error-border",
|
|
19
|
-
"--color-status-error-text",
|
|
20
|
-
"--color-text-brand",
|
|
21
|
-
"--color-text-muted",
|
|
22
|
-
"--color-text-secondary"
|
|
23
|
-
],
|
|
24
|
-
"fragments": [
|
|
25
|
-
{
|
|
26
|
-
"id": "ProfileForm",
|
|
27
|
-
"line": 25,
|
|
28
|
-
"code": "export function ProfileForm({\n title, subtitle, initials, photoUrl,\n matchingTitle, matchingDescription,\n fields, initialFirstName, initialLastName, initialEmail,\n}: ProfileShellProps) {\n const [photo, setPhoto] = useState<string | null>(photoUrl ?? null);\n const [firstName, setFirstName] = useState(initialFirstName);\n const [lastName, setLastName] = useState(initialLastName);\n const [email] = useState(initialEmail);\n const [saving, setSaving] = useState(false);\n const [saved, setSaved] = useState(false);\n const [confirmDelete, setConfirmDelete] = useState(false);\n const fileRef = useRef<HTMLInputElement>(null);\n\n const handleSave = (e: React.FormEvent) => {\n e.preventDefault();\n setSaving(true);\n setSaved(false);\n setTimeout(() => {\n setSaving(false);\n setSaved(true);\n }, 700);\n };\n\n return (\n <>\n <div className=\"mb-6\">\n <h1 className=\"text-[1.75rem] font-semibold tracking-tight mb-1\" style={{ letterSpacing: \"-0.025em\" }}>\n {title}\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>{subtitle}</p>\n </div>\n\n <form onSubmit={handleSave} className=\"space-y-5 max-w-3xl\">\n {/* Photo */}\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Profile photo</CardTitle>\n </CardHeader>\n <CardContent>\n <div className=\"rounded-2xl border border-dashed flex flex-col items-center justify-center p-6 gap-3\" style={{ borderColor: \"var(--color-border-strong)\" }}>\n <div\n className=\"w-20 h-20 rounded-full flex items-center justify-center text-[22px] font-semibold\"\n style={{\n background: photo ? `url(${photo}) center/cover` : \"var(--color-bg-brand-muted)\",\n color: \"var(--color-text-brand)\",\n }}\n >\n {!photo && initials}\n </div>\n <p className=\"text-[12px]\" style={{ color: \"var(--color-text-secondary)\" }}>This image appears in your profile and menus across Mentix.</p>\n <Button type=\"button\" variant=\"brand\" size=\"sm\" onClick={() => fileRef.current?.click()}>\n <Camera size={12} /> Upload photo\n </Button>\n <p className=\"text-[11px] inline-flex items-center gap-1.5\" style={{ color: \"var(--color-text-muted)\" }}>\n <Upload size={11} /> Drag and drop here, or use the button · JPG, PNG, or WEBP · max 5MB\n </p>\n <input\n ref={fileRef}\n type=\"file\"\n accept=\"image/png,image/jpeg,image/webp\"\n className=\"sr-only\"\n onChange={(e) => {\n const f = e.target.files?.[0];\n if (f) setPhoto(URL.createObjectURL(f));\n }}\n />\n </div>\n </CardContent>\n </Card>\n\n {/* Matching profile */}\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">{matchingTitle}</CardTitle>\n <CardDescription>{matchingDescription}</CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n {saved && (\n <InlineAlert variant=\"success\">\n Profile saved. You can update your details below anytime.\n </InlineAlert>\n )}\n\n <div className=\"grid grid-cols-1 sm:grid-cols-2 gap-3\">\n <Field label=\"First name\" htmlFor=\"fn\">\n <Input id=\"fn\" value={firstName} onChange={(e) => setFirstName(e.target.value)} className=\"h-10\" />\n </Field>\n <Field label=\"Second name\" htmlFor=\"ln\">\n <Input id=\"ln\" value={lastName} onChange={(e) => setLastName(e.target.value)} className=\"h-10\" />\n </Field>\n </div>\n\n <Separator />\n\n <Field label=\"Email\" htmlFor=\"em\">\n <div className=\"flex items-center gap-2\">\n <Input id=\"em\" value={email} disabled className=\"h-10 flex-1\" />\n <Button type=\"button\" variant=\"outline\">\n <Mail size={12} /> Change email\n </Button>\n </div>\n </Field>\n\n {fields}\n\n <p className=\"text-[11px]\" style={{ color: \"var(--color-text-muted)\" }}>\n Draft answers are kept in this browser until you save. You can return from onboarding anytime.\n </p>\n\n <div>\n <Button type=\"submit\" variant=\"brand\" disabled={saving}>\n {saving ? <><Spinner className=\"w-3.5 h-3.5 mr-1\" /> Saving…</> : \"Save profile changes\"}\n </Button>\n </div>\n </CardContent>\n </Card>\n\n {/* Security */}\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Password</CardTitle>\n <CardDescription>You may be asked to confirm your identity before updating password or security settings.</CardDescription>\n </CardHeader>\n <CardContent>\n <Button type=\"button\" variant=\"outline\">\n <KeyRound size={13} /> Update password\n </Button>\n </CardContent>\n </Card>\n\n {/* Danger zone */}\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\" style={{ color: \"var(--color-status-error-text)\" }}>Delete account</CardTitle>\n <CardDescription>This action is permanent. All your sessions, recordings, and competency record will be removed.</CardDescription>\n </CardHeader>\n <CardContent>\n {!confirmDelete ? (\n <Button type=\"button\" variant=\"destructive\" onClick={() => setConfirmDelete(true)}>\n <Trash2 size={13} /> Delete account\n </Button>\n ) : (\n <div className=\"rounded-lg border p-4\" style={{ borderColor: \"var(--color-status-error-border)\", background: \"var(--color-status-error-bg)\" }}>\n <div className=\"flex items-start gap-3 mb-3\">\n <AlertTriangle size={16} strokeWidth={1.8} style={{ color: \"var(--color-status-error-text)\" }} />\n <p className=\"text-[13px]\" style={{ color: \"var(--color-status-error-text)\" }}>\n Are you sure? This cannot be undone. Type <strong>DELETE</strong> in the field below to confirm.\n </p>\n </div>\n <div className=\"flex items-center gap-2\">\n <Input placeholder=\"Type DELETE to confirm\" className=\"h-9\" />\n <Button type=\"button\" variant=\"destructive\">Permanently delete</Button>\n <Button type=\"button\" variant=\"outline\" onClick={() => setConfirmDelete(false)}>Cancel</Button>\n </div>\n </div>\n )}\n </CardContent>\n </Card>\n </form>\n </>\n );\n}"
|
|
29
|
-
}
|
|
30
|
-
],
|
|
31
|
-
"content": "\"use client\";\n\nimport { ReactNode, useState, useRef } from \"react\";\nimport { Camera, Upload, Trash2, KeyRound, Mail, AlertTriangle } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Card, CardHeader, CardTitle, CardDescription, CardContent } from \"@/components/ui/card\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { Field, InlineAlert, Spinner } from \"@/components/auth/auth-primitives\";\n\ninterface ProfileShellProps {\n title: string;\n subtitle: string;\n initials: string;\n photoUrl?: string;\n matchingTitle: string;\n matchingDescription: string;\n /** Mentor-matching form fields (or role-specific fields) */\n fields: ReactNode;\n initialFirstName: string;\n initialLastName: string;\n initialEmail: string;\n}\n\nexport function ProfileForm({\n title, subtitle, initials, photoUrl,\n matchingTitle, matchingDescription,\n fields, initialFirstName, initialLastName, initialEmail,\n}: ProfileShellProps) {\n const [photo, setPhoto] = useState<string | null>(photoUrl ?? null);\n const [firstName, setFirstName] = useState(initialFirstName);\n const [lastName, setLastName] = useState(initialLastName);\n const [email] = useState(initialEmail);\n const [saving, setSaving] = useState(false);\n const [saved, setSaved] = useState(false);\n const [confirmDelete, setConfirmDelete] = useState(false);\n const fileRef = useRef<HTMLInputElement>(null);\n\n const handleSave = (e: React.FormEvent) => {\n e.preventDefault();\n setSaving(true);\n setSaved(false);\n setTimeout(() => {\n setSaving(false);\n setSaved(true);\n }, 700);\n };\n\n return (\n <>\n <div className=\"mb-6\">\n <h1 className=\"text-[1.75rem] font-semibold tracking-tight mb-1\" style={{ letterSpacing: \"-0.025em\" }}>\n {title}\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>{subtitle}</p>\n </div>\n\n <form onSubmit={handleSave} className=\"space-y-5 max-w-3xl\">\n {/* Photo */}\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Profile photo</CardTitle>\n </CardHeader>\n <CardContent>\n <div className=\"rounded-2xl border border-dashed flex flex-col items-center justify-center p-6 gap-3\" style={{ borderColor: \"var(--color-border-strong)\" }}>\n <div\n className=\"w-20 h-20 rounded-full flex items-center justify-center text-[22px] font-semibold\"\n style={{\n background: photo ? `url(${photo}) center/cover` : \"var(--color-bg-brand-muted)\",\n color: \"var(--color-text-brand)\",\n }}\n >\n {!photo && initials}\n </div>\n <p className=\"text-[12px]\" style={{ color: \"var(--color-text-secondary)\" }}>This image appears in your profile and menus across Mentix.</p>\n <Button type=\"button\" variant=\"brand\" size=\"sm\" onClick={() => fileRef.current?.click()}>\n <Camera size={12} /> Upload photo\n </Button>\n <p className=\"text-[11px] inline-flex items-center gap-1.5\" style={{ color: \"var(--color-text-muted)\" }}>\n <Upload size={11} /> Drag and drop here, or use the button · JPG, PNG, or WEBP · max 5MB\n </p>\n <input\n ref={fileRef}\n type=\"file\"\n accept=\"image/png,image/jpeg,image/webp\"\n className=\"sr-only\"\n onChange={(e) => {\n const f = e.target.files?.[0];\n if (f) setPhoto(URL.createObjectURL(f));\n }}\n />\n </div>\n </CardContent>\n </Card>\n\n {/* Matching profile */}\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">{matchingTitle}</CardTitle>\n <CardDescription>{matchingDescription}</CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n {saved && (\n <InlineAlert variant=\"success\">\n Profile saved. You can update your details below anytime.\n </InlineAlert>\n )}\n\n <div className=\"grid grid-cols-1 sm:grid-cols-2 gap-3\">\n <Field label=\"First name\" htmlFor=\"fn\">\n <Input id=\"fn\" value={firstName} onChange={(e) => setFirstName(e.target.value)} className=\"h-10\" />\n </Field>\n <Field label=\"Second name\" htmlFor=\"ln\">\n <Input id=\"ln\" value={lastName} onChange={(e) => setLastName(e.target.value)} className=\"h-10\" />\n </Field>\n </div>\n\n <Separator />\n\n <Field label=\"Email\" htmlFor=\"em\">\n <div className=\"flex items-center gap-2\">\n <Input id=\"em\" value={email} disabled className=\"h-10 flex-1\" />\n <Button type=\"button\" variant=\"outline\">\n <Mail size={12} /> Change email\n </Button>\n </div>\n </Field>\n\n {fields}\n\n <p className=\"text-[11px]\" style={{ color: \"var(--color-text-muted)\" }}>\n Draft answers are kept in this browser until you save. You can return from onboarding anytime.\n </p>\n\n <div>\n <Button type=\"submit\" variant=\"brand\" disabled={saving}>\n {saving ? <><Spinner className=\"w-3.5 h-3.5 mr-1\" /> Saving…</> : \"Save profile changes\"}\n </Button>\n </div>\n </CardContent>\n </Card>\n\n {/* Security */}\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Password</CardTitle>\n <CardDescription>You may be asked to confirm your identity before updating password or security settings.</CardDescription>\n </CardHeader>\n <CardContent>\n <Button type=\"button\" variant=\"outline\">\n <KeyRound size={13} /> Update password\n </Button>\n </CardContent>\n </Card>\n\n {/* Danger zone */}\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\" style={{ color: \"var(--color-status-error-text)\" }}>Delete account</CardTitle>\n <CardDescription>This action is permanent. All your sessions, recordings, and competency record will be removed.</CardDescription>\n </CardHeader>\n <CardContent>\n {!confirmDelete ? (\n <Button type=\"button\" variant=\"destructive\" onClick={() => setConfirmDelete(true)}>\n <Trash2 size={13} /> Delete account\n </Button>\n ) : (\n <div className=\"rounded-lg border p-4\" style={{ borderColor: \"var(--color-status-error-border)\", background: \"var(--color-status-error-bg)\" }}>\n <div className=\"flex items-start gap-3 mb-3\">\n <AlertTriangle size={16} strokeWidth={1.8} style={{ color: \"var(--color-status-error-text)\" }} />\n <p className=\"text-[13px]\" style={{ color: \"var(--color-status-error-text)\" }}>\n Are you sure? This cannot be undone. Type <strong>DELETE</strong> in the field below to confirm.\n </p>\n </div>\n <div className=\"flex items-center gap-2\">\n <Input placeholder=\"Type DELETE to confirm\" className=\"h-9\" />\n <Button type=\"button\" variant=\"destructive\">Permanently delete</Button>\n <Button type=\"button\" variant=\"outline\" onClick={() => setConfirmDelete(false)}>Cancel</Button>\n </div>\n </div>\n )}\n </CardContent>\n </Card>\n </form>\n </>\n );\n}\n"
|
|
32
|
-
}
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
|
|
3
|
-
"file": "components/portal/settings-view.tsx",
|
|
4
|
-
"sha256": "48d9f031984835e7165bfbfea45d5d4c6466a50725abfe77fccc505a43b76383",
|
|
5
|
-
"imports": [
|
|
6
|
-
"next/link",
|
|
7
|
-
"react",
|
|
8
|
-
"lucide-react",
|
|
9
|
-
"@/components/ui/button",
|
|
10
|
-
"@/components/ui/card",
|
|
11
|
-
"@/components/ui/badge",
|
|
12
|
-
"@/components/ui/separator",
|
|
13
|
-
"@/components/ui/input",
|
|
14
|
-
"@/components/ui/label",
|
|
15
|
-
"@/components/ui/switch",
|
|
16
|
-
"@/components/ui/tabs"
|
|
17
|
-
],
|
|
18
|
-
"tokens": [
|
|
19
|
-
"--color-bg-brand-muted",
|
|
20
|
-
"--color-bg-default",
|
|
21
|
-
"--color-bg-subtle",
|
|
22
|
-
"--color-border-brand",
|
|
23
|
-
"--color-border-danger",
|
|
24
|
-
"--color-border-default",
|
|
25
|
-
"--color-status-error-bg",
|
|
26
|
-
"--color-status-error-text",
|
|
27
|
-
"--color-status-success-text",
|
|
28
|
-
"--color-text-brand",
|
|
29
|
-
"--color-text-muted",
|
|
30
|
-
"--color-text-primary",
|
|
31
|
-
"--color-text-secondary"
|
|
32
|
-
],
|
|
33
|
-
"fragments": [
|
|
34
|
-
{
|
|
35
|
-
"id": "SettingsView",
|
|
36
|
-
"line": 35,
|
|
37
|
-
"code": "export function SettingsView({ kind, name, email, id, notificationsHref }: SettingsViewProps) {\n return (\n <div className=\"max-w-4xl\">\n <div className=\"mb-6\">\n <p className=\"text-xs font-medium uppercase tracking-widest mb-2\" style={{ color: \"var(--color-text-brand)\" }}>\n {KIND_TITLE[kind]}\n </p>\n <h1 className=\"text-h1 mb-1\" style={{ color: \"var(--color-text-primary)\" }}>\n Settings\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n Manage your account, notifications, security, and appearance preferences.\n </p>\n </div>\n\n <Tabs defaultValue=\"account\" className=\"w-full\">\n <TabsList className=\"mb-6 flex-wrap h-auto\">\n <TabsTrigger value=\"account\">Account</TabsTrigger>\n <TabsTrigger value=\"notifications\">Notifications</TabsTrigger>\n <TabsTrigger value=\"security\">Security</TabsTrigger>\n <TabsTrigger value=\"appearance\">Appearance</TabsTrigger>\n <TabsTrigger value=\"sessions\">Devices</TabsTrigger>\n {kind !== \"admin\" && <TabsTrigger value=\"danger\">Account close</TabsTrigger>}\n </TabsList>\n\n <TabsContent value=\"account\" className=\"space-y-4\">\n <AccountSection name={name} email={email} id={id} kind={kind} />\n </TabsContent>\n\n <TabsContent value=\"notifications\" className=\"space-y-4\">\n <NotificationsSection notificationsHref={notificationsHref} />\n </TabsContent>\n\n <TabsContent value=\"security\" className=\"space-y-4\">\n <SecuritySection />\n </TabsContent>\n\n <TabsContent value=\"appearance\" className=\"space-y-4\">\n <AppearanceSection />\n </TabsContent>\n\n <TabsContent value=\"sessions\" className=\"space-y-4\">\n <DevicesSection />\n </TabsContent>\n\n {kind !== \"admin\" && (\n <TabsContent value=\"danger\" className=\"space-y-4\">\n <DangerSection />\n </TabsContent>\n )}\n </Tabs>\n </div>\n );\n}"
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
"id": "AccountSection",
|
|
41
|
-
"line": 92,
|
|
42
|
-
"code": "function AccountSection({ name, email, id, kind }: { name: string; email: string; id?: string; kind: SettingsKind }) {\n const [formName, setName] = useState(name);\n const [formEmail, setEmail] = useState(email);\n const [saved, setSaved] = useState(false);\n\n return (\n <>\n <Card>\n <CardHeader>\n <div className=\"flex items-start gap-3\">\n <div className=\"w-9 h-9 rounded-lg flex items-center justify-center shrink-0\" style={{ background: \"var(--color-bg-brand-muted)\" }}>\n <User size={18} strokeWidth={1.6} style={{ color: \"var(--color-text-brand)\" }} />\n </div>\n <div>\n <CardTitle>Account information</CardTitle>\n <CardDescription>Your basic profile details visible to mentors and admins.</CardDescription>\n </div>\n </div>\n </CardHeader>\n <CardContent className=\"space-y-5\">\n <div className=\"grid sm:grid-cols-2 gap-4\">\n <div className=\"space-y-1.5\">\n <Label htmlFor=\"name\">Full name</Label>\n <Input id=\"name\" value={formName} onChange={(e) => { setName(e.target.value); setSaved(false); }} />\n </div>\n <div className=\"space-y-1.5\">\n <Label htmlFor=\"email\">Email address</Label>\n <Input id=\"email\" type=\"email\" value={formEmail} onChange={(e) => { setEmail(e.target.value); setSaved(false); }} />\n </div>\n </div>\n\n {id && (\n <div className=\"flex items-center justify-between p-3 rounded-lg\" style={{ background: \"var(--color-bg-subtle)\" }}>\n <div>\n <p className=\"text-xs font-medium uppercase tracking-widest mb-0.5\" style={{ color: \"var(--color-text-muted)\" }}>\n Mentix ID\n </p>\n <p className=\"text-sm font-mono\" style={{ color: \"var(--color-text-primary)\" }}>{id}</p>\n </div>\n <Badge variant=\"outline\">Read-only</Badge>\n </div>\n )}\n\n <div className=\"flex items-center justify-between flex-wrap gap-3 pt-2\">\n <p className=\"text-xs\" style={{ color: \"var(--color-text-muted)\" }}>\n Changes to email require re-verification.\n </p>\n <div className=\"flex items-center gap-2\">\n {saved && (\n <span className=\"inline-flex items-center gap-1 text-xs\" style={{ color: \"var(--color-status-success-text)\" }}>\n <Check size={13} /> Saved\n </span>\n )}\n <Button variant=\"brand\" size=\"sm\" onClick={() => setSaved(true)}>Save changes</Button>\n </div>\n </div>\n </CardContent>\n </Card>\n\n <Card>\n <CardHeader>\n <CardTitle>Language & region</CardTitle>\n <CardDescription>Affects dates, times, and number formatting across the product.</CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <SettingRow\n icon={Languages}\n title=\"Display language\"\n desc=\"English (United Kingdom)\"\n action={<Button variant=\"outline\" size=\"sm\">Change</Button>}\n />\n <Separator />\n <SettingRow\n icon={Globe}\n title=\"Time zone\"\n desc=\"Europe / London · GMT+1\"\n action={<Button variant=\"outline\" size=\"sm\">Change</Button>}\n />\n </CardContent>\n </Card>\n\n {kind === \"sponsor\" && (\n <Card>\n <CardHeader>\n <CardTitle>Organization</CardTitle>\n <CardDescription>Settings inherited from your sponsor organization.</CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <SettingRow icon={User} title=\"Olympus Medical UK\" desc=\"Admin: Helen Chen · 12 active trainees\" />\n </CardContent>\n </Card>\n )}\n </>\n );\n}"
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
"id": "NotificationsSection",
|
|
46
|
-
"line": 190,
|
|
47
|
-
"code": "function NotificationsSection({ notificationsHref }: { notificationsHref?: string }) {\n const [emailDigest, setEmailDigest] = useState(true);\n const [browser, setBrowser] = useState(true);\n const [marketing, setMarketing] = useState(false);\n\n return (\n <>\n <Card>\n <CardHeader>\n <div className=\"flex items-start justify-between gap-3 flex-wrap\">\n <div>\n <CardTitle>Quick notification settings</CardTitle>\n <CardDescription>For granular per-event controls, open the full preferences page.</CardDescription>\n </div>\n {notificationsHref && (\n <Button variant=\"outline\" size=\"sm\" render={<Link href={notificationsHref} />}>\n Manage per-event <ChevronRight size={14} className=\"ml-1\" />\n </Button>\n )}\n </div>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <ToggleRow\n icon={Mail}\n title=\"Weekly email digest\"\n desc=\"A summary of your sessions, feedback, and announcements every Monday.\"\n checked={emailDigest}\n onChange={setEmailDigest}\n />\n <Separator />\n <ToggleRow\n icon={Bell}\n title=\"Browser notifications\"\n desc=\"Real-time alerts in this browser while you're signed in.\"\n checked={browser}\n onChange={setBrowser}\n />\n <Separator />\n <ToggleRow\n icon={Globe}\n title=\"Product news & marketing\"\n desc=\"Occasional updates about new features and surgical broadcasts.\"\n checked={marketing}\n onChange={setMarketing}\n />\n </CardContent>\n </Card>\n </>\n );\n}"
|
|
48
|
-
},
|
|
49
|
-
{
|
|
50
|
-
"id": "SecuritySection",
|
|
51
|
-
"line": 243,
|
|
52
|
-
"code": "function SecuritySection() {\n const [mfaEnabled, setMfa] = useState(true);\n\n return (\n <>\n <Card>\n <CardHeader>\n <div className=\"flex items-start gap-3\">\n <div className=\"w-9 h-9 rounded-lg flex items-center justify-center shrink-0\" style={{ background: \"var(--color-bg-brand-muted)\" }}>\n <ShieldCheck size={18} strokeWidth={1.6} style={{ color: \"var(--color-text-brand)\" }} />\n </div>\n <div>\n <CardTitle>Sign-in & security</CardTitle>\n <CardDescription>Protect your account with strong credentials and a second factor.</CardDescription>\n </div>\n </div>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <SettingRow\n icon={KeyRound}\n title=\"Password\"\n desc=\"Last changed 42 days ago\"\n action={<Button variant=\"outline\" size=\"sm\">Change password</Button>}\n />\n <Separator />\n <ToggleRow\n icon={Smartphone}\n title=\"Multi-factor authentication\"\n desc={mfaEnabled\n ? \"Authenticator app · Backup codes generated\"\n : \"Add a second factor to protect your account\"}\n checked={mfaEnabled}\n onChange={setMfa}\n />\n <Separator />\n <SettingRow\n icon={ShieldCheck}\n title=\"Active sessions\"\n desc=\"3 devices · Last active 4 min ago\"\n action={<Button variant=\"outline\" size=\"sm\">Review</Button>}\n />\n </CardContent>\n </Card>\n </>\n );\n}"
|
|
53
|
-
},
|
|
54
|
-
{
|
|
55
|
-
"id": "AppearanceSection",
|
|
56
|
-
"line": 292,
|
|
57
|
-
"code": "function AppearanceSection() {\n const [theme, setTheme] = useState<\"system\" | \"light\" | \"dark\">(\"system\");\n const [density, setDensity] = useState<\"comfortable\" | \"compact\">(\"comfortable\");\n\n return (\n <Card>\n <CardHeader>\n <div className=\"flex items-start gap-3\">\n <div className=\"w-9 h-9 rounded-lg flex items-center justify-center shrink-0\" style={{ background: \"var(--color-bg-brand-muted)\" }}>\n <Monitor size={18} strokeWidth={1.6} style={{ color: \"var(--color-text-brand)\" }} />\n </div>\n <div>\n <CardTitle>Appearance</CardTitle>\n <CardDescription>Theme and interface density.</CardDescription>\n </div>\n </div>\n </CardHeader>\n <CardContent className=\"space-y-5\">\n <div>\n <p className=\"text-sm font-medium mb-2\" style={{ color: \"var(--color-text-primary)\" }}>Theme</p>\n <div className=\"grid grid-cols-3 gap-2 max-w-md\">\n {([\"system\", \"light\", \"dark\"] as const).map((t) => (\n <button\n key={t}\n type=\"button\"\n onClick={() => setTheme(t)}\n className=\"rounded-lg border p-3 text-sm capitalize text-left transition-colors\"\n style={{\n borderColor: theme === t ? \"var(--color-border-brand)\" : \"var(--color-border-default)\",\n background: theme === t ? \"var(--color-bg-brand-muted)\" : \"var(--color-bg-default)\",\n color: \"var(--color-text-primary)\",\n }}\n >\n {t}\n </button>\n ))}\n </div>\n </div>\n\n <Separator />\n\n <div>\n <p className=\"text-sm font-medium mb-2\" style={{ color: \"var(--color-text-primary)\" }}>Interface density</p>\n <div className=\"grid grid-cols-2 gap-2 max-w-md\">\n {([\"comfortable\", \"compact\"] as const).map((d) => (\n <button\n key={d}\n type=\"button\"\n onClick={() => setDensity(d)}\n className=\"rounded-lg border p-3 text-sm capitalize text-left transition-colors\"\n style={{\n borderColor: density === d ? \"var(--color-border-brand)\" : \"var(--color-border-default)\",\n background: density === d ? \"var(--color-bg-brand-muted)\" : \"var(--color-bg-default)\",\n color: \"var(--color-text-primary)\",\n }}\n >\n {d}\n </button>\n ))}\n </div>\n </div>\n </CardContent>\n </Card>\n );\n}"
|
|
58
|
-
},
|
|
59
|
-
{
|
|
60
|
-
"id": "DevicesSection",
|
|
61
|
-
"line": 366,
|
|
62
|
-
"code": "function DevicesSection() {\n return (\n <Card>\n <CardHeader>\n <CardTitle>Signed-in devices</CardTitle>\n <CardDescription>\n Devices where your account is currently signed in. Sign out anywhere you don't recognise.\n </CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-3\">\n {DEVICES.map((d) => (\n <div\n key={d.name}\n className=\"flex items-center justify-between gap-3 p-3 rounded-lg border flex-wrap\"\n style={{ borderColor: \"var(--color-border-default)\" }}\n >\n <div className=\"flex items-center gap-3 min-w-0\">\n <div className=\"w-9 h-9 rounded-lg flex items-center justify-center shrink-0\" style={{ background: \"var(--color-bg-subtle)\" }}>\n <Smartphone size={16} strokeWidth={1.6} style={{ color: \"var(--color-text-secondary)\" }} />\n </div>\n <div className=\"min-w-0\">\n <div className=\"flex items-center gap-2 flex-wrap\">\n <p className=\"text-sm font-medium truncate\" style={{ color: \"var(--color-text-primary)\" }}>{d.name}</p>\n {d.current && <Badge variant=\"secondary\">This device</Badge>}\n </div>\n <p className=\"text-xs\" style={{ color: \"var(--color-text-muted)\" }}>\n {d.where} · {d.last}\n </p>\n </div>\n </div>\n {!d.current && (\n <Button variant=\"outline\" size=\"sm\">\n <LogOut size={13} className=\"mr-1.5\" /> Sign out\n </Button>\n )}\n </div>\n ))}\n </CardContent>\n </Card>\n );\n}"
|
|
63
|
-
},
|
|
64
|
-
{
|
|
65
|
-
"id": "DangerSection",
|
|
66
|
-
"line": 410,
|
|
67
|
-
"code": "function DangerSection() {\n return (\n <Card style={{ borderColor: \"var(--color-border-danger, var(--color-status-error-bg))\" }}>\n <CardHeader>\n <div className=\"flex items-start gap-3\">\n <div className=\"w-9 h-9 rounded-lg flex items-center justify-center shrink-0\" style={{ background: \"var(--color-status-error-bg)\" }}>\n <Trash2 size={18} strokeWidth={1.6} style={{ color: \"var(--color-status-error-text)\" }} />\n </div>\n <div>\n <CardTitle>Close account</CardTitle>\n <CardDescription>\n Closing your account removes access to sessions, credits, and Mentix TV history.\n You can request reactivation within 30 days.\n </CardDescription>\n </div>\n </div>\n </CardHeader>\n <CardContent>\n <Button variant=\"outline\" size=\"sm\" style={{ color: \"var(--color-status-error-text)\", borderColor: \"var(--color-status-error-text)\" }}>\n Request account closure\n </Button>\n </CardContent>\n </Card>\n );\n}"
|
|
68
|
-
},
|
|
69
|
-
{
|
|
70
|
-
"id": "SettingRow",
|
|
71
|
-
"line": 438,
|
|
72
|
-
"code": "function SettingRow({\n icon: Icon, title, desc, action,\n}: { icon: typeof User; title: string; desc: string; action?: React.ReactNode }) {\n return (\n <div className=\"flex items-center justify-between gap-3 flex-wrap\">\n <div className=\"flex items-center gap-3 min-w-0\">\n <Icon size={16} strokeWidth={1.6} style={{ color: \"var(--color-text-secondary)\" }} />\n <div className=\"min-w-0\">\n <p className=\"text-sm font-medium truncate\" style={{ color: \"var(--color-text-primary)\" }}>{title}</p>\n <p className=\"text-xs\" style={{ color: \"var(--color-text-muted)\" }}>{desc}</p>\n </div>\n </div>\n {action}\n </div>\n );\n}"
|
|
73
|
-
},
|
|
74
|
-
{
|
|
75
|
-
"id": "ToggleRow",
|
|
76
|
-
"line": 455,
|
|
77
|
-
"code": "function ToggleRow({\n icon: Icon, title, desc, checked, onChange,\n}: { icon: typeof User; title: string; desc: string; checked: boolean; onChange: (v: boolean) => void }) {\n return (\n <div className=\"flex items-center justify-between gap-3 flex-wrap\">\n <div className=\"flex items-center gap-3 min-w-0 flex-1\">\n <Icon size={16} strokeWidth={1.6} style={{ color: \"var(--color-text-secondary)\" }} />\n <div className=\"min-w-0\">\n <p className=\"text-sm font-medium\" style={{ color: \"var(--color-text-primary)\" }}>{title}</p>\n <p className=\"text-xs\" style={{ color: \"var(--color-text-muted)\" }}>{desc}</p>\n </div>\n </div>\n <Switch checked={checked} onCheckedChange={onChange} />\n </div>\n );\n}"
|
|
78
|
-
}
|
|
79
|
-
],
|
|
80
|
-
"content": "\"use client\";\n\nimport Link from \"next/link\";\nimport { useState } from \"react\";\nimport {\n User, Bell, ShieldCheck, Languages, Monitor, KeyRound,\n Smartphone, LogOut, Trash2, Globe, Mail, ChevronRight, Check,\n} from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardHeader, CardTitle, CardDescription, CardContent } from \"@/components/ui/card\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { Tabs, TabsList, TabsTrigger, TabsContent } from \"@/components/ui/tabs\";\n\nexport type SettingsKind = \"trainee\" | \"mentor\" | \"sponsor\" | \"admin\";\n\ninterface SettingsViewProps {\n kind: SettingsKind;\n name: string;\n email: string;\n id?: string;\n notificationsHref?: string;\n}\n\nconst KIND_TITLE: Record<SettingsKind, string> = {\n trainee: \"Trainee settings\",\n mentor: \"Mentor settings\",\n sponsor: \"Sponsor settings\",\n admin: \"Admin settings\",\n};\n\nexport function SettingsView({ kind, name, email, id, notificationsHref }: SettingsViewProps) {\n return (\n <div className=\"max-w-4xl\">\n <div className=\"mb-6\">\n <p className=\"text-xs font-medium uppercase tracking-widest mb-2\" style={{ color: \"var(--color-text-brand)\" }}>\n {KIND_TITLE[kind]}\n </p>\n <h1 className=\"text-h1 mb-1\" style={{ color: \"var(--color-text-primary)\" }}>\n Settings\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n Manage your account, notifications, security, and appearance preferences.\n </p>\n </div>\n\n <Tabs defaultValue=\"account\" className=\"w-full\">\n <TabsList className=\"mb-6 flex-wrap h-auto\">\n <TabsTrigger value=\"account\">Account</TabsTrigger>\n <TabsTrigger value=\"notifications\">Notifications</TabsTrigger>\n <TabsTrigger value=\"security\">Security</TabsTrigger>\n <TabsTrigger value=\"appearance\">Appearance</TabsTrigger>\n <TabsTrigger value=\"sessions\">Devices</TabsTrigger>\n {kind !== \"admin\" && <TabsTrigger value=\"danger\">Account close</TabsTrigger>}\n </TabsList>\n\n <TabsContent value=\"account\" className=\"space-y-4\">\n <AccountSection name={name} email={email} id={id} kind={kind} />\n </TabsContent>\n\n <TabsContent value=\"notifications\" className=\"space-y-4\">\n <NotificationsSection notificationsHref={notificationsHref} />\n </TabsContent>\n\n <TabsContent value=\"security\" className=\"space-y-4\">\n <SecuritySection />\n </TabsContent>\n\n <TabsContent value=\"appearance\" className=\"space-y-4\">\n <AppearanceSection />\n </TabsContent>\n\n <TabsContent value=\"sessions\" className=\"space-y-4\">\n <DevicesSection />\n </TabsContent>\n\n {kind !== \"admin\" && (\n <TabsContent value=\"danger\" className=\"space-y-4\">\n <DangerSection />\n </TabsContent>\n )}\n </Tabs>\n </div>\n );\n}\n\n/* ─────────────────────────── Account ─────────────────────────── */\n\nfunction AccountSection({ name, email, id, kind }: { name: string; email: string; id?: string; kind: SettingsKind }) {\n const [formName, setName] = useState(name);\n const [formEmail, setEmail] = useState(email);\n const [saved, setSaved] = useState(false);\n\n return (\n <>\n <Card>\n <CardHeader>\n <div className=\"flex items-start gap-3\">\n <div className=\"w-9 h-9 rounded-lg flex items-center justify-center shrink-0\" style={{ background: \"var(--color-bg-brand-muted)\" }}>\n <User size={18} strokeWidth={1.6} style={{ color: \"var(--color-text-brand)\" }} />\n </div>\n <div>\n <CardTitle>Account information</CardTitle>\n <CardDescription>Your basic profile details visible to mentors and admins.</CardDescription>\n </div>\n </div>\n </CardHeader>\n <CardContent className=\"space-y-5\">\n <div className=\"grid sm:grid-cols-2 gap-4\">\n <div className=\"space-y-1.5\">\n <Label htmlFor=\"name\">Full name</Label>\n <Input id=\"name\" value={formName} onChange={(e) => { setName(e.target.value); setSaved(false); }} />\n </div>\n <div className=\"space-y-1.5\">\n <Label htmlFor=\"email\">Email address</Label>\n <Input id=\"email\" type=\"email\" value={formEmail} onChange={(e) => { setEmail(e.target.value); setSaved(false); }} />\n </div>\n </div>\n\n {id && (\n <div className=\"flex items-center justify-between p-3 rounded-lg\" style={{ background: \"var(--color-bg-subtle)\" }}>\n <div>\n <p className=\"text-xs font-medium uppercase tracking-widest mb-0.5\" style={{ color: \"var(--color-text-muted)\" }}>\n Mentix ID\n </p>\n <p className=\"text-sm font-mono\" style={{ color: \"var(--color-text-primary)\" }}>{id}</p>\n </div>\n <Badge variant=\"outline\">Read-only</Badge>\n </div>\n )}\n\n <div className=\"flex items-center justify-between flex-wrap gap-3 pt-2\">\n <p className=\"text-xs\" style={{ color: \"var(--color-text-muted)\" }}>\n Changes to email require re-verification.\n </p>\n <div className=\"flex items-center gap-2\">\n {saved && (\n <span className=\"inline-flex items-center gap-1 text-xs\" style={{ color: \"var(--color-status-success-text)\" }}>\n <Check size={13} /> Saved\n </span>\n )}\n <Button variant=\"brand\" size=\"sm\" onClick={() => setSaved(true)}>Save changes</Button>\n </div>\n </div>\n </CardContent>\n </Card>\n\n <Card>\n <CardHeader>\n <CardTitle>Language & region</CardTitle>\n <CardDescription>Affects dates, times, and number formatting across the product.</CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <SettingRow\n icon={Languages}\n title=\"Display language\"\n desc=\"English (United Kingdom)\"\n action={<Button variant=\"outline\" size=\"sm\">Change</Button>}\n />\n <Separator />\n <SettingRow\n icon={Globe}\n title=\"Time zone\"\n desc=\"Europe / London · GMT+1\"\n action={<Button variant=\"outline\" size=\"sm\">Change</Button>}\n />\n </CardContent>\n </Card>\n\n {kind === \"sponsor\" && (\n <Card>\n <CardHeader>\n <CardTitle>Organization</CardTitle>\n <CardDescription>Settings inherited from your sponsor organization.</CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <SettingRow icon={User} title=\"Olympus Medical UK\" desc=\"Admin: Helen Chen · 12 active trainees\" />\n </CardContent>\n </Card>\n )}\n </>\n );\n}\n\n/* ─────────────────────────── Notifications ─────────────────────────── */\n\nfunction NotificationsSection({ notificationsHref }: { notificationsHref?: string }) {\n const [emailDigest, setEmailDigest] = useState(true);\n const [browser, setBrowser] = useState(true);\n const [marketing, setMarketing] = useState(false);\n\n return (\n <>\n <Card>\n <CardHeader>\n <div className=\"flex items-start justify-between gap-3 flex-wrap\">\n <div>\n <CardTitle>Quick notification settings</CardTitle>\n <CardDescription>For granular per-event controls, open the full preferences page.</CardDescription>\n </div>\n {notificationsHref && (\n <Button variant=\"outline\" size=\"sm\" render={<Link href={notificationsHref} />}>\n Manage per-event <ChevronRight size={14} className=\"ml-1\" />\n </Button>\n )}\n </div>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <ToggleRow\n icon={Mail}\n title=\"Weekly email digest\"\n desc=\"A summary of your sessions, feedback, and announcements every Monday.\"\n checked={emailDigest}\n onChange={setEmailDigest}\n />\n <Separator />\n <ToggleRow\n icon={Bell}\n title=\"Browser notifications\"\n desc=\"Real-time alerts in this browser while you're signed in.\"\n checked={browser}\n onChange={setBrowser}\n />\n <Separator />\n <ToggleRow\n icon={Globe}\n title=\"Product news & marketing\"\n desc=\"Occasional updates about new features and surgical broadcasts.\"\n checked={marketing}\n onChange={setMarketing}\n />\n </CardContent>\n </Card>\n </>\n );\n}\n\n/* ─────────────────────────── Security ─────────────────────────── */\n\nfunction SecuritySection() {\n const [mfaEnabled, setMfa] = useState(true);\n\n return (\n <>\n <Card>\n <CardHeader>\n <div className=\"flex items-start gap-3\">\n <div className=\"w-9 h-9 rounded-lg flex items-center justify-center shrink-0\" style={{ background: \"var(--color-bg-brand-muted)\" }}>\n <ShieldCheck size={18} strokeWidth={1.6} style={{ color: \"var(--color-text-brand)\" }} />\n </div>\n <div>\n <CardTitle>Sign-in & security</CardTitle>\n <CardDescription>Protect your account with strong credentials and a second factor.</CardDescription>\n </div>\n </div>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <SettingRow\n icon={KeyRound}\n title=\"Password\"\n desc=\"Last changed 42 days ago\"\n action={<Button variant=\"outline\" size=\"sm\">Change password</Button>}\n />\n <Separator />\n <ToggleRow\n icon={Smartphone}\n title=\"Multi-factor authentication\"\n desc={mfaEnabled\n ? \"Authenticator app · Backup codes generated\"\n : \"Add a second factor to protect your account\"}\n checked={mfaEnabled}\n onChange={setMfa}\n />\n <Separator />\n <SettingRow\n icon={ShieldCheck}\n title=\"Active sessions\"\n desc=\"3 devices · Last active 4 min ago\"\n action={<Button variant=\"outline\" size=\"sm\">Review</Button>}\n />\n </CardContent>\n </Card>\n </>\n );\n}\n\n/* ─────────────────────────── Appearance ─────────────────────────── */\n\nfunction AppearanceSection() {\n const [theme, setTheme] = useState<\"system\" | \"light\" | \"dark\">(\"system\");\n const [density, setDensity] = useState<\"comfortable\" | \"compact\">(\"comfortable\");\n\n return (\n <Card>\n <CardHeader>\n <div className=\"flex items-start gap-3\">\n <div className=\"w-9 h-9 rounded-lg flex items-center justify-center shrink-0\" style={{ background: \"var(--color-bg-brand-muted)\" }}>\n <Monitor size={18} strokeWidth={1.6} style={{ color: \"var(--color-text-brand)\" }} />\n </div>\n <div>\n <CardTitle>Appearance</CardTitle>\n <CardDescription>Theme and interface density.</CardDescription>\n </div>\n </div>\n </CardHeader>\n <CardContent className=\"space-y-5\">\n <div>\n <p className=\"text-sm font-medium mb-2\" style={{ color: \"var(--color-text-primary)\" }}>Theme</p>\n <div className=\"grid grid-cols-3 gap-2 max-w-md\">\n {([\"system\", \"light\", \"dark\"] as const).map((t) => (\n <button\n key={t}\n type=\"button\"\n onClick={() => setTheme(t)}\n className=\"rounded-lg border p-3 text-sm capitalize text-left transition-colors\"\n style={{\n borderColor: theme === t ? \"var(--color-border-brand)\" : \"var(--color-border-default)\",\n background: theme === t ? \"var(--color-bg-brand-muted)\" : \"var(--color-bg-default)\",\n color: \"var(--color-text-primary)\",\n }}\n >\n {t}\n </button>\n ))}\n </div>\n </div>\n\n <Separator />\n\n <div>\n <p className=\"text-sm font-medium mb-2\" style={{ color: \"var(--color-text-primary)\" }}>Interface density</p>\n <div className=\"grid grid-cols-2 gap-2 max-w-md\">\n {([\"comfortable\", \"compact\"] as const).map((d) => (\n <button\n key={d}\n type=\"button\"\n onClick={() => setDensity(d)}\n className=\"rounded-lg border p-3 text-sm capitalize text-left transition-colors\"\n style={{\n borderColor: density === d ? \"var(--color-border-brand)\" : \"var(--color-border-default)\",\n background: density === d ? \"var(--color-bg-brand-muted)\" : \"var(--color-bg-default)\",\n color: \"var(--color-text-primary)\",\n }}\n >\n {d}\n </button>\n ))}\n </div>\n </div>\n </CardContent>\n </Card>\n );\n}\n\n/* ─────────────────────────── Devices ─────────────────────────── */\n\nconst DEVICES = [\n { name: \"MacBook Pro · Chrome\", where: \"London, GB\", last: \"Just now\", current: true },\n { name: \"iPhone 15 · Safari\", where: \"London, GB\", last: \"2h ago\", current: false },\n { name: \"Operating room iPad · App\", where: \"King's College Hospital\", last: \"Yesterday\", current: false },\n];\n\nfunction DevicesSection() {\n return (\n <Card>\n <CardHeader>\n <CardTitle>Signed-in devices</CardTitle>\n <CardDescription>\n Devices where your account is currently signed in. Sign out anywhere you don't recognise.\n </CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-3\">\n {DEVICES.map((d) => (\n <div\n key={d.name}\n className=\"flex items-center justify-between gap-3 p-3 rounded-lg border flex-wrap\"\n style={{ borderColor: \"var(--color-border-default)\" }}\n >\n <div className=\"flex items-center gap-3 min-w-0\">\n <div className=\"w-9 h-9 rounded-lg flex items-center justify-center shrink-0\" style={{ background: \"var(--color-bg-subtle)\" }}>\n <Smartphone size={16} strokeWidth={1.6} style={{ color: \"var(--color-text-secondary)\" }} />\n </div>\n <div className=\"min-w-0\">\n <div className=\"flex items-center gap-2 flex-wrap\">\n <p className=\"text-sm font-medium truncate\" style={{ color: \"var(--color-text-primary)\" }}>{d.name}</p>\n {d.current && <Badge variant=\"secondary\">This device</Badge>}\n </div>\n <p className=\"text-xs\" style={{ color: \"var(--color-text-muted)\" }}>\n {d.where} · {d.last}\n </p>\n </div>\n </div>\n {!d.current && (\n <Button variant=\"outline\" size=\"sm\">\n <LogOut size={13} className=\"mr-1.5\" /> Sign out\n </Button>\n )}\n </div>\n ))}\n </CardContent>\n </Card>\n );\n}\n\n/* ─────────────────────────── Danger zone ─────────────────────────── */\n\nfunction DangerSection() {\n return (\n <Card style={{ borderColor: \"var(--color-border-danger, var(--color-status-error-bg))\" }}>\n <CardHeader>\n <div className=\"flex items-start gap-3\">\n <div className=\"w-9 h-9 rounded-lg flex items-center justify-center shrink-0\" style={{ background: \"var(--color-status-error-bg)\" }}>\n <Trash2 size={18} strokeWidth={1.6} style={{ color: \"var(--color-status-error-text)\" }} />\n </div>\n <div>\n <CardTitle>Close account</CardTitle>\n <CardDescription>\n Closing your account removes access to sessions, credits, and Mentix TV history.\n You can request reactivation within 30 days.\n </CardDescription>\n </div>\n </div>\n </CardHeader>\n <CardContent>\n <Button variant=\"outline\" size=\"sm\" style={{ color: \"var(--color-status-error-text)\", borderColor: \"var(--color-status-error-text)\" }}>\n Request account closure\n </Button>\n </CardContent>\n </Card>\n );\n}\n\n/* ─────────────────────────── helpers ─────────────────────────── */\n\nfunction SettingRow({\n icon: Icon, title, desc, action,\n}: { icon: typeof User; title: string; desc: string; action?: React.ReactNode }) {\n return (\n <div className=\"flex items-center justify-between gap-3 flex-wrap\">\n <div className=\"flex items-center gap-3 min-w-0\">\n <Icon size={16} strokeWidth={1.6} style={{ color: \"var(--color-text-secondary)\" }} />\n <div className=\"min-w-0\">\n <p className=\"text-sm font-medium truncate\" style={{ color: \"var(--color-text-primary)\" }}>{title}</p>\n <p className=\"text-xs\" style={{ color: \"var(--color-text-muted)\" }}>{desc}</p>\n </div>\n </div>\n {action}\n </div>\n );\n}\n\nfunction ToggleRow({\n icon: Icon, title, desc, checked, onChange,\n}: { icon: typeof User; title: string; desc: string; checked: boolean; onChange: (v: boolean) => void }) {\n return (\n <div className=\"flex items-center justify-between gap-3 flex-wrap\">\n <div className=\"flex items-center gap-3 min-w-0 flex-1\">\n <Icon size={16} strokeWidth={1.6} style={{ color: \"var(--color-text-secondary)\" }} />\n <div className=\"min-w-0\">\n <p className=\"text-sm font-medium\" style={{ color: \"var(--color-text-primary)\" }}>{title}</p>\n <p className=\"text-xs\" style={{ color: \"var(--color-text-muted)\" }}>{desc}</p>\n </div>\n </div>\n <Switch checked={checked} onCheckedChange={onChange} />\n </div>\n );\n}\n"
|
|
81
|
-
}
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
|
|
3
|
-
"file": "components/portal/usage-sparkline.tsx",
|
|
4
|
-
"sha256": "7ae9e15569254ca36566795d8f37883d21d48db4bfc52888de083ad7b93f8827",
|
|
5
|
-
"imports": [],
|
|
6
|
-
"tokens": [
|
|
7
|
-
"--color-text-muted",
|
|
8
|
-
"--teal-500"
|
|
9
|
-
],
|
|
10
|
-
"fragments": [
|
|
11
|
-
{
|
|
12
|
-
"id": "UsageSparkline",
|
|
13
|
-
"line": 5,
|
|
14
|
-
"code": "export function UsageSparkline({\n data,\n max,\n height = 80,\n label,\n}: {\n data: { month: string; cumulative: number }[];\n max: number;\n height?: number;\n label?: string;\n}) {\n if (data.length < 2) return null;\n const width = 600;\n const pad = 8;\n const yMax = Math.max(max, ...data.map((d) => d.cumulative));\n const points = data.map((d, i) => {\n const x = pad + (i / (data.length - 1)) * (width - pad * 2);\n const y = height - pad - (d.cumulative / yMax) * (height - pad * 2);\n return { x, y, month: d.month, value: d.cumulative };\n });\n const path = points.map((p, i) => (i === 0 ? `M ${p.x},${p.y}` : `L ${p.x},${p.y}`)).join(\" \");\n const area = `${path} L ${points[points.length - 1].x},${height - pad} L ${points[0].x},${height - pad} Z`;\n\n return (\n <div>\n {label && (\n <p className=\"text-[11px] font-medium uppercase tracking-wide mb-2\" style={{ color: \"var(--color-text-muted)\" }}>\n {label}\n </p>\n )}\n <svg viewBox={`0 0 ${width} ${height}`} className=\"w-full\" preserveAspectRatio=\"none\" aria-label={label}>\n <defs>\n <linearGradient id=\"spark-grad\" x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">\n <stop offset=\"0%\" stopColor=\"var(--teal-500)\" stopOpacity=\"0.25\" />\n <stop offset=\"100%\" stopColor=\"var(--teal-500)\" stopOpacity=\"0\" />\n </linearGradient>\n </defs>\n <path d={area} fill=\"url(#spark-grad)\" />\n <path d={path} fill=\"none\" stroke=\"var(--teal-500)\" strokeWidth=\"1.6\" strokeLinejoin=\"round\" strokeLinecap=\"round\" />\n {points.map((p, i) => (\n <g key={i}>\n <circle cx={p.x} cy={p.y} r=\"2.5\" fill=\"var(--teal-500)\" />\n </g>\n ))}\n </svg>\n <div className=\"flex justify-between mt-1 text-[10px]\" style={{ color: \"var(--color-text-muted)\" }}>\n {points.map((p) => <span key={p.month}>{p.month}</span>)}\n </div>\n </div>\n );\n}"
|
|
15
|
-
}
|
|
16
|
-
],
|
|
17
|
-
"content": "\"use client\";\n\n/* Lightweight SVG sparkline — uses design tokens, no chart lib */\n\nexport function UsageSparkline({\n data,\n max,\n height = 80,\n label,\n}: {\n data: { month: string; cumulative: number }[];\n max: number;\n height?: number;\n label?: string;\n}) {\n if (data.length < 2) return null;\n const width = 600;\n const pad = 8;\n const yMax = Math.max(max, ...data.map((d) => d.cumulative));\n const points = data.map((d, i) => {\n const x = pad + (i / (data.length - 1)) * (width - pad * 2);\n const y = height - pad - (d.cumulative / yMax) * (height - pad * 2);\n return { x, y, month: d.month, value: d.cumulative };\n });\n const path = points.map((p, i) => (i === 0 ? `M ${p.x},${p.y}` : `L ${p.x},${p.y}`)).join(\" \");\n const area = `${path} L ${points[points.length - 1].x},${height - pad} L ${points[0].x},${height - pad} Z`;\n\n return (\n <div>\n {label && (\n <p className=\"text-[11px] font-medium uppercase tracking-wide mb-2\" style={{ color: \"var(--color-text-muted)\" }}>\n {label}\n </p>\n )}\n <svg viewBox={`0 0 ${width} ${height}`} className=\"w-full\" preserveAspectRatio=\"none\" aria-label={label}>\n <defs>\n <linearGradient id=\"spark-grad\" x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">\n <stop offset=\"0%\" stopColor=\"var(--teal-500)\" stopOpacity=\"0.25\" />\n <stop offset=\"100%\" stopColor=\"var(--teal-500)\" stopOpacity=\"0\" />\n </linearGradient>\n </defs>\n <path d={area} fill=\"url(#spark-grad)\" />\n <path d={path} fill=\"none\" stroke=\"var(--teal-500)\" strokeWidth=\"1.6\" strokeLinejoin=\"round\" strokeLinecap=\"round\" />\n {points.map((p, i) => (\n <g key={i}>\n <circle cx={p.x} cy={p.y} r=\"2.5\" fill=\"var(--teal-500)\" />\n </g>\n ))}\n </svg>\n <div className=\"flex justify-between mt-1 text-[10px]\" style={{ color: \"var(--color-text-muted)\" }}>\n {points.map((p) => <span key={p.month}>{p.month}</span>)}\n </div>\n </div>\n );\n}\n"
|
|
18
|
-
}
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
|
|
3
|
-
"file": "components/portal/user-menu.tsx",
|
|
4
|
-
"sha256": "70c4e206e1e2ff5fbeaa901fa01d16dda233598729344d9b668d5729c8aa046a",
|
|
5
|
-
"imports": [
|
|
6
|
-
"next/link",
|
|
7
|
-
"react",
|
|
8
|
-
"lucide-react"
|
|
9
|
-
],
|
|
10
|
-
"tokens": [
|
|
11
|
-
"--color-bg-default",
|
|
12
|
-
"--color-bg-subtle",
|
|
13
|
-
"--color-border-default",
|
|
14
|
-
"--color-border-muted",
|
|
15
|
-
"--color-status-error-text",
|
|
16
|
-
"--color-text-muted",
|
|
17
|
-
"--color-text-primary",
|
|
18
|
-
"--gray-950",
|
|
19
|
-
"--teal-500"
|
|
20
|
-
],
|
|
21
|
-
"fragments": [
|
|
22
|
-
{
|
|
23
|
-
"id": "UserMenu",
|
|
24
|
-
"line": 18,
|
|
25
|
-
"code": "export function UserMenu({ user }: { user: UserMenuItem }) {\n const [open, setOpen] = useState(false);\n const wrapperRef = useRef<HTMLDivElement>(null);\n\n useEffect(() => {\n if (!open) return;\n const handler = (e: MouseEvent) => {\n if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) setOpen(false);\n };\n document.addEventListener(\"mousedown\", handler);\n return () => document.removeEventListener(\"mousedown\", handler);\n }, [open]);\n\n return (\n <div ref={wrapperRef} className=\"relative\">\n <button\n type=\"button\"\n onClick={() => setOpen((s) => !s)}\n aria-label=\"Account menu\"\n aria-expanded={open}\n className=\"flex items-center gap-1.5 pl-1 pr-1.5 py-1 rounded-full transition-colors hover:bg-[var(--color-bg-subtle)] focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-[var(--teal-500)]/30\"\n >\n <div className=\"w-7 h-7 rounded-full flex items-center justify-center text-[10px] font-semibold shrink-0\" style={{ background: \"var(--teal-500)\", color: \"var(--gray-950)\" }}>\n {user.initials}\n </div>\n <ChevronDown size={12} strokeWidth={1.8} style={{ color: \"var(--color-text-muted)\" }} />\n </button>\n\n {open && (\n <div\n className=\"absolute right-0 mt-2 w-64 rounded-xl shadow-xl z-50 overflow-hidden\"\n style={{ background: \"var(--color-bg-default)\", border: \"1px solid var(--color-border-default)\" }}\n >\n <div className=\"px-4 py-3 border-b flex items-center gap-3\" style={{ borderColor: \"var(--color-border-muted)\" }}>\n <div className=\"w-10 h-10 rounded-full flex items-center justify-center text-[12px] font-semibold shrink-0\" style={{ background: \"var(--teal-500)\", color: \"var(--gray-950)\" }}>\n {user.initials}\n </div>\n <div className=\"min-w-0\">\n <div className=\"text-[13px] font-semibold truncate\">{user.name}</div>\n <div className=\"text-[11px] truncate\" style={{ color: \"var(--color-text-muted)\" }}>\n {user.subtitle ?? `${user.role ?? \"\"}${user.id ? ` · ${user.id}` : \"\"}`}\n </div>\n </div>\n </div>\n\n <div className=\"py-1.5\">\n <Item href={user.profileHref ?? \"#\"} icon={UserIcon} label=\"My Profile\" onClick={() => setOpen(false)} />\n <Item href={user.settingsHref ?? \"/portal/settings\"} icon={Settings} label=\"Settings\" onClick={() => setOpen(false)} />\n <Item href=\"#\" icon={HelpCircle} label=\"Help & support\" onClick={() => setOpen(false)} />\n </div>\n\n <div className=\"border-t py-1.5\" style={{ borderColor: \"var(--color-border-muted)\" }}>\n <Item href=\"/session-expired?state=logged-out\" icon={LogOut} label=\"Sign out\" onClick={() => setOpen(false)} accent=\"danger\" />\n </div>\n </div>\n )}\n </div>\n );\n}"
|
|
26
|
-
},
|
|
27
|
-
{
|
|
28
|
-
"id": "Item",
|
|
29
|
-
"line": 78,
|
|
30
|
-
"code": "function Item({ href, icon: Icon, label, onClick, accent }: { href: string; icon: typeof UserIcon; label: string; onClick: () => void; accent?: \"danger\" }) {\n return (\n <Link\n href={href}\n onClick={onClick}\n className=\"flex items-center gap-2.5 px-4 py-2 text-[12.5px] transition-colors hover:bg-[var(--color-bg-subtle)]\"\n style={{ color: accent === \"danger\" ? \"var(--color-status-error-text)\" : \"var(--color-text-primary)\" }}\n >\n <Icon size={14} strokeWidth={1.7} />\n {label}\n </Link>\n );\n}"
|
|
31
|
-
}
|
|
32
|
-
],
|
|
33
|
-
"content": "\"use client\";\n\nimport Link from \"next/link\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { ChevronDown, User as UserIcon, Settings, LogOut, HelpCircle } from \"lucide-react\";\n\nexport interface UserMenuItem {\n name: string;\n initials: string;\n role?: string;\n id?: string; // e.g. MX-87A37E\n email?: string;\n subtitle?: string; // optional override; falls back to role + id\n profileHref?: string;\n settingsHref?: string;\n}\n\nexport function UserMenu({ user }: { user: UserMenuItem }) {\n const [open, setOpen] = useState(false);\n const wrapperRef = useRef<HTMLDivElement>(null);\n\n useEffect(() => {\n if (!open) return;\n const handler = (e: MouseEvent) => {\n if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) setOpen(false);\n };\n document.addEventListener(\"mousedown\", handler);\n return () => document.removeEventListener(\"mousedown\", handler);\n }, [open]);\n\n return (\n <div ref={wrapperRef} className=\"relative\">\n <button\n type=\"button\"\n onClick={() => setOpen((s) => !s)}\n aria-label=\"Account menu\"\n aria-expanded={open}\n className=\"flex items-center gap-1.5 pl-1 pr-1.5 py-1 rounded-full transition-colors hover:bg-[var(--color-bg-subtle)] focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-[var(--teal-500)]/30\"\n >\n <div className=\"w-7 h-7 rounded-full flex items-center justify-center text-[10px] font-semibold shrink-0\" style={{ background: \"var(--teal-500)\", color: \"var(--gray-950)\" }}>\n {user.initials}\n </div>\n <ChevronDown size={12} strokeWidth={1.8} style={{ color: \"var(--color-text-muted)\" }} />\n </button>\n\n {open && (\n <div\n className=\"absolute right-0 mt-2 w-64 rounded-xl shadow-xl z-50 overflow-hidden\"\n style={{ background: \"var(--color-bg-default)\", border: \"1px solid var(--color-border-default)\" }}\n >\n <div className=\"px-4 py-3 border-b flex items-center gap-3\" style={{ borderColor: \"var(--color-border-muted)\" }}>\n <div className=\"w-10 h-10 rounded-full flex items-center justify-center text-[12px] font-semibold shrink-0\" style={{ background: \"var(--teal-500)\", color: \"var(--gray-950)\" }}>\n {user.initials}\n </div>\n <div className=\"min-w-0\">\n <div className=\"text-[13px] font-semibold truncate\">{user.name}</div>\n <div className=\"text-[11px] truncate\" style={{ color: \"var(--color-text-muted)\" }}>\n {user.subtitle ?? `${user.role ?? \"\"}${user.id ? ` · ${user.id}` : \"\"}`}\n </div>\n </div>\n </div>\n\n <div className=\"py-1.5\">\n <Item href={user.profileHref ?? \"#\"} icon={UserIcon} label=\"My Profile\" onClick={() => setOpen(false)} />\n <Item href={user.settingsHref ?? \"/portal/settings\"} icon={Settings} label=\"Settings\" onClick={() => setOpen(false)} />\n <Item href=\"#\" icon={HelpCircle} label=\"Help & support\" onClick={() => setOpen(false)} />\n </div>\n\n <div className=\"border-t py-1.5\" style={{ borderColor: \"var(--color-border-muted)\" }}>\n <Item href=\"/session-expired?state=logged-out\" icon={LogOut} label=\"Sign out\" onClick={() => setOpen(false)} accent=\"danger\" />\n </div>\n </div>\n )}\n </div>\n );\n}\n\nfunction Item({ href, icon: Icon, label, onClick, accent }: { href: string; icon: typeof UserIcon; label: string; onClick: () => void; accent?: \"danger\" }) {\n return (\n <Link\n href={href}\n onClick={onClick}\n className=\"flex items-center gap-2.5 px-4 py-2 text-[12.5px] transition-colors hover:bg-[var(--color-bg-subtle)]\"\n style={{ color: accent === \"danger\" ? \"var(--color-status-error-text)\" : \"var(--color-text-primary)\" }}\n >\n <Icon size={14} strokeWidth={1.7} />\n {label}\n </Link>\n );\n}\n"
|
|
34
|
-
}
|