@utopia-studio-design/design-system-cli 0.8.1 → 0.8.2
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 +15 -4
- package/bin/utopia-ds.mjs +16 -3
- package/data/blocks/semantic-pattern-parts/AuthParts.tsx +48 -0
- package/data/blocks/semantic-pattern-parts/ChoiceCards.tsx +7 -0
- package/data/blocks/semantic-pattern-parts/ListFilters.tsx +9 -0
- package/data/blocks/semantic-pattern-parts/OutcomeNotice.tsx +7 -0
- package/data/blocks/semantic-pattern-parts/PatternParts.tsx +7 -0
- package/data/blocks/semantic-pattern-parts/ProductParts.tsx +38 -0
- package/data/blocks/semantic-pattern-parts/ProgressSteps.tsx +6 -0
- package/data/blocks/semantic-pattern-parts/ReviewList.tsx +7 -0
- package/data/blocks/semantic-pattern-parts/ValidatedField.tsx +8 -0
- package/data/blocks/semantic-pattern-parts/WorkflowParts.tsx +339 -0
- package/data/blocks/semantic-pattern-parts/auth-parts.css +27 -0
- package/data/blocks/semantic-pattern-parts/pattern-parts.css +25 -0
- package/data/blocks/semantic-pattern-parts/product-parts.css +25 -0
- package/data/blocks/semantic-pattern-parts/types.ts +3 -0
- package/data/blocks/semantic-pattern-parts/workflow-parts.css +118 -0
- package/data/blocks/ux-pattern-examples/AccessExamples.tsx +46 -0
- package/data/blocks/ux-pattern-examples/AccountExamples.tsx +133 -0
- package/data/blocks/ux-pattern-examples/OnboardingExample.tsx +15 -0
- package/data/blocks/ux-pattern-examples/ProductExamples.tsx +79 -0
- package/data/blocks/ux-pattern-examples/ReviewExample.tsx +14 -0
- package/data/blocks/ux-pattern-examples/SignupExample.tsx +22 -0
- package/data/blocks/ux-pattern-examples/WorkflowExample.tsx +1761 -0
- package/data/blocks/ux-pattern-examples/WorkflowExamples.tsx +190 -0
- package/data/blocks/ux-pattern-examples/copy.ts +6 -0
- package/data/blocks/ux-pattern-examples/ux-examples.css +18 -0
- package/data/blocks/ux-pattern-examples/workflow-copy.ts +76 -0
- package/data/blocks/ux-pattern-examples/workflow-definitions.ts +1291 -0
- package/data/docs/ui-parts-qa.md +172 -0
- package/data/docs/ui-parts.md +105 -0
- package/data/docs/ux-pattern-adaptation-qa.md +38 -0
- package/data/docs/ux-pattern-adaptation.md +41 -0
- package/data/docs/ux-pattern-migration.md +111 -0
- package/data/docs/ux-pattern-publish-2026-09-13.md +22 -0
- package/data/docs/ux-pattern-release-2026-09-13.md +26 -0
- package/data/docs/ux-source-evidence/account-auth.json +41 -0
- package/data/docs/ux-source-evidence/account-forgot-password.json +36 -0
- package/data/docs/ux-source-evidence/account-invite.json +63 -0
- package/data/docs/ux-source-evidence/account-locked.json +63 -0
- package/data/docs/ux-source-evidence/account-login.json +28 -0
- package/data/docs/ux-source-evidence/account-mfa.json +62 -0
- package/data/docs/ux-source-evidence/account-onboarding-credentials.json +52 -0
- package/data/docs/ux-source-evidence/account-onboarding-legal.json +56 -0
- package/data/docs/ux-source-evidence/account-onboarding-orientation.json +66 -0
- package/data/docs/ux-source-evidence/account-onboarding-profile.json +50 -0
- package/data/docs/ux-source-evidence/account-onboarding.json +69 -0
- package/data/docs/ux-source-evidence/account-reset-password.json +27 -0
- package/data/docs/ux-source-evidence/account-session-expired.json +62 -0
- package/data/docs/ux-source-evidence/account-signup.json +33 -0
- package/data/docs/ux-source-evidence/account-verify-email.json +34 -0
- package/data/docs/ux-source-evidence/account-welcome.json +41 -0
- package/data/docs/ux-source-evidence/admin-analytics.json +54 -0
- package/data/docs/ux-source-evidence/admin-audit.json +31 -0
- package/data/docs/ux-source-evidence/admin-broadcasts-new.json +36 -0
- package/data/docs/ux-source-evidence/admin-broadcasts.json +41 -0
- package/data/docs/ux-source-evidence/admin-closures.json +52 -0
- package/data/docs/ux-source-evidence/admin-devices.json +51 -0
- package/data/docs/ux-source-evidence/admin-escalations.json +35 -0
- package/data/docs/ux-source-evidence/admin-incidents.json +54 -0
- package/data/docs/ux-source-evidence/admin-loading.json +17 -0
- package/data/docs/ux-source-evidence/admin-onboarding-detail.json +80 -0
- package/data/docs/ux-source-evidence/admin-onboarding.json +62 -0
- package/data/docs/ux-source-evidence/admin-payments.json +45 -0
- package/data/docs/ux-source-evidence/admin-providers.json +38 -0
- package/data/docs/ux-source-evidence/admin-settings.json +18 -0
- package/data/docs/ux-source-evidence/admin-subscriptions-detail-allocate.json +42 -0
- package/data/docs/ux-source-evidence/admin-subscriptions-detail.json +48 -0
- package/data/docs/ux-source-evidence/admin-subscriptions-new.json +43 -0
- package/data/docs/ux-source-evidence/admin-subscriptions.json +41 -0
- package/data/docs/ux-source-evidence/admin-transactions.json +41 -0
- package/data/docs/ux-source-evidence/error.json +30 -0
- package/data/docs/ux-source-evidence/loading.json +19 -0
- package/data/docs/ux-source-evidence/marketing-blog.json +48 -0
- package/data/docs/ux-source-evidence/marketing-contact.json +36 -0
- package/data/docs/ux-source-evidence/marketing.json +64 -0
- package/data/docs/ux-source-evidence/not-found.json +28 -0
- package/data/docs/ux-source-evidence/portal-loading.json +17 -0
- package/data/docs/ux-source-evidence/portal-mentor-messages.json +31 -0
- package/data/docs/ux-source-evidence/portal-mentor-profile.json +23 -0
- package/data/docs/ux-source-evidence/portal-mentor-requests-detail.json +54 -0
- package/data/docs/ux-source-evidence/portal-mentor-sessions-detail-assess.json +42 -0
- package/data/docs/ux-source-evidence/portal-mentor-settings.json +18 -0
- package/data/docs/ux-source-evidence/portal-mentor.json +38 -0
- package/data/docs/ux-source-evidence/portal-sessions-detail-live.json +48 -0
- package/data/docs/ux-source-evidence/portal-sponsor-billing.json +28 -0
- package/data/docs/ux-source-evidence/portal-sponsor-profile.json +22 -0
- package/data/docs/ux-source-evidence/portal-sponsor-sessions.json +28 -0
- package/data/docs/ux-source-evidence/portal-sponsor-settings.json +18 -0
- package/data/docs/ux-source-evidence/portal-sponsor-trainees.json +36 -0
- package/data/docs/ux-source-evidence/portal-sponsor.json +52 -0
- package/data/docs/ux-source-evidence/portal-trainee-competency.json +42 -0
- package/data/docs/ux-source-evidence/portal-trainee-credits.json +40 -0
- package/data/docs/ux-source-evidence/portal-trainee-mentors.json +48 -0
- package/data/docs/ux-source-evidence/portal-trainee-messages.json +32 -0
- package/data/docs/ux-source-evidence/portal-trainee-notifications.json +35 -0
- package/data/docs/ux-source-evidence/portal-trainee-profile.json +26 -0
- package/data/docs/ux-source-evidence/portal-trainee-request.json +54 -0
- package/data/docs/ux-source-evidence/portal-trainee-sessions-detail-feedback.json +36 -0
- package/data/docs/ux-source-evidence/portal-trainee-sessions-detail-precheck.json +42 -0
- package/data/docs/ux-source-evidence/portal-trainee-sessions-detail.json +55 -0
- package/data/docs/ux-source-evidence/portal-trainee-sessions.json +36 -0
- package/data/docs/ux-source-evidence/portal-trainee-settings-notifications.json +38 -0
- package/data/docs/ux-source-evidence/portal-trainee-settings.json +18 -0
- package/data/docs/ux-source-evidence/portal-trainee.json +44 -0
- package/data/docs/ux-source-evidence/portal-tv-detail.json +39 -0
- package/data/docs/ux-source-evidence/portal-tv.json +44 -0
- package/data/docs/ux-source-evidence/shared-admin-admin-shell.json +35 -0
- package/data/docs/ux-source-evidence/shared-auth-auth-primitives.json +105 -0
- package/data/docs/ux-source-evidence/shared-auth-auth-shell.json +25 -0
- package/data/docs/ux-source-evidence/shared-auth-onboarding-primitives.json +52 -0
- package/data/docs/ux-source-evidence/shared-auth-onboarding-shell.json +28 -0
- package/data/docs/ux-source-evidence/shared-auth-status-indicator.json +41 -0
- package/data/docs/ux-source-evidence/shared-portal-empty-state.json +24 -0
- package/data/docs/ux-source-evidence/shared-portal-loading-skeleton.json +27 -0
- package/data/docs/ux-source-evidence/shared-portal-notifications-dropdown.json +39 -0
- package/data/docs/ux-source-evidence/shared-portal-portal-shell.json +36 -0
- package/data/docs/ux-source-evidence/shared-portal-profile-form.json +32 -0
- package/data/docs/ux-source-evidence/shared-portal-settings-view.json +81 -0
- package/data/docs/ux-source-evidence/shared-portal-usage-sparkline.json +18 -0
- package/data/docs/ux-source-evidence/shared-portal-user-menu.json +34 -0
- package/data/manifests/blocks.json +230 -21
- package/data/manifests/patterns.json +330 -0
- package/data/manifests/ui-patterns.json +1029 -0
- package/data/manifests/ux-examples.json +2177 -0
- package/data/manifests/ux-patterns.json +1135 -0
- package/data/manifests/ux-source-inventory.json +4669 -0
- package/data/ui-parts/blocks/analytics-dashboard/AnalyticsMetricBlocks.tsx +87 -0
- package/data/ui-parts/blocks/analytics-dashboard/analytics-dashboard.css +92 -0
- package/data/ui-parts/blocks/semantic-pattern-parts/AuthParts.tsx +48 -0
- package/data/ui-parts/blocks/semantic-pattern-parts/ChoiceCards.tsx +7 -0
- package/data/ui-parts/blocks/semantic-pattern-parts/ListFilters.tsx +9 -0
- package/data/ui-parts/blocks/semantic-pattern-parts/OutcomeNotice.tsx +7 -0
- package/data/ui-parts/blocks/semantic-pattern-parts/ProductParts.tsx +38 -0
- package/data/ui-parts/blocks/semantic-pattern-parts/ProgressSteps.tsx +6 -0
- package/data/ui-parts/blocks/semantic-pattern-parts/ReviewList.tsx +7 -0
- package/data/ui-parts/blocks/semantic-pattern-parts/ValidatedField.tsx +8 -0
- package/data/ui-parts/blocks/semantic-pattern-parts/WorkflowParts.tsx +339 -0
- package/data/ui-parts/blocks/semantic-pattern-parts/auth-parts.css +27 -0
- package/data/ui-parts/blocks/semantic-pattern-parts/pattern-parts.css +25 -0
- package/data/ui-parts/blocks/semantic-pattern-parts/product-parts.css +25 -0
- package/data/ui-parts/blocks/semantic-pattern-parts/types.ts +3 -0
- package/data/ui-parts/blocks/semantic-pattern-parts/workflow-parts.css +118 -0
- package/data/ui-parts/blocks/ux-pattern-examples/AccessExamples.tsx +46 -0
- package/data/ui-parts/blocks/ux-pattern-examples/AccountExamples.tsx +133 -0
- package/data/ui-parts/blocks/ux-pattern-examples/ProductExamples.tsx +79 -0
- package/data/ui-parts/blocks/ux-pattern-examples/WorkflowExample.tsx +1761 -0
- package/data/ui-parts/blocks/ux-pattern-examples/WorkflowExamples.tsx +190 -0
- package/data/ui-parts/blocks/ux-pattern-examples/copy.ts +6 -0
- package/data/ui-parts/blocks/ux-pattern-examples/ux-examples.css +18 -0
- package/data/ui-parts/blocks/ux-pattern-examples/workflow-copy.ts +76 -0
- package/data/ui-parts/blocks/ux-pattern-examples/workflow-definitions.ts +1291 -0
- package/data/ux-source-evidence/account-auth.json +41 -0
- package/data/ux-source-evidence/account-forgot-password.json +36 -0
- package/data/ux-source-evidence/account-invite.json +63 -0
- package/data/ux-source-evidence/account-locked.json +63 -0
- package/data/ux-source-evidence/account-login.json +28 -0
- package/data/ux-source-evidence/account-mfa.json +62 -0
- package/data/ux-source-evidence/account-onboarding-credentials.json +52 -0
- package/data/ux-source-evidence/account-onboarding-legal.json +56 -0
- package/data/ux-source-evidence/account-onboarding-orientation.json +66 -0
- package/data/ux-source-evidence/account-onboarding-profile.json +50 -0
- package/data/ux-source-evidence/account-onboarding.json +69 -0
- package/data/ux-source-evidence/account-reset-password.json +27 -0
- package/data/ux-source-evidence/account-session-expired.json +62 -0
- package/data/ux-source-evidence/account-signup.json +33 -0
- package/data/ux-source-evidence/account-verify-email.json +34 -0
- package/data/ux-source-evidence/account-welcome.json +41 -0
- package/data/ux-source-evidence/admin-analytics.json +54 -0
- package/data/ux-source-evidence/admin-audit.json +31 -0
- package/data/ux-source-evidence/admin-broadcasts-new.json +36 -0
- package/data/ux-source-evidence/admin-broadcasts.json +41 -0
- package/data/ux-source-evidence/admin-closures.json +52 -0
- package/data/ux-source-evidence/admin-devices.json +51 -0
- package/data/ux-source-evidence/admin-escalations.json +35 -0
- package/data/ux-source-evidence/admin-incidents.json +54 -0
- package/data/ux-source-evidence/admin-loading.json +17 -0
- package/data/ux-source-evidence/admin-onboarding-detail.json +80 -0
- package/data/ux-source-evidence/admin-onboarding.json +62 -0
- package/data/ux-source-evidence/admin-payments.json +45 -0
- package/data/ux-source-evidence/admin-providers.json +38 -0
- package/data/ux-source-evidence/admin-settings.json +18 -0
- package/data/ux-source-evidence/admin-subscriptions-detail-allocate.json +42 -0
- package/data/ux-source-evidence/admin-subscriptions-detail.json +48 -0
- package/data/ux-source-evidence/admin-subscriptions-new.json +43 -0
- package/data/ux-source-evidence/admin-subscriptions.json +41 -0
- package/data/ux-source-evidence/admin-transactions.json +41 -0
- package/data/ux-source-evidence/error.json +30 -0
- package/data/ux-source-evidence/loading.json +19 -0
- package/data/ux-source-evidence/marketing-blog.json +48 -0
- package/data/ux-source-evidence/marketing-contact.json +36 -0
- package/data/ux-source-evidence/marketing.json +64 -0
- package/data/ux-source-evidence/not-found.json +28 -0
- package/data/ux-source-evidence/portal-loading.json +17 -0
- package/data/ux-source-evidence/portal-mentor-messages.json +31 -0
- package/data/ux-source-evidence/portal-mentor-profile.json +23 -0
- package/data/ux-source-evidence/portal-mentor-requests-detail.json +54 -0
- package/data/ux-source-evidence/portal-mentor-sessions-detail-assess.json +42 -0
- package/data/ux-source-evidence/portal-mentor-settings.json +18 -0
- package/data/ux-source-evidence/portal-mentor.json +38 -0
- package/data/ux-source-evidence/portal-sessions-detail-live.json +48 -0
- package/data/ux-source-evidence/portal-sponsor-billing.json +28 -0
- package/data/ux-source-evidence/portal-sponsor-profile.json +22 -0
- package/data/ux-source-evidence/portal-sponsor-sessions.json +28 -0
- package/data/ux-source-evidence/portal-sponsor-settings.json +18 -0
- package/data/ux-source-evidence/portal-sponsor-trainees.json +36 -0
- package/data/ux-source-evidence/portal-sponsor.json +52 -0
- package/data/ux-source-evidence/portal-trainee-competency.json +42 -0
- package/data/ux-source-evidence/portal-trainee-credits.json +40 -0
- package/data/ux-source-evidence/portal-trainee-mentors.json +48 -0
- package/data/ux-source-evidence/portal-trainee-messages.json +32 -0
- package/data/ux-source-evidence/portal-trainee-notifications.json +35 -0
- package/data/ux-source-evidence/portal-trainee-profile.json +26 -0
- package/data/ux-source-evidence/portal-trainee-request.json +54 -0
- package/data/ux-source-evidence/portal-trainee-sessions-detail-feedback.json +36 -0
- package/data/ux-source-evidence/portal-trainee-sessions-detail-precheck.json +42 -0
- package/data/ux-source-evidence/portal-trainee-sessions-detail.json +55 -0
- package/data/ux-source-evidence/portal-trainee-sessions.json +36 -0
- package/data/ux-source-evidence/portal-trainee-settings-notifications.json +38 -0
- package/data/ux-source-evidence/portal-trainee-settings.json +18 -0
- package/data/ux-source-evidence/portal-trainee.json +44 -0
- package/data/ux-source-evidence/portal-tv-detail.json +39 -0
- package/data/ux-source-evidence/portal-tv.json +44 -0
- package/data/ux-source-evidence/shared-admin-admin-shell.json +35 -0
- package/data/ux-source-evidence/shared-auth-auth-primitives.json +105 -0
- package/data/ux-source-evidence/shared-auth-auth-shell.json +25 -0
- package/data/ux-source-evidence/shared-auth-onboarding-primitives.json +52 -0
- package/data/ux-source-evidence/shared-auth-onboarding-shell.json +28 -0
- package/data/ux-source-evidence/shared-auth-status-indicator.json +41 -0
- package/data/ux-source-evidence/shared-portal-empty-state.json +24 -0
- package/data/ux-source-evidence/shared-portal-loading-skeleton.json +27 -0
- package/data/ux-source-evidence/shared-portal-notifications-dropdown.json +39 -0
- package/data/ux-source-evidence/shared-portal-portal-shell.json +36 -0
- package/data/ux-source-evidence/shared-portal-profile-form.json +32 -0
- package/data/ux-source-evidence/shared-portal-settings-view.json +81 -0
- package/data/ux-source-evidence/shared-portal-usage-sparkline.json +18 -0
- package/data/ux-source-evidence/shared-portal-user-menu.json +34 -0
- package/lib/api.mjs +193 -4
- package/package.json +2 -2
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
|
|
3
|
+
"file": "app/admin/providers/page.tsx",
|
|
4
|
+
"sha256": "5222640ee2c3dfe38c26b86f7edfb27dd283d8c9d1622a791b5dc5404c11b7c6",
|
|
5
|
+
"imports": [
|
|
6
|
+
"next/link",
|
|
7
|
+
"lucide-react",
|
|
8
|
+
"@/components/ui/button",
|
|
9
|
+
"@/components/ui/badge",
|
|
10
|
+
"@/components/ui/card",
|
|
11
|
+
"@/components/admin/admin-shell",
|
|
12
|
+
"@/lib/closures-mock"
|
|
13
|
+
],
|
|
14
|
+
"tokens": [
|
|
15
|
+
"--color-bg-brand-muted",
|
|
16
|
+
"--color-status-error-border",
|
|
17
|
+
"--color-status-error-text",
|
|
18
|
+
"--color-status-success-border",
|
|
19
|
+
"--color-status-success-text",
|
|
20
|
+
"--color-text-brand",
|
|
21
|
+
"--color-text-muted",
|
|
22
|
+
"--color-text-primary",
|
|
23
|
+
"--color-text-secondary"
|
|
24
|
+
],
|
|
25
|
+
"fragments": [
|
|
26
|
+
{
|
|
27
|
+
"id": "ProvidersPage",
|
|
28
|
+
"line": 11,
|
|
29
|
+
"code": "export default function ProvidersPage() {\n return (\n <AdminShell>\n <Link href=\"/admin/devices\" className=\"inline-flex items-center gap-1.5 text-[12px] mb-4 transition-opacity hover:opacity-70\" style={{ color: \"var(--color-text-secondary)\" }}>\n <ArrowLeft size={13} /> Back to devices\n </Link>\n\n <div className=\"flex items-start justify-between gap-4 mb-6\">\n <div>\n <h1 className=\"text-[1.75rem] font-semibold tracking-tight mb-1\" style={{ letterSpacing: \"-0.025em\" }}>\n Teleproctoring providers\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n API integrations with third-party teleproctoring vendors.\n </p>\n </div>\n <Button variant=\"brand\"><Plus size={13} /> Add provider</Button>\n </div>\n\n <div className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3\">\n {TPI_PROVIDERS.map((p) => (\n <Card key={p.id}>\n <CardHeader>\n <div className=\"flex items-start justify-between gap-3 mb-2\">\n <div className=\"flex items-center gap-3\">\n <div className=\"w-10 h-10 rounded-lg flex items-center justify-center shrink-0\" style={{ background: \"var(--color-bg-brand-muted)\", color: \"var(--color-text-brand)\" }}>\n <Plug size={16} strokeWidth={1.7} />\n </div>\n <div>\n <CardTitle className=\"text-base\">{p.name}</CardTitle>\n <CardDescription className=\"text-[11px]\">{p.devices} device{p.devices === 1 ? \"\" : \"s\"} linked</CardDescription>\n </div>\n </div>\n {p.status === \"connected\" ? (\n <Badge variant=\"outline\" className=\"text-[10px]\" style={{ color: \"var(--color-status-success-text)\", borderColor: \"var(--color-status-success-border)\" }}>\n <CheckCircle2 size={9} className=\"mr-1\" /> Connected\n </Badge>\n ) : (\n <Badge variant=\"outline\" className=\"text-[10px]\" style={{ color: \"var(--color-status-error-text)\", borderColor: \"var(--color-status-error-border)\" }}>\n <AlertTriangle size={9} className=\"mr-1\" /> Disconnected\n </Badge>\n )}\n </div>\n </CardHeader>\n <CardContent>\n <dl className=\"space-y-2 text-[12px]\">\n <Row label=\"API base\" value={<code className=\"text-[11px]\">{p.apiBase}</code>} />\n <Row label=\"Auth\" value={<span className=\"capitalize\">{p.auth.replace(\"_\", \" \")}</span>} />\n <Row label=\"Last call\" value={<span style={{ color: \"var(--color-text-muted)\" }}>{p.lastCallAt}</span>} />\n </dl>\n <div className=\"mt-4 flex items-center gap-2\">\n <Button variant=\"outline\" size=\"sm\">Test connection</Button>\n <Button variant=\"outline\" size=\"sm\">Edit credentials</Button>\n </div>\n </CardContent>\n </Card>\n ))}\n </div>\n </AdminShell>\n );\n}"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"id": "Row",
|
|
33
|
+
"line": 73,
|
|
34
|
+
"code": "function Row({ label, value }: { label: string; value: React.ReactNode }) {\n return (\n <div className=\"flex items-start justify-between gap-2\">\n <dt className=\"shrink-0\" style={{ color: \"var(--color-text-muted)\" }}>{label}</dt>\n <dd className=\"text-right text-[11px]\" style={{ color: \"var(--color-text-primary)\" }}>{value}</dd>\n </div>\n );\n}"
|
|
35
|
+
}
|
|
36
|
+
],
|
|
37
|
+
"content": "\"use client\";\n\nimport Link from \"next/link\";\nimport { Plus, Plug, ArrowLeft, CheckCircle2, AlertTriangle } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Card, CardHeader, CardTitle, CardContent, CardDescription } from \"@/components/ui/card\";\nimport { AdminShell } from \"@/components/admin/admin-shell\";\nimport { TPI_PROVIDERS } from \"@/lib/closures-mock\";\n\nexport default function ProvidersPage() {\n return (\n <AdminShell>\n <Link href=\"/admin/devices\" className=\"inline-flex items-center gap-1.5 text-[12px] mb-4 transition-opacity hover:opacity-70\" style={{ color: \"var(--color-text-secondary)\" }}>\n <ArrowLeft size={13} /> Back to devices\n </Link>\n\n <div className=\"flex items-start justify-between gap-4 mb-6\">\n <div>\n <h1 className=\"text-[1.75rem] font-semibold tracking-tight mb-1\" style={{ letterSpacing: \"-0.025em\" }}>\n Teleproctoring providers\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n API integrations with third-party teleproctoring vendors.\n </p>\n </div>\n <Button variant=\"brand\"><Plus size={13} /> Add provider</Button>\n </div>\n\n <div className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3\">\n {TPI_PROVIDERS.map((p) => (\n <Card key={p.id}>\n <CardHeader>\n <div className=\"flex items-start justify-between gap-3 mb-2\">\n <div className=\"flex items-center gap-3\">\n <div className=\"w-10 h-10 rounded-lg flex items-center justify-center shrink-0\" style={{ background: \"var(--color-bg-brand-muted)\", color: \"var(--color-text-brand)\" }}>\n <Plug size={16} strokeWidth={1.7} />\n </div>\n <div>\n <CardTitle className=\"text-base\">{p.name}</CardTitle>\n <CardDescription className=\"text-[11px]\">{p.devices} device{p.devices === 1 ? \"\" : \"s\"} linked</CardDescription>\n </div>\n </div>\n {p.status === \"connected\" ? (\n <Badge variant=\"outline\" className=\"text-[10px]\" style={{ color: \"var(--color-status-success-text)\", borderColor: \"var(--color-status-success-border)\" }}>\n <CheckCircle2 size={9} className=\"mr-1\" /> Connected\n </Badge>\n ) : (\n <Badge variant=\"outline\" className=\"text-[10px]\" style={{ color: \"var(--color-status-error-text)\", borderColor: \"var(--color-status-error-border)\" }}>\n <AlertTriangle size={9} className=\"mr-1\" /> Disconnected\n </Badge>\n )}\n </div>\n </CardHeader>\n <CardContent>\n <dl className=\"space-y-2 text-[12px]\">\n <Row label=\"API base\" value={<code className=\"text-[11px]\">{p.apiBase}</code>} />\n <Row label=\"Auth\" value={<span className=\"capitalize\">{p.auth.replace(\"_\", \" \")}</span>} />\n <Row label=\"Last call\" value={<span style={{ color: \"var(--color-text-muted)\" }}>{p.lastCallAt}</span>} />\n </dl>\n <div className=\"mt-4 flex items-center gap-2\">\n <Button variant=\"outline\" size=\"sm\">Test connection</Button>\n <Button variant=\"outline\" size=\"sm\">Edit credentials</Button>\n </div>\n </CardContent>\n </Card>\n ))}\n </div>\n </AdminShell>\n );\n}\n\nfunction Row({ label, value }: { label: string; value: React.ReactNode }) {\n return (\n <div className=\"flex items-start justify-between gap-2\">\n <dt className=\"shrink-0\" style={{ color: \"var(--color-text-muted)\" }}>{label}</dt>\n <dd className=\"text-right text-[11px]\" style={{ color: \"var(--color-text-primary)\" }}>{value}</dd>\n </div>\n );\n}\n"
|
|
38
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
|
|
3
|
+
"file": "app/admin/settings/page.tsx",
|
|
4
|
+
"sha256": "20fc18b01d7b0c7dcb4fdada3428332539cc1c2c8a816976cc0f3b4fdf588bed",
|
|
5
|
+
"imports": [
|
|
6
|
+
"@/components/admin/admin-shell",
|
|
7
|
+
"@/components/portal/settings-view"
|
|
8
|
+
],
|
|
9
|
+
"tokens": [],
|
|
10
|
+
"fragments": [
|
|
11
|
+
{
|
|
12
|
+
"id": "AdminSettingsPage",
|
|
13
|
+
"line": 4,
|
|
14
|
+
"code": "export default function AdminSettingsPage() {\n return (\n <AdminShell>\n <SettingsView\n kind=\"admin\"\n name=\"Eleanor Whitmore\"\n email=\"s.kim@mentix.health\"\n id=\"MX-A0001A\"\n />\n </AdminShell>\n );\n}"
|
|
15
|
+
}
|
|
16
|
+
],
|
|
17
|
+
"content": "import { AdminShell } from \"@/components/admin/admin-shell\";\nimport { SettingsView } from \"@/components/portal/settings-view\";\n\nexport default function AdminSettingsPage() {\n return (\n <AdminShell>\n <SettingsView\n kind=\"admin\"\n name=\"Eleanor Whitmore\"\n email=\"s.kim@mentix.health\"\n id=\"MX-A0001A\"\n />\n </AdminShell>\n );\n}\n"
|
|
18
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
|
|
3
|
+
"file": "app/admin/subscriptions/[id]/allocate/page.tsx",
|
|
4
|
+
"sha256": "06725b6ee89ae953df007105149dc03e13317a411b4a05db72e9e4d5e2503bac",
|
|
5
|
+
"imports": [
|
|
6
|
+
"next/link",
|
|
7
|
+
"react",
|
|
8
|
+
"lucide-react",
|
|
9
|
+
"@/components/ui/button",
|
|
10
|
+
"@/components/ui/input",
|
|
11
|
+
"@/components/ui/card",
|
|
12
|
+
"@/components/ui/badge",
|
|
13
|
+
"@/components/ui/table",
|
|
14
|
+
"@/components/admin/admin-shell",
|
|
15
|
+
"@/components/auth/auth-primitives",
|
|
16
|
+
"@/lib/admin-mock",
|
|
17
|
+
"@/lib/credits-mock",
|
|
18
|
+
"@/lib/credits"
|
|
19
|
+
],
|
|
20
|
+
"tokens": [
|
|
21
|
+
"--color-bg-subtle",
|
|
22
|
+
"--color-status-error-text",
|
|
23
|
+
"--color-status-success-bg",
|
|
24
|
+
"--color-status-success-text",
|
|
25
|
+
"--color-text-muted",
|
|
26
|
+
"--color-text-primary",
|
|
27
|
+
"--color-text-secondary"
|
|
28
|
+
],
|
|
29
|
+
"fragments": [
|
|
30
|
+
{
|
|
31
|
+
"id": "AllocatePage",
|
|
32
|
+
"line": 17,
|
|
33
|
+
"code": "export default function AllocatePage({ params }: { params: Promise<{ id: string }> }) {\n const { id } = use(params);\n const sub = SUBSCRIPTIONS.find((s) => s.id === id);\n\n if (!sub) {\n return (\n <AdminShell>\n <div className=\"text-center py-20\">\n <p className=\"text-[14px]\" style={{ color: \"var(--color-text-secondary)\" }}>Subscription not found.</p>\n </div>\n </AdminShell>\n );\n }\n\n const remaining = creditsRemaining(sub);\n\n // Trainees eligible for allocation: status Active or Credits Pending\n const eligible = APPLICATIONS.filter((a) =>\n a.role === \"trainee\" && (a.status === \"credits_pending\" || a.status === \"hardware_pending\" || a.status === \"active\")\n );\n\n const [amounts, setAmounts] = useState<Record<string, number>>({});\n const [saving, setSaving] = useState(false);\n const [done, setDone] = useState(false);\n\n const totalToAllocate = useMemo(() =>\n Object.values(amounts).reduce((a, b) => a + b, 0),\n [amounts]\n );\n\n const setAmount = (id: string, n: number) => {\n const clamped = Math.max(0, n);\n setAmounts({ ...amounts, [id]: clamped });\n };\n\n const overBudget = totalToAllocate > remaining;\n const canSubmit = totalToAllocate > 0 && !overBudget;\n\n if (done) {\n return (\n <AdminShell>\n <div className=\"max-w-md mx-auto text-center py-16\">\n <div className=\"w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-5\" style={{ background: \"var(--color-status-success-bg)\", color: \"var(--color-status-success-text)\" }}>\n <CheckCircle2 size={32} strokeWidth={1.6} />\n </div>\n <h1 className=\"text-[1.5rem] font-semibold mb-2\" style={{ letterSpacing: \"-0.025em\" }}>\n {totalToAllocate} credits allocated\n </h1>\n <p className=\"text-sm mb-6\" style={{ color: \"var(--color-text-secondary)\" }}>\n Trainees have been notified by email.\n </p>\n <Link href={`/admin/subscriptions/${sub.id}`}>\n <Button variant=\"brand\" size=\"lg\">Back to subscription <ArrowRight size={13} /></Button>\n </Link>\n </div>\n </AdminShell>\n );\n }\n\n return (\n <AdminShell>\n <Link href={`/admin/subscriptions/${sub.id}`} className=\"inline-flex items-center gap-1.5 text-[12px] mb-4 transition-opacity hover:opacity-70\" style={{ color: \"var(--color-text-secondary)\" }}>\n <ArrowLeft size={13} /> Back to {sub.sponsorName}\n </Link>\n\n <div className=\"mb-6\">\n <h1 className=\"text-[1.75rem] font-semibold tracking-tight mb-1\" style={{ letterSpacing: \"-0.025em\" }}>\n Allocate credits\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n Move credits from <strong style={{ color: \"var(--color-text-primary)\" }}>{sub.sponsorName}</strong>'s pool to specific trainees.\n </p>\n </div>\n\n {/* Budget summary */}\n <Card className=\"mb-5\">\n <CardContent className=\"pt-4\">\n <div className=\"grid grid-cols-1 sm:grid-cols-3 gap-4\">\n <Stat label=\"Sponsor pool\" value={remaining.toString()} sub=\"available to allocate\" />\n <Stat label=\"Allocating now\" value={totalToAllocate.toString()} sub=\"credits selected\" />\n <Stat label=\"After allocation\" value={(remaining - totalToAllocate).toString()} sub=\"will remain\" accent={overBudget ? \"error\" : \"default\"} />\n </div>\n </CardContent>\n </Card>\n\n {overBudget && (\n <div className=\"mb-5\">\n <InlineAlert variant=\"error\" title=\"Insufficient credits in sponsor pool\">\n You're trying to allocate {totalToAllocate} but only {remaining} are available. Reduce the allocation or top up the pool first.\n </InlineAlert>\n </div>\n )}\n\n {/* Trainee picker */}\n <Card className=\"p-0 mb-5\">\n <Table>\n <TableHeader style={{ background: \"var(--color-bg-subtle)\" }}>\n <TableRow>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Trainee</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Procedures</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Status</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5 text-right\" style={{ color: \"var(--color-text-muted)\" }}>Allocate</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {eligible.map((t) => {\n const amount = amounts[t.id] ?? 0;\n return (\n <TableRow key={t.id}>\n <TableCell className=\"px-5 py-3\">\n <div>\n <div className=\"font-medium text-[13px]\">{t.name}</div>\n <div className=\"text-[11px]\" style={{ color: \"var(--color-text-muted)\" }}>{t.institution}</div>\n </div>\n </TableCell>\n <TableCell className=\"px-5 py-3\">\n <div className=\"flex flex-wrap gap-1\">\n {(t.procedures ?? []).map((p) => <Badge key={p} variant=\"outline\" className=\"text-[10px]\">{p}</Badge>)}\n </div>\n </TableCell>\n <TableCell className=\"px-5 py-3\">\n <Badge variant=\"outline\" className=\"text-[10px]\">{t.status.replace(\"_\", \" \")}</Badge>\n </TableCell>\n <TableCell className=\"px-5 py-3\">\n <div className=\"flex items-center gap-1.5 justify-end\">\n <Button variant=\"outline\" size=\"icon-sm\" onClick={() => setAmount(t.id, amount - 1)} disabled={amount === 0} aria-label=\"Decrease\"><Minus size={12} /></Button>\n <Input\n type=\"number\"\n value={amount || \"\"}\n onChange={(e) => setAmount(t.id, parseInt(e.target.value) || 0)}\n placeholder=\"0\"\n className=\"h-8 w-14 text-center tabular-nums\"\n />\n <Button variant=\"outline\" size=\"icon-sm\" onClick={() => setAmount(t.id, amount + 1)} aria-label=\"Increase\"><Plus size={12} /></Button>\n </div>\n </TableCell>\n </TableRow>\n );\n })}\n </TableBody>\n </Table>\n </Card>\n\n {/* Footer */}\n <div className=\"flex items-center justify-end gap-2\">\n <Link href={`/admin/subscriptions/${sub.id}`}>\n <Button variant=\"outline\">Cancel</Button>\n </Link>\n <Button\n variant=\"brand\"\n disabled={!canSubmit || saving}\n onClick={() => { setSaving(true); setTimeout(() => setDone(true), 800); }}\n >\n {saving ? <><Spinner className=\"w-3.5 h-3.5 mr-1\" /> Allocating…</> : <>Allocate {totalToAllocate} credits <ArrowRight size={13} /></>}\n </Button>\n </div>\n </AdminShell>\n );\n}"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"id": "Stat",
|
|
37
|
+
"line": 177,
|
|
38
|
+
"code": "function Stat({ label, value, sub, accent }: { label: string; value: string; sub: string; accent?: \"error\" | \"default\" }) {\n const color = accent === \"error\" ? \"var(--color-status-error-text)\" : \"var(--color-text-primary)\";\n return (\n <div>\n <div className=\"text-[10px] font-semibold uppercase tracking-wide mb-1\" style={{ color: \"var(--color-text-muted)\" }}>{label}</div>\n <div className=\"text-2xl font-semibold tabular-nums\" style={{ letterSpacing: \"-0.02em\", color }}>{value}</div>\n <div className=\"text-[11px] mt-0.5\" style={{ color: \"var(--color-text-muted)\" }}>{sub}</div>\n </div>\n );\n}"
|
|
39
|
+
}
|
|
40
|
+
],
|
|
41
|
+
"content": "\"use client\";\n\nimport Link from \"next/link\";\nimport { use, useState, useMemo } from \"react\";\nimport { ArrowLeft, ArrowRight, AlertCircle, Plus, Minus, CheckCircle2 } 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 { Badge } from \"@/components/ui/badge\";\nimport { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from \"@/components/ui/table\";\nimport { AdminShell } from \"@/components/admin/admin-shell\";\nimport { InlineAlert, Spinner } from \"@/components/auth/auth-primitives\";\nimport { APPLICATIONS } from \"@/lib/admin-mock\";\nimport { SUBSCRIPTIONS } from \"@/lib/credits-mock\";\nimport { creditsRemaining } from \"@/lib/credits\";\n\nexport default function AllocatePage({ params }: { params: Promise<{ id: string }> }) {\n const { id } = use(params);\n const sub = SUBSCRIPTIONS.find((s) => s.id === id);\n\n if (!sub) {\n return (\n <AdminShell>\n <div className=\"text-center py-20\">\n <p className=\"text-[14px]\" style={{ color: \"var(--color-text-secondary)\" }}>Subscription not found.</p>\n </div>\n </AdminShell>\n );\n }\n\n const remaining = creditsRemaining(sub);\n\n // Trainees eligible for allocation: status Active or Credits Pending\n const eligible = APPLICATIONS.filter((a) =>\n a.role === \"trainee\" && (a.status === \"credits_pending\" || a.status === \"hardware_pending\" || a.status === \"active\")\n );\n\n const [amounts, setAmounts] = useState<Record<string, number>>({});\n const [saving, setSaving] = useState(false);\n const [done, setDone] = useState(false);\n\n const totalToAllocate = useMemo(() =>\n Object.values(amounts).reduce((a, b) => a + b, 0),\n [amounts]\n );\n\n const setAmount = (id: string, n: number) => {\n const clamped = Math.max(0, n);\n setAmounts({ ...amounts, [id]: clamped });\n };\n\n const overBudget = totalToAllocate > remaining;\n const canSubmit = totalToAllocate > 0 && !overBudget;\n\n if (done) {\n return (\n <AdminShell>\n <div className=\"max-w-md mx-auto text-center py-16\">\n <div className=\"w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-5\" style={{ background: \"var(--color-status-success-bg)\", color: \"var(--color-status-success-text)\" }}>\n <CheckCircle2 size={32} strokeWidth={1.6} />\n </div>\n <h1 className=\"text-[1.5rem] font-semibold mb-2\" style={{ letterSpacing: \"-0.025em\" }}>\n {totalToAllocate} credits allocated\n </h1>\n <p className=\"text-sm mb-6\" style={{ color: \"var(--color-text-secondary)\" }}>\n Trainees have been notified by email.\n </p>\n <Link href={`/admin/subscriptions/${sub.id}`}>\n <Button variant=\"brand\" size=\"lg\">Back to subscription <ArrowRight size={13} /></Button>\n </Link>\n </div>\n </AdminShell>\n );\n }\n\n return (\n <AdminShell>\n <Link href={`/admin/subscriptions/${sub.id}`} className=\"inline-flex items-center gap-1.5 text-[12px] mb-4 transition-opacity hover:opacity-70\" style={{ color: \"var(--color-text-secondary)\" }}>\n <ArrowLeft size={13} /> Back to {sub.sponsorName}\n </Link>\n\n <div className=\"mb-6\">\n <h1 className=\"text-[1.75rem] font-semibold tracking-tight mb-1\" style={{ letterSpacing: \"-0.025em\" }}>\n Allocate credits\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n Move credits from <strong style={{ color: \"var(--color-text-primary)\" }}>{sub.sponsorName}</strong>'s pool to specific trainees.\n </p>\n </div>\n\n {/* Budget summary */}\n <Card className=\"mb-5\">\n <CardContent className=\"pt-4\">\n <div className=\"grid grid-cols-1 sm:grid-cols-3 gap-4\">\n <Stat label=\"Sponsor pool\" value={remaining.toString()} sub=\"available to allocate\" />\n <Stat label=\"Allocating now\" value={totalToAllocate.toString()} sub=\"credits selected\" />\n <Stat label=\"After allocation\" value={(remaining - totalToAllocate).toString()} sub=\"will remain\" accent={overBudget ? \"error\" : \"default\"} />\n </div>\n </CardContent>\n </Card>\n\n {overBudget && (\n <div className=\"mb-5\">\n <InlineAlert variant=\"error\" title=\"Insufficient credits in sponsor pool\">\n You're trying to allocate {totalToAllocate} but only {remaining} are available. Reduce the allocation or top up the pool first.\n </InlineAlert>\n </div>\n )}\n\n {/* Trainee picker */}\n <Card className=\"p-0 mb-5\">\n <Table>\n <TableHeader style={{ background: \"var(--color-bg-subtle)\" }}>\n <TableRow>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Trainee</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Procedures</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Status</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5 text-right\" style={{ color: \"var(--color-text-muted)\" }}>Allocate</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {eligible.map((t) => {\n const amount = amounts[t.id] ?? 0;\n return (\n <TableRow key={t.id}>\n <TableCell className=\"px-5 py-3\">\n <div>\n <div className=\"font-medium text-[13px]\">{t.name}</div>\n <div className=\"text-[11px]\" style={{ color: \"var(--color-text-muted)\" }}>{t.institution}</div>\n </div>\n </TableCell>\n <TableCell className=\"px-5 py-3\">\n <div className=\"flex flex-wrap gap-1\">\n {(t.procedures ?? []).map((p) => <Badge key={p} variant=\"outline\" className=\"text-[10px]\">{p}</Badge>)}\n </div>\n </TableCell>\n <TableCell className=\"px-5 py-3\">\n <Badge variant=\"outline\" className=\"text-[10px]\">{t.status.replace(\"_\", \" \")}</Badge>\n </TableCell>\n <TableCell className=\"px-5 py-3\">\n <div className=\"flex items-center gap-1.5 justify-end\">\n <Button variant=\"outline\" size=\"icon-sm\" onClick={() => setAmount(t.id, amount - 1)} disabled={amount === 0} aria-label=\"Decrease\"><Minus size={12} /></Button>\n <Input\n type=\"number\"\n value={amount || \"\"}\n onChange={(e) => setAmount(t.id, parseInt(e.target.value) || 0)}\n placeholder=\"0\"\n className=\"h-8 w-14 text-center tabular-nums\"\n />\n <Button variant=\"outline\" size=\"icon-sm\" onClick={() => setAmount(t.id, amount + 1)} aria-label=\"Increase\"><Plus size={12} /></Button>\n </div>\n </TableCell>\n </TableRow>\n );\n })}\n </TableBody>\n </Table>\n </Card>\n\n {/* Footer */}\n <div className=\"flex items-center justify-end gap-2\">\n <Link href={`/admin/subscriptions/${sub.id}`}>\n <Button variant=\"outline\">Cancel</Button>\n </Link>\n <Button\n variant=\"brand\"\n disabled={!canSubmit || saving}\n onClick={() => { setSaving(true); setTimeout(() => setDone(true), 800); }}\n >\n {saving ? <><Spinner className=\"w-3.5 h-3.5 mr-1\" /> Allocating…</> : <>Allocate {totalToAllocate} credits <ArrowRight size={13} /></>}\n </Button>\n </div>\n </AdminShell>\n );\n}\n\nfunction Stat({ label, value, sub, accent }: { label: string; value: string; sub: string; accent?: \"error\" | \"default\" }) {\n const color = accent === \"error\" ? \"var(--color-status-error-text)\" : \"var(--color-text-primary)\";\n return (\n <div>\n <div className=\"text-[10px] font-semibold uppercase tracking-wide mb-1\" style={{ color: \"var(--color-text-muted)\" }}>{label}</div>\n <div className=\"text-2xl font-semibold tabular-nums\" style={{ letterSpacing: \"-0.02em\", color }}>{value}</div>\n <div className=\"text-[11px] mt-0.5\" style={{ color: \"var(--color-text-muted)\" }}>{sub}</div>\n </div>\n );\n}\n"
|
|
42
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
|
|
3
|
+
"file": "app/admin/subscriptions/[id]/page.tsx",
|
|
4
|
+
"sha256": "90da1916703c2f480eae476c142acfd1a05a3e8814db106acbdd98d4e032dea5",
|
|
5
|
+
"imports": [
|
|
6
|
+
"next/link",
|
|
7
|
+
"react",
|
|
8
|
+
"lucide-react",
|
|
9
|
+
"@/components/ui/button",
|
|
10
|
+
"@/components/ui/badge",
|
|
11
|
+
"@/components/ui/card",
|
|
12
|
+
"@/components/ui/separator",
|
|
13
|
+
"@/components/ui/table",
|
|
14
|
+
"@/components/admin/admin-shell",
|
|
15
|
+
"@/components/portal/usage-sparkline",
|
|
16
|
+
"@/lib/credits-mock",
|
|
17
|
+
"@/lib/credits"
|
|
18
|
+
],
|
|
19
|
+
"tokens": [
|
|
20
|
+
"--color-bg-subtle",
|
|
21
|
+
"--color-border-default",
|
|
22
|
+
"--color-status-error-text",
|
|
23
|
+
"--color-text-muted",
|
|
24
|
+
"--color-text-primary",
|
|
25
|
+
"--color-text-secondary",
|
|
26
|
+
"--gray-950",
|
|
27
|
+
"--teal-300",
|
|
28
|
+
"--teal-500"
|
|
29
|
+
],
|
|
30
|
+
"fragments": [
|
|
31
|
+
{
|
|
32
|
+
"id": "SubscriptionDetailPage",
|
|
33
|
+
"line": 16,
|
|
34
|
+
"code": "export default function SubscriptionDetailPage({ params }: { params: Promise<{ id: string }> }) {\n const { id } = use(params);\n const sub = SUBSCRIPTIONS.find((s) => s.id === id);\n\n if (!sub) {\n return (\n <AdminShell>\n <div className=\"text-center py-20\">\n <p className=\"text-[14px] mb-4\" style={{ color: \"var(--color-text-secondary)\" }}>Subscription not found.</p>\n <Link href=\"/admin/subscriptions\"><Button variant=\"outline\">← Back to subscriptions</Button></Link>\n </div>\n </AdminShell>\n );\n }\n\n const tier = TIERS[sub.tier];\n const remaining = creditsRemaining(sub);\n const util = utilization(sub);\n const daysLeft = daysFrom(sub.endDate);\n const allocations = ALLOCATIONS_BY_SUB[sub.id] ?? [];\n\n return (\n <AdminShell>\n <Link href=\"/admin/subscriptions\" className=\"inline-flex items-center gap-1.5 text-[12px] mb-4 transition-opacity hover:opacity-70\" style={{ color: \"var(--color-text-secondary)\" }}>\n <ArrowLeft size={13} /> Back to subscriptions\n </Link>\n\n <div className=\"flex items-start justify-between gap-4 mb-6\">\n <div className=\"flex items-start gap-4\">\n <div className=\"w-12 h-12 rounded-xl flex items-center justify-center text-[14px] font-bold shrink-0\" style={{ background: \"var(--teal-500)\", color: \"var(--gray-950)\" }}>\n {sub.sponsorName.split(\" \").slice(0, 2).map((s) => s[0]).join(\"\")}\n </div>\n <div>\n <h1 className=\"text-[1.5rem] font-semibold tracking-tight\" style={{ letterSpacing: \"-0.025em\" }}>{sub.sponsorName}</h1>\n <div className=\"flex items-center gap-2 mt-1\">\n <Badge variant=\"outline\" className=\"capitalize\">{tier.name}</Badge>\n <Badge variant=\"outline\" className=\"capitalize\">{sub.status.replace(\"_\", \" \")}</Badge>\n <span className=\"text-[12px]\" style={{ color: \"var(--color-text-muted)\" }}>· Sub {sub.id}</span>\n </div>\n </div>\n </div>\n <div className=\"flex items-center gap-2\">\n <Button variant=\"outline\"><RefreshCcw size={13} /> Renew / Upgrade</Button>\n <Button variant=\"brand\" render={<Link href={`/admin/subscriptions/${sub.id}/allocate`} />}>\n <Plus size={13} /> Allocate credits\n </Button>\n </div>\n </div>\n\n {/* 2-column layout */}\n <div className=\"grid grid-cols-1 lg:grid-cols-[1fr_320px] gap-6\">\n <div className=\"space-y-6\">\n {/* Summary card */}\n <Card>\n <CardHeader>\n <div className=\"flex items-center justify-between\">\n <CardTitle className=\"text-base\">Credit pool</CardTitle>\n <span className=\"text-[11px]\" style={{ color: \"var(--color-text-muted)\" }}>\n {sub.creditsConsumed} of {sub.totalCredits} consumed\n </span>\n </div>\n </CardHeader>\n <CardContent>\n <div className=\"grid grid-cols-2 md:grid-cols-4 gap-4 mb-5\">\n <Metric label=\"Purchased\" value={sub.totalCredits.toString()} />\n <Metric label=\"Allocated\" value={sub.creditsAllocated.toString()} />\n <Metric label=\"Escrowed\" value={sub.creditsEscrowed.toString()} />\n <Metric label=\"Consumed\" value={sub.creditsConsumed.toString()} />\n </div>\n <div className=\"h-2 rounded-full overflow-hidden flex\" style={{ background: \"var(--color-border-default)\" }}>\n <div className=\"h-full\" style={{ width: `${(sub.creditsConsumed / sub.totalCredits) * 100}%`, background: \"var(--teal-500)\" }} />\n <div className=\"h-full\" style={{ width: `${(sub.creditsEscrowed / sub.totalCredits) * 100}%`, background: \"var(--teal-300)\" }} />\n <div className=\"h-full\" style={{ width: `${((sub.creditsAllocated - sub.creditsConsumed - sub.creditsEscrowed) / sub.totalCredits) * 100}%`, background: \"color-mix(in srgb, var(--teal-500) 25%, transparent)\" }} />\n </div>\n </CardContent>\n </Card>\n\n {/* Trend */}\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Consumption trend</CardTitle>\n <CardDescription>Cumulative credits consumed.</CardDescription>\n </CardHeader>\n <CardContent>\n <UsageSparkline data={CONSUMPTION_HISTORY[sub.id] ?? []} max={sub.totalCredits} />\n </CardContent>\n </Card>\n\n {/* Allocations */}\n <Card className=\"p-0\">\n <CardHeader className=\"px-4 py-4\">\n <div className=\"flex items-center justify-between\">\n <div>\n <CardTitle className=\"text-base\">Trainee allocations</CardTitle>\n <CardDescription>{allocations.length} sponsored trainees</CardDescription>\n </div>\n <Button variant=\"outline\" size=\"sm\" render={<Link href={`/admin/subscriptions/${sub.id}/allocate`} />}>\n <Plus size={11} /> Allocate\n </Button>\n </div>\n </CardHeader>\n <Table>\n <TableHeader style={{ background: \"var(--color-bg-subtle)\" }}>\n <TableRow>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-4\" style={{ color: \"var(--color-text-muted)\" }}>Trainee</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-4 text-right\" style={{ color: \"var(--color-text-muted)\" }}>Allocated</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-4 text-right\" style={{ color: \"var(--color-text-muted)\" }}>Consumed</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-4 text-right\" style={{ color: \"var(--color-text-muted)\" }}>Remaining</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {allocations.map((a) => (\n <TableRow key={a.traineeId}>\n <TableCell className=\"px-4 py-2.5 text-[12px] font-medium\">{a.traineeName}</TableCell>\n <TableCell className=\"px-4 py-2.5 text-right text-[12px] tabular-nums\">{a.allocated}</TableCell>\n <TableCell className=\"px-4 py-2.5 text-right text-[12px] tabular-nums\" style={{ color: \"var(--color-text-secondary)\" }}>{a.consumed}</TableCell>\n <TableCell className=\"px-4 py-2.5 text-right text-[12px] tabular-nums font-medium\" style={{ color: a.remaining === 0 ? \"var(--color-status-error-text)\" : \"var(--color-text-primary)\" }}>\n {a.remaining}\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </Table>\n </Card>\n </div>\n\n {/* Sidebar */}\n <div className=\"space-y-4\">\n <Card>\n <CardHeader>\n <CardTitle className=\"text-[13px] flex items-center gap-2\">\n <Building2 size={13} /> Sponsor\n </CardTitle>\n </CardHeader>\n <CardContent>\n <dl className=\"space-y-2.5 text-[12px]\">\n <DRow label=\"Primary contact\" value={sub.primaryContactName} />\n <DRow label=\"Email\" value={sub.primaryContactEmail} />\n </dl>\n </CardContent>\n </Card>\n\n <Card>\n <CardHeader>\n <CardTitle className=\"text-[13px] flex items-center gap-2\">\n <CalendarIcon size={13} /> Subscription\n </CardTitle>\n </CardHeader>\n <CardContent>\n <dl className=\"space-y-2.5 text-[12px]\">\n <DRow label=\"Start\" value={sub.startDate} />\n <DRow label=\"End\" value={`${sub.endDate} · ${daysLeft}d left`} />\n <DRow label=\"Package fee\" value={gbp(sub.packageFee)} />\n <DRow label=\"Access fee\" value={gbp(sub.platformAccessFee)} />\n <DRow label=\"Total annual\" value={gbp(sub.packageFee + sub.platformAccessFee)} />\n </dl>\n </CardContent>\n </Card>\n </div>\n </div>\n </AdminShell>\n );\n}"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"id": "Metric",
|
|
38
|
+
"line": 180,
|
|
39
|
+
"code": "function Metric({ label, value }: { label: string; value: string }) {\n return (\n <div>\n <div className=\"text-[10px] font-semibold uppercase tracking-wide mb-1\" style={{ color: \"var(--color-text-muted)\" }}>{label}</div>\n <div className=\"text-2xl font-semibold tabular-nums\" style={{ letterSpacing: \"-0.02em\" }}>{value}</div>\n </div>\n );\n}"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"id": "DRow",
|
|
43
|
+
"line": 189,
|
|
44
|
+
"code": "function DRow({ label, value }: { label: string; value: string }) {\n return (\n <div className=\"flex items-start justify-between gap-3\">\n <dt className=\"shrink-0\" style={{ color: \"var(--color-text-muted)\" }}>{label}</dt>\n <dd className=\"text-right\" style={{ color: \"var(--color-text-primary)\" }}>{value}</dd>\n </div>\n );\n}"
|
|
45
|
+
}
|
|
46
|
+
],
|
|
47
|
+
"content": "\"use client\";\n\nimport Link from \"next/link\";\nimport { use } from \"react\";\nimport { ArrowLeft, ArrowRight, Coins, Calendar as CalendarIcon, Mail, Building2, Plus, RefreshCcw } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Card, CardHeader, CardTitle, CardDescription, CardContent } from \"@/components/ui/card\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from \"@/components/ui/table\";\nimport { AdminShell } from \"@/components/admin/admin-shell\";\nimport { UsageSparkline } from \"@/components/portal/usage-sparkline\";\nimport { SUBSCRIPTIONS, ALLOCATIONS_BY_SUB, CONSUMPTION_HISTORY } from \"@/lib/credits-mock\";\nimport { TIERS, creditsRemaining, utilization, gbp, daysFrom } from \"@/lib/credits\";\n\nexport default function SubscriptionDetailPage({ params }: { params: Promise<{ id: string }> }) {\n const { id } = use(params);\n const sub = SUBSCRIPTIONS.find((s) => s.id === id);\n\n if (!sub) {\n return (\n <AdminShell>\n <div className=\"text-center py-20\">\n <p className=\"text-[14px] mb-4\" style={{ color: \"var(--color-text-secondary)\" }}>Subscription not found.</p>\n <Link href=\"/admin/subscriptions\"><Button variant=\"outline\">← Back to subscriptions</Button></Link>\n </div>\n </AdminShell>\n );\n }\n\n const tier = TIERS[sub.tier];\n const remaining = creditsRemaining(sub);\n const util = utilization(sub);\n const daysLeft = daysFrom(sub.endDate);\n const allocations = ALLOCATIONS_BY_SUB[sub.id] ?? [];\n\n return (\n <AdminShell>\n <Link href=\"/admin/subscriptions\" className=\"inline-flex items-center gap-1.5 text-[12px] mb-4 transition-opacity hover:opacity-70\" style={{ color: \"var(--color-text-secondary)\" }}>\n <ArrowLeft size={13} /> Back to subscriptions\n </Link>\n\n <div className=\"flex items-start justify-between gap-4 mb-6\">\n <div className=\"flex items-start gap-4\">\n <div className=\"w-12 h-12 rounded-xl flex items-center justify-center text-[14px] font-bold shrink-0\" style={{ background: \"var(--teal-500)\", color: \"var(--gray-950)\" }}>\n {sub.sponsorName.split(\" \").slice(0, 2).map((s) => s[0]).join(\"\")}\n </div>\n <div>\n <h1 className=\"text-[1.5rem] font-semibold tracking-tight\" style={{ letterSpacing: \"-0.025em\" }}>{sub.sponsorName}</h1>\n <div className=\"flex items-center gap-2 mt-1\">\n <Badge variant=\"outline\" className=\"capitalize\">{tier.name}</Badge>\n <Badge variant=\"outline\" className=\"capitalize\">{sub.status.replace(\"_\", \" \")}</Badge>\n <span className=\"text-[12px]\" style={{ color: \"var(--color-text-muted)\" }}>· Sub {sub.id}</span>\n </div>\n </div>\n </div>\n <div className=\"flex items-center gap-2\">\n <Button variant=\"outline\"><RefreshCcw size={13} /> Renew / Upgrade</Button>\n <Button variant=\"brand\" render={<Link href={`/admin/subscriptions/${sub.id}/allocate`} />}>\n <Plus size={13} /> Allocate credits\n </Button>\n </div>\n </div>\n\n {/* 2-column layout */}\n <div className=\"grid grid-cols-1 lg:grid-cols-[1fr_320px] gap-6\">\n <div className=\"space-y-6\">\n {/* Summary card */}\n <Card>\n <CardHeader>\n <div className=\"flex items-center justify-between\">\n <CardTitle className=\"text-base\">Credit pool</CardTitle>\n <span className=\"text-[11px]\" style={{ color: \"var(--color-text-muted)\" }}>\n {sub.creditsConsumed} of {sub.totalCredits} consumed\n </span>\n </div>\n </CardHeader>\n <CardContent>\n <div className=\"grid grid-cols-2 md:grid-cols-4 gap-4 mb-5\">\n <Metric label=\"Purchased\" value={sub.totalCredits.toString()} />\n <Metric label=\"Allocated\" value={sub.creditsAllocated.toString()} />\n <Metric label=\"Escrowed\" value={sub.creditsEscrowed.toString()} />\n <Metric label=\"Consumed\" value={sub.creditsConsumed.toString()} />\n </div>\n <div className=\"h-2 rounded-full overflow-hidden flex\" style={{ background: \"var(--color-border-default)\" }}>\n <div className=\"h-full\" style={{ width: `${(sub.creditsConsumed / sub.totalCredits) * 100}%`, background: \"var(--teal-500)\" }} />\n <div className=\"h-full\" style={{ width: `${(sub.creditsEscrowed / sub.totalCredits) * 100}%`, background: \"var(--teal-300)\" }} />\n <div className=\"h-full\" style={{ width: `${((sub.creditsAllocated - sub.creditsConsumed - sub.creditsEscrowed) / sub.totalCredits) * 100}%`, background: \"color-mix(in srgb, var(--teal-500) 25%, transparent)\" }} />\n </div>\n </CardContent>\n </Card>\n\n {/* Trend */}\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Consumption trend</CardTitle>\n <CardDescription>Cumulative credits consumed.</CardDescription>\n </CardHeader>\n <CardContent>\n <UsageSparkline data={CONSUMPTION_HISTORY[sub.id] ?? []} max={sub.totalCredits} />\n </CardContent>\n </Card>\n\n {/* Allocations */}\n <Card className=\"p-0\">\n <CardHeader className=\"px-4 py-4\">\n <div className=\"flex items-center justify-between\">\n <div>\n <CardTitle className=\"text-base\">Trainee allocations</CardTitle>\n <CardDescription>{allocations.length} sponsored trainees</CardDescription>\n </div>\n <Button variant=\"outline\" size=\"sm\" render={<Link href={`/admin/subscriptions/${sub.id}/allocate`} />}>\n <Plus size={11} /> Allocate\n </Button>\n </div>\n </CardHeader>\n <Table>\n <TableHeader style={{ background: \"var(--color-bg-subtle)\" }}>\n <TableRow>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-4\" style={{ color: \"var(--color-text-muted)\" }}>Trainee</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-4 text-right\" style={{ color: \"var(--color-text-muted)\" }}>Allocated</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-4 text-right\" style={{ color: \"var(--color-text-muted)\" }}>Consumed</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-4 text-right\" style={{ color: \"var(--color-text-muted)\" }}>Remaining</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {allocations.map((a) => (\n <TableRow key={a.traineeId}>\n <TableCell className=\"px-4 py-2.5 text-[12px] font-medium\">{a.traineeName}</TableCell>\n <TableCell className=\"px-4 py-2.5 text-right text-[12px] tabular-nums\">{a.allocated}</TableCell>\n <TableCell className=\"px-4 py-2.5 text-right text-[12px] tabular-nums\" style={{ color: \"var(--color-text-secondary)\" }}>{a.consumed}</TableCell>\n <TableCell className=\"px-4 py-2.5 text-right text-[12px] tabular-nums font-medium\" style={{ color: a.remaining === 0 ? \"var(--color-status-error-text)\" : \"var(--color-text-primary)\" }}>\n {a.remaining}\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </Table>\n </Card>\n </div>\n\n {/* Sidebar */}\n <div className=\"space-y-4\">\n <Card>\n <CardHeader>\n <CardTitle className=\"text-[13px] flex items-center gap-2\">\n <Building2 size={13} /> Sponsor\n </CardTitle>\n </CardHeader>\n <CardContent>\n <dl className=\"space-y-2.5 text-[12px]\">\n <DRow label=\"Primary contact\" value={sub.primaryContactName} />\n <DRow label=\"Email\" value={sub.primaryContactEmail} />\n </dl>\n </CardContent>\n </Card>\n\n <Card>\n <CardHeader>\n <CardTitle className=\"text-[13px] flex items-center gap-2\">\n <CalendarIcon size={13} /> Subscription\n </CardTitle>\n </CardHeader>\n <CardContent>\n <dl className=\"space-y-2.5 text-[12px]\">\n <DRow label=\"Start\" value={sub.startDate} />\n <DRow label=\"End\" value={`${sub.endDate} · ${daysLeft}d left`} />\n <DRow label=\"Package fee\" value={gbp(sub.packageFee)} />\n <DRow label=\"Access fee\" value={gbp(sub.platformAccessFee)} />\n <DRow label=\"Total annual\" value={gbp(sub.packageFee + sub.platformAccessFee)} />\n </dl>\n </CardContent>\n </Card>\n </div>\n </div>\n </AdminShell>\n );\n}\n\nfunction Metric({ label, value }: { label: string; value: string }) {\n return (\n <div>\n <div className=\"text-[10px] font-semibold uppercase tracking-wide mb-1\" style={{ color: \"var(--color-text-muted)\" }}>{label}</div>\n <div className=\"text-2xl font-semibold tabular-nums\" style={{ letterSpacing: \"-0.02em\" }}>{value}</div>\n </div>\n );\n}\n\nfunction DRow({ label, value }: { label: string; value: string }) {\n return (\n <div className=\"flex items-start justify-between gap-3\">\n <dt className=\"shrink-0\" style={{ color: \"var(--color-text-muted)\" }}>{label}</dt>\n <dd className=\"text-right\" style={{ color: \"var(--color-text-primary)\" }}>{value}</dd>\n </div>\n );\n}\n"
|
|
48
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
|
|
3
|
+
"file": "app/admin/subscriptions/new/page.tsx",
|
|
4
|
+
"sha256": "90f7ac72bdbfbb4c14d1b2e584c706021fed70af4434ee24b2a13d81e8cba7d7",
|
|
5
|
+
"imports": [
|
|
6
|
+
"next/link",
|
|
7
|
+
"react",
|
|
8
|
+
"lucide-react",
|
|
9
|
+
"@/components/ui/button",
|
|
10
|
+
"@/components/ui/input",
|
|
11
|
+
"@/components/ui/card",
|
|
12
|
+
"@/components/ui/separator",
|
|
13
|
+
"@/components/admin/admin-shell",
|
|
14
|
+
"@/components/auth/auth-primitives",
|
|
15
|
+
"@/components/auth/onboarding-primitives",
|
|
16
|
+
"@/lib/credits"
|
|
17
|
+
],
|
|
18
|
+
"tokens": [
|
|
19
|
+
"--color-bg-brand-muted",
|
|
20
|
+
"--color-border-brand",
|
|
21
|
+
"--color-border-default",
|
|
22
|
+
"--color-status-success-bg",
|
|
23
|
+
"--color-status-success-text",
|
|
24
|
+
"--color-text-brand",
|
|
25
|
+
"--color-text-muted",
|
|
26
|
+
"--color-text-primary",
|
|
27
|
+
"--color-text-secondary",
|
|
28
|
+
"--teal-500"
|
|
29
|
+
],
|
|
30
|
+
"fragments": [
|
|
31
|
+
{
|
|
32
|
+
"id": "NewSubscriptionPage",
|
|
33
|
+
"line": 15,
|
|
34
|
+
"code": "export default function NewSubscriptionPage() {\n const [sponsorName, setSponsorName] = useState(\"\");\n const [contactName, setContactName] = useState(\"\");\n const [contactEmail, setContactEmail] = useState(\"\");\n const [tier, setTier] = useState<Tier>(\"growth\");\n const [credits, setCredits] = useState<number>(20);\n const [accessFee, setAccessFee] = useState<number>(15_000);\n const [startDate, setStartDate] = useState<string>(new Date().toISOString().slice(0, 10));\n const [saving, setSaving] = useState(false);\n const [done, setDone] = useState(false);\n\n // Auto-populate when tier changes\n const onTier = (next: Tier) => {\n setTier(next);\n const cfg = TIERS[next];\n setCredits(typeof cfg.sessions === \"number\" ? cfg.sessions : 500);\n setAccessFee(cfg.platformAccessFee);\n };\n\n const cfg = TIERS[tier];\n const totalFee = cfg.packageFee + accessFee;\n\n // End date = start + 12 months\n const endDate = useMemo(() => {\n const d = new Date(startDate);\n d.setFullYear(d.getFullYear() + 1);\n return d.toISOString().slice(0, 10);\n }, [startDate]);\n\n const canSubmit = sponsorName && contactName && /.+@.+\\..+/.test(contactEmail) && startDate;\n\n if (done) {\n return (\n <AdminShell>\n <div className=\"max-w-md mx-auto text-center py-16\">\n <div className=\"w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-5\" style={{ background: \"var(--color-status-success-bg)\", color: \"var(--color-status-success-text)\" }}>\n <CheckCircle2 size={32} strokeWidth={1.6} />\n </div>\n <h1 className=\"text-[1.5rem] font-semibold mb-2\" style={{ letterSpacing: \"-0.025em\" }}>\n Subscription created\n </h1>\n <p className=\"text-sm mb-6\" style={{ color: \"var(--color-text-secondary)\" }}>\n A welcome email has been sent to <strong style={{ color: \"var(--color-text-primary)\" }}>{contactEmail}</strong>.\n </p>\n <div className=\"flex items-center gap-2 justify-center\">\n <Link href=\"/admin/subscriptions\"><Button variant=\"outline\">All subscriptions</Button></Link>\n <Link href=\"/admin/subscriptions/sub-001/allocate\"><Button variant=\"brand\">Allocate credits <ArrowRight size={13} /></Button></Link>\n </div>\n </div>\n </AdminShell>\n );\n }\n\n return (\n <AdminShell>\n <Link href=\"/admin/subscriptions\" className=\"inline-flex items-center gap-1.5 text-[12px] mb-4 transition-opacity hover:opacity-70\" style={{ color: \"var(--color-text-secondary)\" }}>\n <ArrowLeft size={13} /> Back\n </Link>\n\n <div className=\"mb-6\">\n <h1 className=\"text-[1.75rem] font-semibold tracking-tight mb-1\" style={{ letterSpacing: \"-0.025em\" }}>\n New subscription\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n Create a new Industry Partner subscription. Welcome email is sent on creation.\n </p>\n </div>\n\n <div className=\"grid grid-cols-1 lg:grid-cols-[1fr_320px] gap-6\">\n {/* Form */}\n <form onSubmit={(e) => { e.preventDefault(); setSaving(true); setTimeout(() => setDone(true), 800); }} className=\"space-y-5\">\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Sponsor details</CardTitle>\n <CardDescription>Industry partner organisation and primary contact.</CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <Field label=\"Sponsor organisation name\" htmlFor=\"sn\">\n <Input id=\"sn\" value={sponsorName} onChange={(e) => setSponsorName(e.target.value)} placeholder=\"Olympus Medical UK\" className=\"h-10\" />\n </Field>\n <div className=\"grid grid-cols-2 gap-3\">\n <Field label=\"Contact name\" htmlFor=\"cn\">\n <Input id=\"cn\" value={contactName} onChange={(e) => setContactName(e.target.value)} placeholder=\"Helen Chen\" className=\"h-10\" />\n </Field>\n <Field label=\"Contact email\" htmlFor=\"ce\">\n <Input id=\"ce\" type=\"email\" value={contactEmail} onChange={(e) => setContactEmail(e.target.value)} placeholder=\"h.chen@olympus.co.uk\" className=\"h-10\" />\n </Field>\n </div>\n </CardContent>\n </Card>\n\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Subscription package</CardTitle>\n <CardDescription>Choose a tier — credits, per-session price, and fee auto-fill.</CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <Field label=\"Tier\" htmlFor=\"tier\">\n <div className=\"grid grid-cols-2 lg:grid-cols-4 gap-2\">\n {(Object.keys(TIERS) as Tier[]).map((t) => {\n const c = TIERS[t];\n const active = tier === t;\n return (\n <button\n key={t}\n type=\"button\"\n onClick={() => onTier(t)}\n className=\"text-left p-3 rounded-lg border transition-all focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-[var(--teal-500)]/30\"\n style={{\n borderColor: active ? \"var(--color-border-brand)\" : \"var(--color-border-default)\",\n background: active ? \"var(--color-bg-brand-muted)\" : \"transparent\",\n }}\n >\n <div className=\"text-[13px] font-semibold\" style={{ color: active ? \"var(--color-text-brand)\" : \"var(--color-text-primary)\" }}>{c.name}</div>\n <div className=\"text-[11px] mt-0.5\" style={{ color: \"var(--color-text-muted)\" }}>\n {c.sessions === \"unlimited\" ? \"Unlimited\" : `${c.sessions} credits`}\n </div>\n <div className=\"text-[11px]\" style={{ color: \"var(--color-text-muted)\" }}>\n {gbp(c.perSessionPrice)}/session\n </div>\n </button>\n );\n })}\n </div>\n </Field>\n\n <div className=\"grid grid-cols-2 gap-3\">\n <Field label=\"Total credits\" htmlFor=\"tc\" hint={tier === \"enterprise\" ? \"Soft cap — credits tracked but not enforced.\" : \"Auto-set by tier.\"}>\n <Input id=\"tc\" type=\"number\" value={credits} onChange={(e) => setCredits(parseInt(e.target.value) || 0)} className=\"h-10 tabular-nums\" />\n </Field>\n <Field label=\"Platform access fee (£)\" htmlFor=\"af\" hint={tier === \"enterprise\" ? \"Custom for Enterprise.\" : \"Default £15k.\"}>\n <Input id=\"af\" type=\"number\" value={accessFee} onChange={(e) => setAccessFee(parseInt(e.target.value) || 0)} className=\"h-10 tabular-nums\" />\n </Field>\n </div>\n\n <Field label=\"Subscription start date\" htmlFor=\"sd\" hint={`End: ${endDate} (12 months)`}>\n <Input id=\"sd\" type=\"date\" value={startDate} onChange={(e) => setStartDate(e.target.value)} className=\"h-10\" />\n </Field>\n </CardContent>\n </Card>\n\n <div className=\"flex items-center justify-end gap-2\">\n <Link href=\"/admin/subscriptions\"><Button type=\"button\" variant=\"outline\">Cancel</Button></Link>\n <Button type=\"submit\" variant=\"brand\" disabled={!canSubmit || saving}>\n {saving ? <><Spinner className=\"w-3.5 h-3.5 mr-1\" /> Creating…</> : <>Create subscription <ArrowRight size={13} /></>}\n </Button>\n </div>\n </form>\n\n {/* Invoice preview */}\n <Card className=\"h-fit sticky top-20\">\n <CardHeader>\n <CardTitle className=\"text-[13px]\">Invoice preview</CardTitle>\n <CardDescription>Auto-generated from tier selection.</CardDescription>\n </CardHeader>\n <CardContent>\n <dl className=\"space-y-2.5 text-[12px]\">\n <DRow label=\"Tier\" value={cfg.name} />\n <DRow label=\"Credits\" value={typeof cfg.sessions === \"number\" ? `${credits}` : \"Unlimited\"} />\n <DRow label=\"Per session\" value={gbp(cfg.perSessionPrice)} />\n </dl>\n <Separator className=\"my-3\" />\n <dl className=\"space-y-2.5 text-[12px]\">\n <DRow label=\"Package fee\" value={gbp(cfg.packageFee)} />\n <DRow label=\"Platform access fee\" value={gbp(accessFee)} />\n </dl>\n <Separator className=\"my-3\" />\n <dl className=\"space-y-2.5 text-[13px]\">\n <DRow label=\"Total billed (annual)\" value={gbp(cfg.packageFee + accessFee)} bold />\n </dl>\n </CardContent>\n </Card>\n </div>\n </AdminShell>\n );\n}"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"id": "DRow",
|
|
38
|
+
"line": 192,
|
|
39
|
+
"code": "function DRow({ label, value, bold }: { label: string; value: string; bold?: boolean }) {\n return (\n <div className=\"flex items-center justify-between\" style={{ fontWeight: bold ? 600 : 400 }}>\n <dt style={{ color: \"var(--color-text-muted)\" }}>{label}</dt>\n <dd style={{ color: \"var(--color-text-primary)\" }}>{value}</dd>\n </div>\n );\n}"
|
|
40
|
+
}
|
|
41
|
+
],
|
|
42
|
+
"content": "\"use client\";\n\nimport Link from \"next/link\";\nimport { useState, useMemo } from \"react\";\nimport { ArrowLeft, ArrowRight, CheckCircle2 } 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 { AdminShell } from \"@/components/admin/admin-shell\";\nimport { Field, Spinner } from \"@/components/auth/auth-primitives\";\nimport { Select } from \"@/components/auth/onboarding-primitives\";\nimport { TIERS, gbp, type Tier } from \"@/lib/credits\";\n\nexport default function NewSubscriptionPage() {\n const [sponsorName, setSponsorName] = useState(\"\");\n const [contactName, setContactName] = useState(\"\");\n const [contactEmail, setContactEmail] = useState(\"\");\n const [tier, setTier] = useState<Tier>(\"growth\");\n const [credits, setCredits] = useState<number>(20);\n const [accessFee, setAccessFee] = useState<number>(15_000);\n const [startDate, setStartDate] = useState<string>(new Date().toISOString().slice(0, 10));\n const [saving, setSaving] = useState(false);\n const [done, setDone] = useState(false);\n\n // Auto-populate when tier changes\n const onTier = (next: Tier) => {\n setTier(next);\n const cfg = TIERS[next];\n setCredits(typeof cfg.sessions === \"number\" ? cfg.sessions : 500);\n setAccessFee(cfg.platformAccessFee);\n };\n\n const cfg = TIERS[tier];\n const totalFee = cfg.packageFee + accessFee;\n\n // End date = start + 12 months\n const endDate = useMemo(() => {\n const d = new Date(startDate);\n d.setFullYear(d.getFullYear() + 1);\n return d.toISOString().slice(0, 10);\n }, [startDate]);\n\n const canSubmit = sponsorName && contactName && /.+@.+\\..+/.test(contactEmail) && startDate;\n\n if (done) {\n return (\n <AdminShell>\n <div className=\"max-w-md mx-auto text-center py-16\">\n <div className=\"w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-5\" style={{ background: \"var(--color-status-success-bg)\", color: \"var(--color-status-success-text)\" }}>\n <CheckCircle2 size={32} strokeWidth={1.6} />\n </div>\n <h1 className=\"text-[1.5rem] font-semibold mb-2\" style={{ letterSpacing: \"-0.025em\" }}>\n Subscription created\n </h1>\n <p className=\"text-sm mb-6\" style={{ color: \"var(--color-text-secondary)\" }}>\n A welcome email has been sent to <strong style={{ color: \"var(--color-text-primary)\" }}>{contactEmail}</strong>.\n </p>\n <div className=\"flex items-center gap-2 justify-center\">\n <Link href=\"/admin/subscriptions\"><Button variant=\"outline\">All subscriptions</Button></Link>\n <Link href=\"/admin/subscriptions/sub-001/allocate\"><Button variant=\"brand\">Allocate credits <ArrowRight size={13} /></Button></Link>\n </div>\n </div>\n </AdminShell>\n );\n }\n\n return (\n <AdminShell>\n <Link href=\"/admin/subscriptions\" className=\"inline-flex items-center gap-1.5 text-[12px] mb-4 transition-opacity hover:opacity-70\" style={{ color: \"var(--color-text-secondary)\" }}>\n <ArrowLeft size={13} /> Back\n </Link>\n\n <div className=\"mb-6\">\n <h1 className=\"text-[1.75rem] font-semibold tracking-tight mb-1\" style={{ letterSpacing: \"-0.025em\" }}>\n New subscription\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n Create a new Industry Partner subscription. Welcome email is sent on creation.\n </p>\n </div>\n\n <div className=\"grid grid-cols-1 lg:grid-cols-[1fr_320px] gap-6\">\n {/* Form */}\n <form onSubmit={(e) => { e.preventDefault(); setSaving(true); setTimeout(() => setDone(true), 800); }} className=\"space-y-5\">\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Sponsor details</CardTitle>\n <CardDescription>Industry partner organisation and primary contact.</CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <Field label=\"Sponsor organisation name\" htmlFor=\"sn\">\n <Input id=\"sn\" value={sponsorName} onChange={(e) => setSponsorName(e.target.value)} placeholder=\"Olympus Medical UK\" className=\"h-10\" />\n </Field>\n <div className=\"grid grid-cols-2 gap-3\">\n <Field label=\"Contact name\" htmlFor=\"cn\">\n <Input id=\"cn\" value={contactName} onChange={(e) => setContactName(e.target.value)} placeholder=\"Helen Chen\" className=\"h-10\" />\n </Field>\n <Field label=\"Contact email\" htmlFor=\"ce\">\n <Input id=\"ce\" type=\"email\" value={contactEmail} onChange={(e) => setContactEmail(e.target.value)} placeholder=\"h.chen@olympus.co.uk\" className=\"h-10\" />\n </Field>\n </div>\n </CardContent>\n </Card>\n\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Subscription package</CardTitle>\n <CardDescription>Choose a tier — credits, per-session price, and fee auto-fill.</CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <Field label=\"Tier\" htmlFor=\"tier\">\n <div className=\"grid grid-cols-2 lg:grid-cols-4 gap-2\">\n {(Object.keys(TIERS) as Tier[]).map((t) => {\n const c = TIERS[t];\n const active = tier === t;\n return (\n <button\n key={t}\n type=\"button\"\n onClick={() => onTier(t)}\n className=\"text-left p-3 rounded-lg border transition-all focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-[var(--teal-500)]/30\"\n style={{\n borderColor: active ? \"var(--color-border-brand)\" : \"var(--color-border-default)\",\n background: active ? \"var(--color-bg-brand-muted)\" : \"transparent\",\n }}\n >\n <div className=\"text-[13px] font-semibold\" style={{ color: active ? \"var(--color-text-brand)\" : \"var(--color-text-primary)\" }}>{c.name}</div>\n <div className=\"text-[11px] mt-0.5\" style={{ color: \"var(--color-text-muted)\" }}>\n {c.sessions === \"unlimited\" ? \"Unlimited\" : `${c.sessions} credits`}\n </div>\n <div className=\"text-[11px]\" style={{ color: \"var(--color-text-muted)\" }}>\n {gbp(c.perSessionPrice)}/session\n </div>\n </button>\n );\n })}\n </div>\n </Field>\n\n <div className=\"grid grid-cols-2 gap-3\">\n <Field label=\"Total credits\" htmlFor=\"tc\" hint={tier === \"enterprise\" ? \"Soft cap — credits tracked but not enforced.\" : \"Auto-set by tier.\"}>\n <Input id=\"tc\" type=\"number\" value={credits} onChange={(e) => setCredits(parseInt(e.target.value) || 0)} className=\"h-10 tabular-nums\" />\n </Field>\n <Field label=\"Platform access fee (£)\" htmlFor=\"af\" hint={tier === \"enterprise\" ? \"Custom for Enterprise.\" : \"Default £15k.\"}>\n <Input id=\"af\" type=\"number\" value={accessFee} onChange={(e) => setAccessFee(parseInt(e.target.value) || 0)} className=\"h-10 tabular-nums\" />\n </Field>\n </div>\n\n <Field label=\"Subscription start date\" htmlFor=\"sd\" hint={`End: ${endDate} (12 months)`}>\n <Input id=\"sd\" type=\"date\" value={startDate} onChange={(e) => setStartDate(e.target.value)} className=\"h-10\" />\n </Field>\n </CardContent>\n </Card>\n\n <div className=\"flex items-center justify-end gap-2\">\n <Link href=\"/admin/subscriptions\"><Button type=\"button\" variant=\"outline\">Cancel</Button></Link>\n <Button type=\"submit\" variant=\"brand\" disabled={!canSubmit || saving}>\n {saving ? <><Spinner className=\"w-3.5 h-3.5 mr-1\" /> Creating…</> : <>Create subscription <ArrowRight size={13} /></>}\n </Button>\n </div>\n </form>\n\n {/* Invoice preview */}\n <Card className=\"h-fit sticky top-20\">\n <CardHeader>\n <CardTitle className=\"text-[13px]\">Invoice preview</CardTitle>\n <CardDescription>Auto-generated from tier selection.</CardDescription>\n </CardHeader>\n <CardContent>\n <dl className=\"space-y-2.5 text-[12px]\">\n <DRow label=\"Tier\" value={cfg.name} />\n <DRow label=\"Credits\" value={typeof cfg.sessions === \"number\" ? `${credits}` : \"Unlimited\"} />\n <DRow label=\"Per session\" value={gbp(cfg.perSessionPrice)} />\n </dl>\n <Separator className=\"my-3\" />\n <dl className=\"space-y-2.5 text-[12px]\">\n <DRow label=\"Package fee\" value={gbp(cfg.packageFee)} />\n <DRow label=\"Platform access fee\" value={gbp(accessFee)} />\n </dl>\n <Separator className=\"my-3\" />\n <dl className=\"space-y-2.5 text-[13px]\">\n <DRow label=\"Total billed (annual)\" value={gbp(cfg.packageFee + accessFee)} bold />\n </dl>\n </CardContent>\n </Card>\n </div>\n </AdminShell>\n );\n}\n\nfunction DRow({ label, value, bold }: { label: string; value: string; bold?: boolean }) {\n return (\n <div className=\"flex items-center justify-between\" style={{ fontWeight: bold ? 600 : 400 }}>\n <dt style={{ color: \"var(--color-text-muted)\" }}>{label}</dt>\n <dd style={{ color: \"var(--color-text-primary)\" }}>{value}</dd>\n </div>\n );\n}\n"
|
|
43
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
|
|
3
|
+
"file": "app/admin/subscriptions/page.tsx",
|
|
4
|
+
"sha256": "a9fab9d012c7b2352f6a05342b706fdc1e5d014a1e9966b6bc78cb81b9129bff",
|
|
5
|
+
"imports": [
|
|
6
|
+
"next/link",
|
|
7
|
+
"react",
|
|
8
|
+
"lucide-react",
|
|
9
|
+
"@/components/ui/input",
|
|
10
|
+
"@/components/ui/button",
|
|
11
|
+
"@/components/ui/badge",
|
|
12
|
+
"@/components/ui/card",
|
|
13
|
+
"@/components/ui/table",
|
|
14
|
+
"@/components/admin/admin-shell",
|
|
15
|
+
"@/lib/credits-mock",
|
|
16
|
+
"@/lib/credits"
|
|
17
|
+
],
|
|
18
|
+
"tokens": [
|
|
19
|
+
"--color-bg-subtle",
|
|
20
|
+
"--color-border-default",
|
|
21
|
+
"--color-status-error-text",
|
|
22
|
+
"--color-status-warning-text",
|
|
23
|
+
"--color-text-muted",
|
|
24
|
+
"--color-text-primary",
|
|
25
|
+
"--color-text-secondary",
|
|
26
|
+
"--teal-500"
|
|
27
|
+
],
|
|
28
|
+
"fragments": [
|
|
29
|
+
{
|
|
30
|
+
"id": "SubscriptionsListPage",
|
|
31
|
+
"line": 15,
|
|
32
|
+
"code": "export default function SubscriptionsListPage() {\n const [q, setQ] = useState(\"\");\n\n const filtered = useMemo(() =>\n SUBSCRIPTIONS.filter((s) =>\n !q ||\n s.sponsorName.toLowerCase().includes(q.toLowerCase()) ||\n s.primaryContactName.toLowerCase().includes(q.toLowerCase())\n ),\n [q]\n );\n\n const totals = useMemo(() => {\n const totalCredits = SUBSCRIPTIONS.reduce((a, s) => a + s.totalCredits, 0);\n const consumed = SUBSCRIPTIONS.reduce((a, s) => a + s.creditsConsumed, 0);\n const annual = SUBSCRIPTIONS.reduce((a, s) => a + s.packageFee + s.platformAccessFee, 0);\n return { totalCredits, consumed, annual };\n }, []);\n\n return (\n <AdminShell>\n <div className=\"flex items-start justify-between gap-4 mb-6\">\n <div>\n <h1 className=\"text-[1.75rem] font-semibold tracking-tight mb-1\" style={{ letterSpacing: \"-0.025em\" }}>\n Subscriptions\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n Industry partner subscriptions, credit pools, and allocations.\n </p>\n </div>\n <Button variant=\"brand\" render={<Link href=\"/admin/subscriptions/new\" />}>\n <Plus size={13} /> New subscription\n </Button>\n </div>\n\n {/* Metric cards */}\n <div className=\"grid grid-cols-1 sm:grid-cols-3 gap-3 mb-6\">\n <MetricCard label=\"Active subscriptions\" value={SUBSCRIPTIONS.length.toString()} />\n <MetricCard label=\"Credits in market\" value={totals.totalCredits.toLocaleString()} sub={`${totals.consumed} consumed`} />\n <MetricCard label=\"Annual revenue (booked)\" value={gbp(totals.annual)} />\n </div>\n\n {/* Search */}\n <div className=\"relative mb-4 max-w-md\">\n <Search size={14} className=\"absolute left-3 top-1/2 -translate-y-1/2\" style={{ color: \"var(--color-text-muted)\" }} />\n <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder=\"Search sponsors, contacts…\" className=\"h-9 pl-9\" />\n {q && (\n <button onClick={() => setQ(\"\")} className=\"absolute right-3 top-1/2 -translate-y-1/2 transition-opacity hover:opacity-70\" style={{ color: \"var(--color-text-muted)\" }}>\n <X size={13} />\n </button>\n )}\n </div>\n\n <Card className=\"p-0\">\n <Table>\n <TableHeader style={{ background: \"var(--color-bg-subtle)\" }}>\n <TableRow>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Sponsor</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Tier</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Credits</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Utilisation</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Expires</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5 text-right\" style={{ color: \"var(--color-text-muted)\" }}>Action</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {filtered.map((sub) => {\n const util = utilization(sub);\n const daysLeft = daysFrom(sub.endDate);\n const remaining = creditsRemaining(sub);\n const lowCredits = remaining / sub.totalCredits <= 0.1;\n return (\n <TableRow key={sub.id}>\n <TableCell className=\"px-5 py-3\">\n <div>\n <div className=\"font-medium text-[13px]\">{sub.sponsorName}</div>\n <div className=\"text-[11px]\" style={{ color: \"var(--color-text-muted)\" }}>{sub.primaryContactName}</div>\n </div>\n </TableCell>\n <TableCell className=\"px-5 py-3\">\n <Badge variant=\"outline\" className=\"capitalize\">{TIERS[sub.tier].name}</Badge>\n </TableCell>\n <TableCell className=\"px-5 py-3 text-[12px] tabular-nums\">\n <div style={{ color: \"var(--color-text-primary)\" }}>\n {sub.creditsConsumed} <span style={{ color: \"var(--color-text-muted)\" }}>/ {sub.totalCredits}</span>\n </div>\n <div className=\"text-[10px]\" style={{ color: lowCredits ? \"var(--color-status-error-text)\" : \"var(--color-text-muted)\" }}>\n {remaining} remaining\n </div>\n </TableCell>\n <TableCell className=\"px-5 py-3 w-40\">\n <div className=\"flex items-center gap-2\">\n <div className=\"flex-1 h-1.5 rounded-full overflow-hidden\" style={{ background: \"var(--color-border-default)\" }}>\n <div className=\"h-full\" style={{ width: `${util}%`, background: \"var(--teal-500)\" }} />\n </div>\n <span className=\"text-[11px] tabular-nums w-9 text-right\" style={{ color: \"var(--color-text-muted)\" }}>{util}%</span>\n </div>\n </TableCell>\n <TableCell className=\"px-5 py-3 text-[12px]\">\n <div style={{ color: \"var(--color-text-primary)\" }}>{sub.endDate}</div>\n <div className=\"text-[11px]\" style={{ color: daysLeft < 60 ? \"var(--color-status-warning-text)\" : \"var(--color-text-muted)\" }}>\n {daysLeft}d left\n </div>\n </TableCell>\n <TableCell className=\"px-5 py-3 text-right\">\n <Link href={`/admin/subscriptions/${sub.id}`}>\n <Button variant=\"outline\" size=\"sm\">Open <ArrowRight size={11} /></Button>\n </Link>\n </TableCell>\n </TableRow>\n );\n })}\n </TableBody>\n </Table>\n </Card>\n </AdminShell>\n );\n}"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"id": "MetricCard",
|
|
36
|
+
"line": 134,
|
|
37
|
+
"code": "function MetricCard({ label, value, sub }: { label: string; value: string; sub?: string }) {\n return (\n <div className=\"rounded-xl border p-4\" style={{ borderColor: \"var(--color-border-default)\" }}>\n <div className=\"text-[11px] font-medium uppercase tracking-wide mb-1\" style={{ color: \"var(--color-text-muted)\" }}>{label}</div>\n <div className=\"text-2xl font-semibold tabular-nums\" style={{ letterSpacing: \"-0.02em\" }}>{value}</div>\n {sub && <div className=\"text-[11px] mt-0.5\" style={{ color: \"var(--color-text-muted)\" }}>{sub}</div>}\n </div>\n );\n}"
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
"content": "\"use client\";\n\nimport Link from \"next/link\";\nimport { useState, useMemo } from \"react\";\nimport { Search, X, Plus, ArrowRight, Wallet, AlertCircle } from \"lucide-react\";\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Card } from \"@/components/ui/card\";\nimport { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from \"@/components/ui/table\";\nimport { AdminShell } from \"@/components/admin/admin-shell\";\nimport { SUBSCRIPTIONS } from \"@/lib/credits-mock\";\nimport { TIERS, creditsRemaining, utilization, gbp, daysFrom } from \"@/lib/credits\";\n\nexport default function SubscriptionsListPage() {\n const [q, setQ] = useState(\"\");\n\n const filtered = useMemo(() =>\n SUBSCRIPTIONS.filter((s) =>\n !q ||\n s.sponsorName.toLowerCase().includes(q.toLowerCase()) ||\n s.primaryContactName.toLowerCase().includes(q.toLowerCase())\n ),\n [q]\n );\n\n const totals = useMemo(() => {\n const totalCredits = SUBSCRIPTIONS.reduce((a, s) => a + s.totalCredits, 0);\n const consumed = SUBSCRIPTIONS.reduce((a, s) => a + s.creditsConsumed, 0);\n const annual = SUBSCRIPTIONS.reduce((a, s) => a + s.packageFee + s.platformAccessFee, 0);\n return { totalCredits, consumed, annual };\n }, []);\n\n return (\n <AdminShell>\n <div className=\"flex items-start justify-between gap-4 mb-6\">\n <div>\n <h1 className=\"text-[1.75rem] font-semibold tracking-tight mb-1\" style={{ letterSpacing: \"-0.025em\" }}>\n Subscriptions\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n Industry partner subscriptions, credit pools, and allocations.\n </p>\n </div>\n <Button variant=\"brand\" render={<Link href=\"/admin/subscriptions/new\" />}>\n <Plus size={13} /> New subscription\n </Button>\n </div>\n\n {/* Metric cards */}\n <div className=\"grid grid-cols-1 sm:grid-cols-3 gap-3 mb-6\">\n <MetricCard label=\"Active subscriptions\" value={SUBSCRIPTIONS.length.toString()} />\n <MetricCard label=\"Credits in market\" value={totals.totalCredits.toLocaleString()} sub={`${totals.consumed} consumed`} />\n <MetricCard label=\"Annual revenue (booked)\" value={gbp(totals.annual)} />\n </div>\n\n {/* Search */}\n <div className=\"relative mb-4 max-w-md\">\n <Search size={14} className=\"absolute left-3 top-1/2 -translate-y-1/2\" style={{ color: \"var(--color-text-muted)\" }} />\n <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder=\"Search sponsors, contacts…\" className=\"h-9 pl-9\" />\n {q && (\n <button onClick={() => setQ(\"\")} className=\"absolute right-3 top-1/2 -translate-y-1/2 transition-opacity hover:opacity-70\" style={{ color: \"var(--color-text-muted)\" }}>\n <X size={13} />\n </button>\n )}\n </div>\n\n <Card className=\"p-0\">\n <Table>\n <TableHeader style={{ background: \"var(--color-bg-subtle)\" }}>\n <TableRow>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Sponsor</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Tier</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Credits</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Utilisation</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Expires</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5 text-right\" style={{ color: \"var(--color-text-muted)\" }}>Action</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {filtered.map((sub) => {\n const util = utilization(sub);\n const daysLeft = daysFrom(sub.endDate);\n const remaining = creditsRemaining(sub);\n const lowCredits = remaining / sub.totalCredits <= 0.1;\n return (\n <TableRow key={sub.id}>\n <TableCell className=\"px-5 py-3\">\n <div>\n <div className=\"font-medium text-[13px]\">{sub.sponsorName}</div>\n <div className=\"text-[11px]\" style={{ color: \"var(--color-text-muted)\" }}>{sub.primaryContactName}</div>\n </div>\n </TableCell>\n <TableCell className=\"px-5 py-3\">\n <Badge variant=\"outline\" className=\"capitalize\">{TIERS[sub.tier].name}</Badge>\n </TableCell>\n <TableCell className=\"px-5 py-3 text-[12px] tabular-nums\">\n <div style={{ color: \"var(--color-text-primary)\" }}>\n {sub.creditsConsumed} <span style={{ color: \"var(--color-text-muted)\" }}>/ {sub.totalCredits}</span>\n </div>\n <div className=\"text-[10px]\" style={{ color: lowCredits ? \"var(--color-status-error-text)\" : \"var(--color-text-muted)\" }}>\n {remaining} remaining\n </div>\n </TableCell>\n <TableCell className=\"px-5 py-3 w-40\">\n <div className=\"flex items-center gap-2\">\n <div className=\"flex-1 h-1.5 rounded-full overflow-hidden\" style={{ background: \"var(--color-border-default)\" }}>\n <div className=\"h-full\" style={{ width: `${util}%`, background: \"var(--teal-500)\" }} />\n </div>\n <span className=\"text-[11px] tabular-nums w-9 text-right\" style={{ color: \"var(--color-text-muted)\" }}>{util}%</span>\n </div>\n </TableCell>\n <TableCell className=\"px-5 py-3 text-[12px]\">\n <div style={{ color: \"var(--color-text-primary)\" }}>{sub.endDate}</div>\n <div className=\"text-[11px]\" style={{ color: daysLeft < 60 ? \"var(--color-status-warning-text)\" : \"var(--color-text-muted)\" }}>\n {daysLeft}d left\n </div>\n </TableCell>\n <TableCell className=\"px-5 py-3 text-right\">\n <Link href={`/admin/subscriptions/${sub.id}`}>\n <Button variant=\"outline\" size=\"sm\">Open <ArrowRight size={11} /></Button>\n </Link>\n </TableCell>\n </TableRow>\n );\n })}\n </TableBody>\n </Table>\n </Card>\n </AdminShell>\n );\n}\n\nfunction MetricCard({ label, value, sub }: { label: string; value: string; sub?: string }) {\n return (\n <div className=\"rounded-xl border p-4\" style={{ borderColor: \"var(--color-border-default)\" }}>\n <div className=\"text-[11px] font-medium uppercase tracking-wide mb-1\" style={{ color: \"var(--color-text-muted)\" }}>{label}</div>\n <div className=\"text-2xl font-semibold tabular-nums\" style={{ letterSpacing: \"-0.02em\" }}>{value}</div>\n {sub && <div className=\"text-[11px] mt-0.5\" style={{ color: \"var(--color-text-muted)\" }}>{sub}</div>}\n </div>\n );\n}\n"
|
|
41
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
|
|
3
|
+
"file": "app/admin/transactions/page.tsx",
|
|
4
|
+
"sha256": "4722172d14cb99c9ec7efd4013bd46cf2261ab392c0fd1eb64d4df43dd4e23a9",
|
|
5
|
+
"imports": [
|
|
6
|
+
"next/link",
|
|
7
|
+
"react",
|
|
8
|
+
"lucide-react",
|
|
9
|
+
"@/components/ui/input",
|
|
10
|
+
"@/components/ui/button",
|
|
11
|
+
"@/components/ui/badge",
|
|
12
|
+
"@/components/ui/card",
|
|
13
|
+
"@/components/ui/table",
|
|
14
|
+
"@/components/admin/admin-shell",
|
|
15
|
+
"@/lib/credits-mock",
|
|
16
|
+
"@/lib/credits"
|
|
17
|
+
],
|
|
18
|
+
"tokens": [
|
|
19
|
+
"--color-bg-subtle",
|
|
20
|
+
"--color-border-default",
|
|
21
|
+
"--color-status-error-text",
|
|
22
|
+
"--color-status-success-text",
|
|
23
|
+
"--color-text-muted",
|
|
24
|
+
"--color-text-primary",
|
|
25
|
+
"--color-text-secondary",
|
|
26
|
+
"--teal-500"
|
|
27
|
+
],
|
|
28
|
+
"fragments": [
|
|
29
|
+
{
|
|
30
|
+
"id": "TransactionsPage",
|
|
31
|
+
"line": 23,
|
|
32
|
+
"code": "export default function TransactionsPage() {\n const [q, setQ] = useState(\"\");\n const [typeFilter, setTypeFilter] = useState<TxnType | \"all\">(\"all\");\n\n const filtered = useMemo(() => {\n let items = [...CREDIT_TRANSACTIONS];\n if (typeFilter !== \"all\") items = items.filter((t) => t.type === typeFilter);\n if (q) {\n const n = q.toLowerCase();\n items = items.filter((t) =>\n t.id.toLowerCase().includes(n) ||\n t.sponsorName?.toLowerCase().includes(n) ||\n t.traineeName?.toLowerCase().includes(n) ||\n t.sessionId?.toLowerCase().includes(n) ||\n t.admin?.toLowerCase().includes(n) ||\n t.notes?.toLowerCase().includes(n)\n );\n }\n return items.sort((a, b) => b.timestamp.localeCompare(a.timestamp));\n }, [q, typeFilter]);\n\n return (\n <AdminShell>\n <div className=\"flex items-start justify-between gap-4 mb-6\">\n <div>\n <h1 className=\"text-[1.75rem] font-semibold tracking-tight mb-1\" style={{ letterSpacing: \"-0.025em\" }}>\n Credit transactions\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n Append-only ledger of every credit movement. Retained for audit & reconciliation.\n </p>\n </div>\n <Button variant=\"outline\" size=\"default\">\n <Download size={13} /> Export CSV\n </Button>\n </div>\n\n {/* Filters */}\n <div className=\"flex items-center gap-2 mb-5\">\n <div className=\"relative flex-1 max-w-md\">\n <Search size={14} className=\"absolute left-3 top-1/2 -translate-y-1/2\" style={{ color: \"var(--color-text-muted)\" }} />\n <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder=\"Search by sponsor, trainee, session ID, notes…\" className=\"h-9 pl-9\" />\n {q && (\n <button onClick={() => setQ(\"\")} className=\"absolute right-3 top-1/2 -translate-y-1/2 transition-opacity hover:opacity-70\" style={{ color: \"var(--color-text-muted)\" }}>\n <X size={13} />\n </button>\n )}\n </div>\n <div className=\"relative\">\n <select\n value={typeFilter}\n onChange={(e) => setTypeFilter(e.target.value as TxnType | \"all\")}\n className=\"h-9 pl-3 pr-8 rounded-lg border text-[12px] bg-transparent outline-none appearance-none cursor-pointer focus-visible:ring-3 focus-visible:ring-[var(--teal-500)]/30\"\n style={{ borderColor: \"var(--color-border-default)\", color: \"var(--color-text-primary)\" }}\n aria-label=\"Filter by type\"\n >\n {TYPE_OPTIONS.map((o) => <option key={o.value} value={o.value}>Type: {o.label}</option>)}\n </select>\n <ChevronDown size={13} strokeWidth={1.8} className=\"absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none\" style={{ color: \"var(--color-text-muted)\" }} />\n </div>\n </div>\n\n {filtered.length === 0 ? (\n <Card className=\"p-12 text-center\">\n <FileSearch size={28} strokeWidth={1.4} className=\"mx-auto mb-3\" style={{ color: \"var(--color-text-muted)\" }} />\n <p className=\"text-[13px]\" style={{ color: \"var(--color-text-secondary)\" }}>\n No transactions match these filters.\n </p>\n </Card>\n ) : (\n <Card className=\"p-0\">\n <Table>\n <TableHeader style={{ background: \"var(--color-bg-subtle)\" }}>\n <TableRow>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>When</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Type</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Sponsor</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Trainee</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5 text-right\" style={{ color: \"var(--color-text-muted)\" }}>Credits</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>By</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {filtered.map((t) => <TxnRow key={t.id} t={t} />)}\n </TableBody>\n </Table>\n </Card>\n )}\n </AdminShell>\n );\n}"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"id": "TxnRow",
|
|
36
|
+
"line": 115,
|
|
37
|
+
"code": "function TxnRow({ t }: { t: CreditTxn }) {\n const positive = t.creditsDelta > 0;\n const negative = t.creditsDelta < 0;\n return (\n <TableRow>\n <TableCell className=\"px-5 py-3 text-[12px]\">\n <div style={{ color: \"var(--color-text-primary)\" }}>{new Date(t.timestamp).toLocaleDateString()}</div>\n <div className=\"text-[10px]\" style={{ color: \"var(--color-text-muted)\" }}>\n {new Date(t.timestamp).toLocaleTimeString([], { hour: \"2-digit\", minute: \"2-digit\" })}\n </div>\n </TableCell>\n <TableCell className=\"px-5 py-3\">\n <div className=\"flex items-center gap-2\">\n <Coins size={13} strokeWidth={1.7} style={{ color: \"var(--color-text-muted)\" }} />\n <span className=\"text-[12px] font-medium\">{TXN_LABEL[t.type]}</span>\n </div>\n {t.notes && <div className=\"text-[10px] mt-0.5 ml-5\" style={{ color: \"var(--color-text-muted)\" }}>{t.notes}</div>}\n {t.sessionId && <div className=\"text-[10px] mt-0.5 ml-5\"><code style={{ color: \"var(--color-text-secondary)\" }}>{t.sessionId}</code></div>}\n </TableCell>\n <TableCell className=\"px-5 py-3 text-[12px]\" style={{ color: \"var(--color-text-secondary)\" }}>{t.sponsorName ?? \"—\"}</TableCell>\n <TableCell className=\"px-5 py-3 text-[12px]\" style={{ color: \"var(--color-text-secondary)\" }}>{t.traineeName ?? \"—\"}</TableCell>\n <TableCell className=\"px-5 py-3 text-right\">\n <span\n className=\"inline-flex items-center gap-1 text-[12px] font-semibold tabular-nums\"\n style={{ color: positive ? \"var(--color-status-success-text)\" : negative ? \"var(--color-status-error-text)\" : \"var(--color-text-muted)\" }}\n >\n {positive && <ArrowUp size={11} strokeWidth={2.2} />}\n {negative && <ArrowDown size={11} strokeWidth={2.2} />}\n {positive ? \"+\" : \"\"}{t.creditsDelta}\n </span>\n </TableCell>\n <TableCell className=\"px-5 py-3 text-[12px]\" style={{ color: \"var(--color-text-secondary)\" }}>\n {t.admin ?? <span style={{ color: \"var(--color-text-muted)\" }}>system</span>}\n </TableCell>\n </TableRow>\n );\n}"
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
"content": "\"use client\";\n\nimport Link from \"next/link\";\nimport { useState, useMemo } from \"react\";\nimport {\n Search, X, Download, ArrowDown, ArrowUp, ChevronDown,\n Coins, FileSearch,\n} from \"lucide-react\";\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Card } from \"@/components/ui/card\";\nimport { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from \"@/components/ui/table\";\nimport { AdminShell } from \"@/components/admin/admin-shell\";\nimport { CREDIT_TRANSACTIONS } from \"@/lib/credits-mock\";\nimport { TXN_LABEL, type TxnType, type CreditTxn } from \"@/lib/credits\";\n\nconst TYPE_OPTIONS: { value: TxnType | \"all\"; label: string }[] = [\n { value: \"all\", label: \"All types\" },\n ...(Object.entries(TXN_LABEL).map(([v, l]) => ({ value: v as TxnType, label: l }))),\n];\n\nexport default function TransactionsPage() {\n const [q, setQ] = useState(\"\");\n const [typeFilter, setTypeFilter] = useState<TxnType | \"all\">(\"all\");\n\n const filtered = useMemo(() => {\n let items = [...CREDIT_TRANSACTIONS];\n if (typeFilter !== \"all\") items = items.filter((t) => t.type === typeFilter);\n if (q) {\n const n = q.toLowerCase();\n items = items.filter((t) =>\n t.id.toLowerCase().includes(n) ||\n t.sponsorName?.toLowerCase().includes(n) ||\n t.traineeName?.toLowerCase().includes(n) ||\n t.sessionId?.toLowerCase().includes(n) ||\n t.admin?.toLowerCase().includes(n) ||\n t.notes?.toLowerCase().includes(n)\n );\n }\n return items.sort((a, b) => b.timestamp.localeCompare(a.timestamp));\n }, [q, typeFilter]);\n\n return (\n <AdminShell>\n <div className=\"flex items-start justify-between gap-4 mb-6\">\n <div>\n <h1 className=\"text-[1.75rem] font-semibold tracking-tight mb-1\" style={{ letterSpacing: \"-0.025em\" }}>\n Credit transactions\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n Append-only ledger of every credit movement. Retained for audit & reconciliation.\n </p>\n </div>\n <Button variant=\"outline\" size=\"default\">\n <Download size={13} /> Export CSV\n </Button>\n </div>\n\n {/* Filters */}\n <div className=\"flex items-center gap-2 mb-5\">\n <div className=\"relative flex-1 max-w-md\">\n <Search size={14} className=\"absolute left-3 top-1/2 -translate-y-1/2\" style={{ color: \"var(--color-text-muted)\" }} />\n <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder=\"Search by sponsor, trainee, session ID, notes…\" className=\"h-9 pl-9\" />\n {q && (\n <button onClick={() => setQ(\"\")} className=\"absolute right-3 top-1/2 -translate-y-1/2 transition-opacity hover:opacity-70\" style={{ color: \"var(--color-text-muted)\" }}>\n <X size={13} />\n </button>\n )}\n </div>\n <div className=\"relative\">\n <select\n value={typeFilter}\n onChange={(e) => setTypeFilter(e.target.value as TxnType | \"all\")}\n className=\"h-9 pl-3 pr-8 rounded-lg border text-[12px] bg-transparent outline-none appearance-none cursor-pointer focus-visible:ring-3 focus-visible:ring-[var(--teal-500)]/30\"\n style={{ borderColor: \"var(--color-border-default)\", color: \"var(--color-text-primary)\" }}\n aria-label=\"Filter by type\"\n >\n {TYPE_OPTIONS.map((o) => <option key={o.value} value={o.value}>Type: {o.label}</option>)}\n </select>\n <ChevronDown size={13} strokeWidth={1.8} className=\"absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none\" style={{ color: \"var(--color-text-muted)\" }} />\n </div>\n </div>\n\n {filtered.length === 0 ? (\n <Card className=\"p-12 text-center\">\n <FileSearch size={28} strokeWidth={1.4} className=\"mx-auto mb-3\" style={{ color: \"var(--color-text-muted)\" }} />\n <p className=\"text-[13px]\" style={{ color: \"var(--color-text-secondary)\" }}>\n No transactions match these filters.\n </p>\n </Card>\n ) : (\n <Card className=\"p-0\">\n <Table>\n <TableHeader style={{ background: \"var(--color-bg-subtle)\" }}>\n <TableRow>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>When</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Type</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Sponsor</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Trainee</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5 text-right\" style={{ color: \"var(--color-text-muted)\" }}>Credits</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>By</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {filtered.map((t) => <TxnRow key={t.id} t={t} />)}\n </TableBody>\n </Table>\n </Card>\n )}\n </AdminShell>\n );\n}\n\nfunction TxnRow({ t }: { t: CreditTxn }) {\n const positive = t.creditsDelta > 0;\n const negative = t.creditsDelta < 0;\n return (\n <TableRow>\n <TableCell className=\"px-5 py-3 text-[12px]\">\n <div style={{ color: \"var(--color-text-primary)\" }}>{new Date(t.timestamp).toLocaleDateString()}</div>\n <div className=\"text-[10px]\" style={{ color: \"var(--color-text-muted)\" }}>\n {new Date(t.timestamp).toLocaleTimeString([], { hour: \"2-digit\", minute: \"2-digit\" })}\n </div>\n </TableCell>\n <TableCell className=\"px-5 py-3\">\n <div className=\"flex items-center gap-2\">\n <Coins size={13} strokeWidth={1.7} style={{ color: \"var(--color-text-muted)\" }} />\n <span className=\"text-[12px] font-medium\">{TXN_LABEL[t.type]}</span>\n </div>\n {t.notes && <div className=\"text-[10px] mt-0.5 ml-5\" style={{ color: \"var(--color-text-muted)\" }}>{t.notes}</div>}\n {t.sessionId && <div className=\"text-[10px] mt-0.5 ml-5\"><code style={{ color: \"var(--color-text-secondary)\" }}>{t.sessionId}</code></div>}\n </TableCell>\n <TableCell className=\"px-5 py-3 text-[12px]\" style={{ color: \"var(--color-text-secondary)\" }}>{t.sponsorName ?? \"—\"}</TableCell>\n <TableCell className=\"px-5 py-3 text-[12px]\" style={{ color: \"var(--color-text-secondary)\" }}>{t.traineeName ?? \"—\"}</TableCell>\n <TableCell className=\"px-5 py-3 text-right\">\n <span\n className=\"inline-flex items-center gap-1 text-[12px] font-semibold tabular-nums\"\n style={{ color: positive ? \"var(--color-status-success-text)\" : negative ? \"var(--color-status-error-text)\" : \"var(--color-text-muted)\" }}\n >\n {positive && <ArrowUp size={11} strokeWidth={2.2} />}\n {negative && <ArrowDown size={11} strokeWidth={2.2} />}\n {positive ? \"+\" : \"\"}{t.creditsDelta}\n </span>\n </TableCell>\n <TableCell className=\"px-5 py-3 text-[12px]\" style={{ color: \"var(--color-text-secondary)\" }}>\n {t.admin ?? <span style={{ color: \"var(--color-text-muted)\" }}>system</span>}\n </TableCell>\n </TableRow>\n );\n}\n"
|
|
41
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
|
|
3
|
+
"file": "app/error.tsx",
|
|
4
|
+
"sha256": "3ec1ffd81e51b6eee831e12e664f72f190a196a86a72d99792b4172995852534",
|
|
5
|
+
"imports": [
|
|
6
|
+
"react",
|
|
7
|
+
"next/link",
|
|
8
|
+
"framer-motion",
|
|
9
|
+
"lucide-react",
|
|
10
|
+
"@/components/ui/button",
|
|
11
|
+
"@/components/design-system/theme-toggle"
|
|
12
|
+
],
|
|
13
|
+
"tokens": [
|
|
14
|
+
"--color-bg-default",
|
|
15
|
+
"--color-border-muted",
|
|
16
|
+
"--color-status-error-bg",
|
|
17
|
+
"--color-status-error-text",
|
|
18
|
+
"--color-text-muted",
|
|
19
|
+
"--color-text-primary",
|
|
20
|
+
"--color-text-secondary"
|
|
21
|
+
],
|
|
22
|
+
"fragments": [
|
|
23
|
+
{
|
|
24
|
+
"id": "ErrorPage",
|
|
25
|
+
"line": 10,
|
|
26
|
+
"code": "export default function ErrorPage({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {\n useEffect(() => {\n // log to monitoring service in production\n console.error(error);\n }, [error]);\n\n return (\n <div className=\"min-h-screen flex flex-col\" style={{ background: \"var(--color-bg-default)\", color: \"var(--color-text-primary)\" }}>\n <header className=\"h-14 flex items-center justify-between px-6 md:px-10 shrink-0\">\n <Link href=\"/marketing\" className=\"flex items-center gap-2.5 group\">\n <img src=\"/mentix-logo.png\" alt=\"Mentix\" style={{ height: 24, width: 24, objectFit: \"contain\" }} />\n <span className=\"text-sm font-semibold tracking-tight transition-opacity group-hover:opacity-70\">Mentix</span>\n </Link>\n <ThemeToggle />\n </header>\n\n <main className=\"flex-1 flex items-center justify-center px-6 py-12\">\n <motion.div\n initial={{ opacity: 0, y: 16 }}\n animate={{ opacity: 1, y: 0 }}\n transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}\n className=\"text-center max-w-md\"\n >\n <motion.div\n initial={{ opacity: 0, scale: 0.85 }}\n animate={{ opacity: 1, scale: 1 }}\n transition={{ duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1] }}\n className=\"w-16 h-16 mx-auto rounded-full flex items-center justify-center mb-6\"\n style={{ background: \"var(--color-status-error-bg)\", color: \"var(--color-status-error-text)\" }}\n >\n <AlertTriangle size={28} strokeWidth={1.5} />\n </motion.div>\n\n <h1 className=\"text-[1.75rem] font-semibold mb-2\" style={{ letterSpacing: \"-0.025em\", lineHeight: 1.15 }}>\n Something went wrong\n </h1>\n <p className=\"text-sm mb-2\" style={{ color: \"var(--color-text-secondary)\", lineHeight: 1.55 }}>\n We hit an unexpected error. Our team has been notified.\n </p>\n {error.digest && (\n <p className=\"text-[11px] mb-8 font-mono\" style={{ color: \"var(--color-text-muted)\" }}>\n Error ID: {error.digest}\n </p>\n )}\n\n <div className=\"flex flex-col sm:flex-row items-center justify-center gap-2 mt-6\">\n <Button variant=\"brand\" size=\"lg\" onClick={reset} className=\"w-full sm:w-auto\">\n <RefreshCw size={14} /> Try again\n </Button>\n <Link href=\"/marketing\">\n <Button variant=\"outline\" size=\"lg\" className=\"w-full sm:w-auto\">\n <Home size={14} /> Back to homepage\n </Button>\n </Link>\n </div>\n </motion.div>\n </main>\n\n <footer className=\"h-12 flex items-center justify-center text-[11px] shrink-0 border-t\" style={{ color: \"var(--color-text-muted)\", borderColor: \"var(--color-border-muted)\" }}>\n © 2026 Mentix\n </footer>\n </div>\n );\n}"
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
"content": "\"use client\";\n\nimport { useEffect } from \"react\";\nimport Link from \"next/link\";\nimport { motion } from \"framer-motion\";\nimport { AlertTriangle, RefreshCw, Home } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { ThemeToggle } from \"@/components/design-system/theme-toggle\";\n\nexport default function ErrorPage({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {\n useEffect(() => {\n // log to monitoring service in production\n console.error(error);\n }, [error]);\n\n return (\n <div className=\"min-h-screen flex flex-col\" style={{ background: \"var(--color-bg-default)\", color: \"var(--color-text-primary)\" }}>\n <header className=\"h-14 flex items-center justify-between px-6 md:px-10 shrink-0\">\n <Link href=\"/marketing\" className=\"flex items-center gap-2.5 group\">\n <img src=\"/mentix-logo.png\" alt=\"Mentix\" style={{ height: 24, width: 24, objectFit: \"contain\" }} />\n <span className=\"text-sm font-semibold tracking-tight transition-opacity group-hover:opacity-70\">Mentix</span>\n </Link>\n <ThemeToggle />\n </header>\n\n <main className=\"flex-1 flex items-center justify-center px-6 py-12\">\n <motion.div\n initial={{ opacity: 0, y: 16 }}\n animate={{ opacity: 1, y: 0 }}\n transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}\n className=\"text-center max-w-md\"\n >\n <motion.div\n initial={{ opacity: 0, scale: 0.85 }}\n animate={{ opacity: 1, scale: 1 }}\n transition={{ duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1] }}\n className=\"w-16 h-16 mx-auto rounded-full flex items-center justify-center mb-6\"\n style={{ background: \"var(--color-status-error-bg)\", color: \"var(--color-status-error-text)\" }}\n >\n <AlertTriangle size={28} strokeWidth={1.5} />\n </motion.div>\n\n <h1 className=\"text-[1.75rem] font-semibold mb-2\" style={{ letterSpacing: \"-0.025em\", lineHeight: 1.15 }}>\n Something went wrong\n </h1>\n <p className=\"text-sm mb-2\" style={{ color: \"var(--color-text-secondary)\", lineHeight: 1.55 }}>\n We hit an unexpected error. Our team has been notified.\n </p>\n {error.digest && (\n <p className=\"text-[11px] mb-8 font-mono\" style={{ color: \"var(--color-text-muted)\" }}>\n Error ID: {error.digest}\n </p>\n )}\n\n <div className=\"flex flex-col sm:flex-row items-center justify-center gap-2 mt-6\">\n <Button variant=\"brand\" size=\"lg\" onClick={reset} className=\"w-full sm:w-auto\">\n <RefreshCw size={14} /> Try again\n </Button>\n <Link href=\"/marketing\">\n <Button variant=\"outline\" size=\"lg\" className=\"w-full sm:w-auto\">\n <Home size={14} /> Back to homepage\n </Button>\n </Link>\n </div>\n </motion.div>\n </main>\n\n <footer className=\"h-12 flex items-center justify-center text-[11px] shrink-0 border-t\" style={{ color: \"var(--color-text-muted)\", borderColor: \"var(--color-border-muted)\" }}>\n © 2026 Mentix\n </footer>\n </div>\n );\n}\n"
|
|
30
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
|
|
3
|
+
"file": "app/loading.tsx",
|
|
4
|
+
"sha256": "74c0d4aae72f803e91481cbe29e8d8fbffa0e9b02b1ef2970ec014a2127ad5b6",
|
|
5
|
+
"imports": [],
|
|
6
|
+
"tokens": [
|
|
7
|
+
"--color-bg-default",
|
|
8
|
+
"--color-text-muted",
|
|
9
|
+
"--teal-500"
|
|
10
|
+
],
|
|
11
|
+
"fragments": [
|
|
12
|
+
{
|
|
13
|
+
"id": "Loading",
|
|
14
|
+
"line": 1,
|
|
15
|
+
"code": "export default function Loading() {\n return (\n <div className=\"min-h-screen flex items-center justify-center\" style={{ background: \"var(--color-bg-default)\" }}>\n <div className=\"flex flex-col items-center gap-4\">\n <div className=\"relative w-12 h-12\">\n <div\n className=\"absolute inset-0 rounded-full border-2 border-transparent animate-spin\"\n style={{\n borderTopColor: \"var(--teal-500)\",\n borderRightColor: \"var(--teal-500)\",\n animationDuration: \"0.9s\",\n }}\n />\n </div>\n <p className=\"text-[12px]\" style={{ color: \"var(--color-text-muted)\" }}>Loading…</p>\n </div>\n </div>\n );\n}"
|
|
16
|
+
}
|
|
17
|
+
],
|
|
18
|
+
"content": "export default function Loading() {\n return (\n <div className=\"min-h-screen flex items-center justify-center\" style={{ background: \"var(--color-bg-default)\" }}>\n <div className=\"flex flex-col items-center gap-4\">\n <div className=\"relative w-12 h-12\">\n <div\n className=\"absolute inset-0 rounded-full border-2 border-transparent animate-spin\"\n style={{\n borderTopColor: \"var(--teal-500)\",\n borderRightColor: \"var(--teal-500)\",\n animationDuration: \"0.9s\",\n }}\n />\n </div>\n <p className=\"text-[12px]\" style={{ color: \"var(--color-text-muted)\" }}>Loading…</p>\n </div>\n </div>\n );\n}\n"
|
|
19
|
+
}
|