@utopia-studio-design/design-system-cli 0.8.1 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. package/bin/utopia-ds-mcp.mjs +15 -4
  2. package/bin/utopia-ds.mjs +16 -3
  3. package/data/blocks/semantic-pattern-parts/AuthParts.tsx +48 -0
  4. package/data/blocks/semantic-pattern-parts/ChoiceCards.tsx +7 -0
  5. package/data/blocks/semantic-pattern-parts/ListFilters.tsx +9 -0
  6. package/data/blocks/semantic-pattern-parts/OutcomeNotice.tsx +7 -0
  7. package/data/blocks/semantic-pattern-parts/PatternParts.tsx +7 -0
  8. package/data/blocks/semantic-pattern-parts/ProductParts.tsx +38 -0
  9. package/data/blocks/semantic-pattern-parts/ProgressSteps.tsx +6 -0
  10. package/data/blocks/semantic-pattern-parts/ReviewList.tsx +7 -0
  11. package/data/blocks/semantic-pattern-parts/ValidatedField.tsx +8 -0
  12. package/data/blocks/semantic-pattern-parts/WorkflowParts.tsx +339 -0
  13. package/data/blocks/semantic-pattern-parts/auth-parts.css +27 -0
  14. package/data/blocks/semantic-pattern-parts/pattern-parts.css +25 -0
  15. package/data/blocks/semantic-pattern-parts/product-parts.css +25 -0
  16. package/data/blocks/semantic-pattern-parts/types.ts +3 -0
  17. package/data/blocks/semantic-pattern-parts/workflow-parts.css +118 -0
  18. package/data/blocks/ux-pattern-examples/AccessExamples.tsx +46 -0
  19. package/data/blocks/ux-pattern-examples/AccountExamples.tsx +133 -0
  20. package/data/blocks/ux-pattern-examples/OnboardingExample.tsx +15 -0
  21. package/data/blocks/ux-pattern-examples/ProductExamples.tsx +79 -0
  22. package/data/blocks/ux-pattern-examples/ReviewExample.tsx +14 -0
  23. package/data/blocks/ux-pattern-examples/SignupExample.tsx +22 -0
  24. package/data/blocks/ux-pattern-examples/WorkflowExample.tsx +1761 -0
  25. package/data/blocks/ux-pattern-examples/WorkflowExamples.tsx +190 -0
  26. package/data/blocks/ux-pattern-examples/copy.ts +6 -0
  27. package/data/blocks/ux-pattern-examples/ux-examples.css +18 -0
  28. package/data/blocks/ux-pattern-examples/workflow-copy.ts +76 -0
  29. package/data/blocks/ux-pattern-examples/workflow-definitions.ts +1291 -0
  30. package/data/docs/ui-parts-qa.md +172 -0
  31. package/data/docs/ui-parts.md +105 -0
  32. package/data/docs/ux-pattern-adaptation-qa.md +38 -0
  33. package/data/docs/ux-pattern-adaptation.md +41 -0
  34. package/data/docs/ux-pattern-migration.md +111 -0
  35. package/data/docs/ux-pattern-publish-2026-09-13.md +31 -0
  36. package/data/docs/ux-pattern-release-2026-09-13.md +26 -0
  37. package/data/docs/ux-source-evidence/account-auth.json +41 -0
  38. package/data/docs/ux-source-evidence/account-forgot-password.json +36 -0
  39. package/data/docs/ux-source-evidence/account-invite.json +63 -0
  40. package/data/docs/ux-source-evidence/account-locked.json +63 -0
  41. package/data/docs/ux-source-evidence/account-login.json +28 -0
  42. package/data/docs/ux-source-evidence/account-mfa.json +62 -0
  43. package/data/docs/ux-source-evidence/account-onboarding-credentials.json +52 -0
  44. package/data/docs/ux-source-evidence/account-onboarding-legal.json +56 -0
  45. package/data/docs/ux-source-evidence/account-onboarding-orientation.json +66 -0
  46. package/data/docs/ux-source-evidence/account-onboarding-profile.json +50 -0
  47. package/data/docs/ux-source-evidence/account-onboarding.json +69 -0
  48. package/data/docs/ux-source-evidence/account-reset-password.json +27 -0
  49. package/data/docs/ux-source-evidence/account-session-expired.json +62 -0
  50. package/data/docs/ux-source-evidence/account-signup.json +33 -0
  51. package/data/docs/ux-source-evidence/account-verify-email.json +34 -0
  52. package/data/docs/ux-source-evidence/account-welcome.json +41 -0
  53. package/data/docs/ux-source-evidence/admin-analytics.json +54 -0
  54. package/data/docs/ux-source-evidence/admin-audit.json +31 -0
  55. package/data/docs/ux-source-evidence/admin-broadcasts-new.json +36 -0
  56. package/data/docs/ux-source-evidence/admin-broadcasts.json +41 -0
  57. package/data/docs/ux-source-evidence/admin-closures.json +52 -0
  58. package/data/docs/ux-source-evidence/admin-devices.json +51 -0
  59. package/data/docs/ux-source-evidence/admin-escalations.json +35 -0
  60. package/data/docs/ux-source-evidence/admin-incidents.json +54 -0
  61. package/data/docs/ux-source-evidence/admin-loading.json +17 -0
  62. package/data/docs/ux-source-evidence/admin-onboarding-detail.json +80 -0
  63. package/data/docs/ux-source-evidence/admin-onboarding.json +62 -0
  64. package/data/docs/ux-source-evidence/admin-payments.json +45 -0
  65. package/data/docs/ux-source-evidence/admin-providers.json +38 -0
  66. package/data/docs/ux-source-evidence/admin-settings.json +18 -0
  67. package/data/docs/ux-source-evidence/admin-subscriptions-detail-allocate.json +42 -0
  68. package/data/docs/ux-source-evidence/admin-subscriptions-detail.json +48 -0
  69. package/data/docs/ux-source-evidence/admin-subscriptions-new.json +43 -0
  70. package/data/docs/ux-source-evidence/admin-subscriptions.json +41 -0
  71. package/data/docs/ux-source-evidence/admin-transactions.json +41 -0
  72. package/data/docs/ux-source-evidence/error.json +30 -0
  73. package/data/docs/ux-source-evidence/loading.json +19 -0
  74. package/data/docs/ux-source-evidence/marketing-blog.json +48 -0
  75. package/data/docs/ux-source-evidence/marketing-contact.json +36 -0
  76. package/data/docs/ux-source-evidence/marketing.json +64 -0
  77. package/data/docs/ux-source-evidence/not-found.json +28 -0
  78. package/data/docs/ux-source-evidence/portal-loading.json +17 -0
  79. package/data/docs/ux-source-evidence/portal-mentor-messages.json +31 -0
  80. package/data/docs/ux-source-evidence/portal-mentor-profile.json +23 -0
  81. package/data/docs/ux-source-evidence/portal-mentor-requests-detail.json +54 -0
  82. package/data/docs/ux-source-evidence/portal-mentor-sessions-detail-assess.json +42 -0
  83. package/data/docs/ux-source-evidence/portal-mentor-settings.json +18 -0
  84. package/data/docs/ux-source-evidence/portal-mentor.json +38 -0
  85. package/data/docs/ux-source-evidence/portal-sessions-detail-live.json +48 -0
  86. package/data/docs/ux-source-evidence/portal-sponsor-billing.json +28 -0
  87. package/data/docs/ux-source-evidence/portal-sponsor-profile.json +22 -0
  88. package/data/docs/ux-source-evidence/portal-sponsor-sessions.json +28 -0
  89. package/data/docs/ux-source-evidence/portal-sponsor-settings.json +18 -0
  90. package/data/docs/ux-source-evidence/portal-sponsor-trainees.json +36 -0
  91. package/data/docs/ux-source-evidence/portal-sponsor.json +52 -0
  92. package/data/docs/ux-source-evidence/portal-trainee-competency.json +42 -0
  93. package/data/docs/ux-source-evidence/portal-trainee-credits.json +40 -0
  94. package/data/docs/ux-source-evidence/portal-trainee-mentors.json +48 -0
  95. package/data/docs/ux-source-evidence/portal-trainee-messages.json +32 -0
  96. package/data/docs/ux-source-evidence/portal-trainee-notifications.json +35 -0
  97. package/data/docs/ux-source-evidence/portal-trainee-profile.json +26 -0
  98. package/data/docs/ux-source-evidence/portal-trainee-request.json +54 -0
  99. package/data/docs/ux-source-evidence/portal-trainee-sessions-detail-feedback.json +36 -0
  100. package/data/docs/ux-source-evidence/portal-trainee-sessions-detail-precheck.json +42 -0
  101. package/data/docs/ux-source-evidence/portal-trainee-sessions-detail.json +55 -0
  102. package/data/docs/ux-source-evidence/portal-trainee-sessions.json +36 -0
  103. package/data/docs/ux-source-evidence/portal-trainee-settings-notifications.json +38 -0
  104. package/data/docs/ux-source-evidence/portal-trainee-settings.json +18 -0
  105. package/data/docs/ux-source-evidence/portal-trainee.json +44 -0
  106. package/data/docs/ux-source-evidence/portal-tv-detail.json +39 -0
  107. package/data/docs/ux-source-evidence/portal-tv.json +44 -0
  108. package/data/docs/ux-source-evidence/shared-admin-admin-shell.json +35 -0
  109. package/data/docs/ux-source-evidence/shared-auth-auth-primitives.json +105 -0
  110. package/data/docs/ux-source-evidence/shared-auth-auth-shell.json +25 -0
  111. package/data/docs/ux-source-evidence/shared-auth-onboarding-primitives.json +52 -0
  112. package/data/docs/ux-source-evidence/shared-auth-onboarding-shell.json +28 -0
  113. package/data/docs/ux-source-evidence/shared-auth-status-indicator.json +41 -0
  114. package/data/docs/ux-source-evidence/shared-portal-empty-state.json +24 -0
  115. package/data/docs/ux-source-evidence/shared-portal-loading-skeleton.json +27 -0
  116. package/data/docs/ux-source-evidence/shared-portal-notifications-dropdown.json +39 -0
  117. package/data/docs/ux-source-evidence/shared-portal-portal-shell.json +36 -0
  118. package/data/docs/ux-source-evidence/shared-portal-profile-form.json +32 -0
  119. package/data/docs/ux-source-evidence/shared-portal-settings-view.json +81 -0
  120. package/data/docs/ux-source-evidence/shared-portal-usage-sparkline.json +18 -0
  121. package/data/docs/ux-source-evidence/shared-portal-user-menu.json +34 -0
  122. package/data/manifests/blocks.json +230 -21
  123. package/data/manifests/patterns.json +330 -0
  124. package/data/manifests/ui-patterns.json +1029 -0
  125. package/data/manifests/ux-examples.json +2177 -0
  126. package/data/manifests/ux-patterns.json +1135 -0
  127. package/data/manifests/ux-source-inventory.json +4669 -0
  128. package/data/ui-parts/blocks/analytics-dashboard/AnalyticsMetricBlocks.tsx +87 -0
  129. package/data/ui-parts/blocks/analytics-dashboard/analytics-dashboard.css +92 -0
  130. package/data/ui-parts/blocks/semantic-pattern-parts/AuthParts.tsx +48 -0
  131. package/data/ui-parts/blocks/semantic-pattern-parts/ChoiceCards.tsx +7 -0
  132. package/data/ui-parts/blocks/semantic-pattern-parts/ListFilters.tsx +9 -0
  133. package/data/ui-parts/blocks/semantic-pattern-parts/OutcomeNotice.tsx +7 -0
  134. package/data/ui-parts/blocks/semantic-pattern-parts/ProductParts.tsx +38 -0
  135. package/data/ui-parts/blocks/semantic-pattern-parts/ProgressSteps.tsx +6 -0
  136. package/data/ui-parts/blocks/semantic-pattern-parts/ReviewList.tsx +7 -0
  137. package/data/ui-parts/blocks/semantic-pattern-parts/ValidatedField.tsx +8 -0
  138. package/data/ui-parts/blocks/semantic-pattern-parts/WorkflowParts.tsx +339 -0
  139. package/data/ui-parts/blocks/semantic-pattern-parts/auth-parts.css +27 -0
  140. package/data/ui-parts/blocks/semantic-pattern-parts/pattern-parts.css +25 -0
  141. package/data/ui-parts/blocks/semantic-pattern-parts/product-parts.css +25 -0
  142. package/data/ui-parts/blocks/semantic-pattern-parts/types.ts +3 -0
  143. package/data/ui-parts/blocks/semantic-pattern-parts/workflow-parts.css +118 -0
  144. package/data/ui-parts/blocks/ux-pattern-examples/AccessExamples.tsx +46 -0
  145. package/data/ui-parts/blocks/ux-pattern-examples/AccountExamples.tsx +133 -0
  146. package/data/ui-parts/blocks/ux-pattern-examples/ProductExamples.tsx +79 -0
  147. package/data/ui-parts/blocks/ux-pattern-examples/WorkflowExample.tsx +1761 -0
  148. package/data/ui-parts/blocks/ux-pattern-examples/WorkflowExamples.tsx +190 -0
  149. package/data/ui-parts/blocks/ux-pattern-examples/copy.ts +6 -0
  150. package/data/ui-parts/blocks/ux-pattern-examples/ux-examples.css +18 -0
  151. package/data/ui-parts/blocks/ux-pattern-examples/workflow-copy.ts +76 -0
  152. package/data/ui-parts/blocks/ux-pattern-examples/workflow-definitions.ts +1291 -0
  153. package/data/ux-source-evidence/account-auth.json +41 -0
  154. package/data/ux-source-evidence/account-forgot-password.json +36 -0
  155. package/data/ux-source-evidence/account-invite.json +63 -0
  156. package/data/ux-source-evidence/account-locked.json +63 -0
  157. package/data/ux-source-evidence/account-login.json +28 -0
  158. package/data/ux-source-evidence/account-mfa.json +62 -0
  159. package/data/ux-source-evidence/account-onboarding-credentials.json +52 -0
  160. package/data/ux-source-evidence/account-onboarding-legal.json +56 -0
  161. package/data/ux-source-evidence/account-onboarding-orientation.json +66 -0
  162. package/data/ux-source-evidence/account-onboarding-profile.json +50 -0
  163. package/data/ux-source-evidence/account-onboarding.json +69 -0
  164. package/data/ux-source-evidence/account-reset-password.json +27 -0
  165. package/data/ux-source-evidence/account-session-expired.json +62 -0
  166. package/data/ux-source-evidence/account-signup.json +33 -0
  167. package/data/ux-source-evidence/account-verify-email.json +34 -0
  168. package/data/ux-source-evidence/account-welcome.json +41 -0
  169. package/data/ux-source-evidence/admin-analytics.json +54 -0
  170. package/data/ux-source-evidence/admin-audit.json +31 -0
  171. package/data/ux-source-evidence/admin-broadcasts-new.json +36 -0
  172. package/data/ux-source-evidence/admin-broadcasts.json +41 -0
  173. package/data/ux-source-evidence/admin-closures.json +52 -0
  174. package/data/ux-source-evidence/admin-devices.json +51 -0
  175. package/data/ux-source-evidence/admin-escalations.json +35 -0
  176. package/data/ux-source-evidence/admin-incidents.json +54 -0
  177. package/data/ux-source-evidence/admin-loading.json +17 -0
  178. package/data/ux-source-evidence/admin-onboarding-detail.json +80 -0
  179. package/data/ux-source-evidence/admin-onboarding.json +62 -0
  180. package/data/ux-source-evidence/admin-payments.json +45 -0
  181. package/data/ux-source-evidence/admin-providers.json +38 -0
  182. package/data/ux-source-evidence/admin-settings.json +18 -0
  183. package/data/ux-source-evidence/admin-subscriptions-detail-allocate.json +42 -0
  184. package/data/ux-source-evidence/admin-subscriptions-detail.json +48 -0
  185. package/data/ux-source-evidence/admin-subscriptions-new.json +43 -0
  186. package/data/ux-source-evidence/admin-subscriptions.json +41 -0
  187. package/data/ux-source-evidence/admin-transactions.json +41 -0
  188. package/data/ux-source-evidence/error.json +30 -0
  189. package/data/ux-source-evidence/loading.json +19 -0
  190. package/data/ux-source-evidence/marketing-blog.json +48 -0
  191. package/data/ux-source-evidence/marketing-contact.json +36 -0
  192. package/data/ux-source-evidence/marketing.json +64 -0
  193. package/data/ux-source-evidence/not-found.json +28 -0
  194. package/data/ux-source-evidence/portal-loading.json +17 -0
  195. package/data/ux-source-evidence/portal-mentor-messages.json +31 -0
  196. package/data/ux-source-evidence/portal-mentor-profile.json +23 -0
  197. package/data/ux-source-evidence/portal-mentor-requests-detail.json +54 -0
  198. package/data/ux-source-evidence/portal-mentor-sessions-detail-assess.json +42 -0
  199. package/data/ux-source-evidence/portal-mentor-settings.json +18 -0
  200. package/data/ux-source-evidence/portal-mentor.json +38 -0
  201. package/data/ux-source-evidence/portal-sessions-detail-live.json +48 -0
  202. package/data/ux-source-evidence/portal-sponsor-billing.json +28 -0
  203. package/data/ux-source-evidence/portal-sponsor-profile.json +22 -0
  204. package/data/ux-source-evidence/portal-sponsor-sessions.json +28 -0
  205. package/data/ux-source-evidence/portal-sponsor-settings.json +18 -0
  206. package/data/ux-source-evidence/portal-sponsor-trainees.json +36 -0
  207. package/data/ux-source-evidence/portal-sponsor.json +52 -0
  208. package/data/ux-source-evidence/portal-trainee-competency.json +42 -0
  209. package/data/ux-source-evidence/portal-trainee-credits.json +40 -0
  210. package/data/ux-source-evidence/portal-trainee-mentors.json +48 -0
  211. package/data/ux-source-evidence/portal-trainee-messages.json +32 -0
  212. package/data/ux-source-evidence/portal-trainee-notifications.json +35 -0
  213. package/data/ux-source-evidence/portal-trainee-profile.json +26 -0
  214. package/data/ux-source-evidence/portal-trainee-request.json +54 -0
  215. package/data/ux-source-evidence/portal-trainee-sessions-detail-feedback.json +36 -0
  216. package/data/ux-source-evidence/portal-trainee-sessions-detail-precheck.json +42 -0
  217. package/data/ux-source-evidence/portal-trainee-sessions-detail.json +55 -0
  218. package/data/ux-source-evidence/portal-trainee-sessions.json +36 -0
  219. package/data/ux-source-evidence/portal-trainee-settings-notifications.json +38 -0
  220. package/data/ux-source-evidence/portal-trainee-settings.json +18 -0
  221. package/data/ux-source-evidence/portal-trainee.json +44 -0
  222. package/data/ux-source-evidence/portal-tv-detail.json +39 -0
  223. package/data/ux-source-evidence/portal-tv.json +44 -0
  224. package/data/ux-source-evidence/shared-admin-admin-shell.json +35 -0
  225. package/data/ux-source-evidence/shared-auth-auth-primitives.json +105 -0
  226. package/data/ux-source-evidence/shared-auth-auth-shell.json +25 -0
  227. package/data/ux-source-evidence/shared-auth-onboarding-primitives.json +52 -0
  228. package/data/ux-source-evidence/shared-auth-onboarding-shell.json +28 -0
  229. package/data/ux-source-evidence/shared-auth-status-indicator.json +41 -0
  230. package/data/ux-source-evidence/shared-portal-empty-state.json +24 -0
  231. package/data/ux-source-evidence/shared-portal-loading-skeleton.json +27 -0
  232. package/data/ux-source-evidence/shared-portal-notifications-dropdown.json +39 -0
  233. package/data/ux-source-evidence/shared-portal-portal-shell.json +36 -0
  234. package/data/ux-source-evidence/shared-portal-profile-form.json +32 -0
  235. package/data/ux-source-evidence/shared-portal-settings-view.json +81 -0
  236. package/data/ux-source-evidence/shared-portal-usage-sparkline.json +18 -0
  237. package/data/ux-source-evidence/shared-portal-user-menu.json +34 -0
  238. package/lib/api.mjs +230 -4
  239. package/package.json +2 -2
@@ -0,0 +1,44 @@
1
+ {
2
+ "revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
3
+ "file": "app/portal/tv/page.tsx",
4
+ "sha256": "cfcd66fa8f7a6ae9c798f86703bde6d59c389468dce26a2d4a33f7dc9e861f5c",
5
+ "imports": [
6
+ "next/link",
7
+ "lucide-react",
8
+ "@/components/ui/button",
9
+ "@/components/ui/badge",
10
+ "@/components/ui/card",
11
+ "@/components/portal/portal-shell",
12
+ "@/components/portal/empty-state",
13
+ "@/lib/broadcasts-mock"
14
+ ],
15
+ "tokens": [
16
+ "--color-bg-default",
17
+ "--color-bg-subtle",
18
+ "--color-text-brand",
19
+ "--color-text-muted",
20
+ "--color-text-secondary",
21
+ "--gray-0",
22
+ "--gray-950",
23
+ "--red-500",
24
+ "--teal-500"
25
+ ],
26
+ "fragments": [
27
+ {
28
+ "id": "MentixTvPage",
29
+ "line": 12,
30
+ "code": "export default function MentixTvPage() {\n const live = BROADCASTS.filter((b) => b.state === \"live\");\n const scheduled = BROADCASTS.filter((b) => b.state === \"scheduled\");\n const past = BROADCASTS.filter((b) => b.state === \"ended\");\n\n return (\n <PortalShell kind=\"trainee\">\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 flex items-center gap-3\" style={{ letterSpacing: \"-0.025em\" }}>\n <Tv size={26} strokeWidth={1.6} style={{ color: \"var(--color-text-brand)\" }} />\n Mentix TV\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n Live and on-demand expert sessions, masterclasses, and product education.\n </p>\n </div>\n </div>\n\n {/* Live now (hero) */}\n {live.length > 0 && (\n <section className=\"mb-8\">\n <p className=\"text-[11px] font-semibold uppercase tracking-[0.15em] mb-3 inline-flex items-center gap-2\" style={{ color: \"var(--red-500)\" }}>\n <span className=\"w-1.5 h-1.5 rounded-full animate-pulse\" style={{ background: \"var(--red-500)\" }} />\n Live now\n </p>\n <div className=\"grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-4\">\n {live.map((b) => <LiveHero key={b.id} b={b} />)}\n </div>\n </section>\n )}\n\n {/* Scheduled */}\n <section className=\"mb-8\">\n <p className=\"text-[11px] font-semibold uppercase tracking-[0.15em] mb-3\" style={{ color: \"var(--color-text-muted)\" }}>Coming up</p>\n {scheduled.length === 0 ? (\n <EmptyState icon={Calendar} title=\"No upcoming broadcasts\" description=\"Check back soon for new masterclasses and product education sessions.\" />\n ) : (\n <div className=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3\">\n {scheduled.map((b) => <BroadcastCard key={b.id} b={b} />)}\n </div>\n )}\n </section>\n\n {/* Past */}\n <section>\n <p className=\"text-[11px] font-semibold uppercase tracking-[0.15em] mb-3\" style={{ color: \"var(--color-text-muted)\" }}>Past broadcasts</p>\n {past.length === 0 ? (\n <EmptyState icon={Tv} title=\"No past broadcasts yet\" description=\"Watch later when sessions are over.\" />\n ) : (\n <div className=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3\">\n {past.map((b) => <BroadcastCard key={b.id} b={b} />)}\n </div>\n )}\n </section>\n </PortalShell>\n );\n}"
31
+ },
32
+ {
33
+ "id": "LiveHero",
34
+ "line": 71,
35
+ "code": "function LiveHero({ b }: { b: Broadcast }) {\n return (\n <>\n <Link href={`/portal/tv/${b.id}`} className=\"group\">\n <Card className=\"overflow-hidden\">\n <div className=\"aspect-video relative\" style={{ background: \"var(--color-bg-subtle)\" }}>\n {b.ytId ? (\n <img\n src={`https://i.ytimg.com/vi/${b.ytId}/maxresdefault.jpg`}\n alt={b.title}\n className=\"absolute inset-0 w-full h-full object-cover\"\n />\n ) : null}\n <div className=\"absolute inset-0 flex items-center justify-center transition-opacity group-hover:bg-[color-mix(in_srgb,_var(--gray-950)_30%,_transparent)]\">\n <div className=\"w-16 h-16 rounded-full flex items-center justify-center transition-transform group-hover:scale-110\" style={{ background: \"color-mix(in srgb, var(--gray-950) 60%, transparent)\", color: \"var(--gray-0)\", backdropFilter: \"blur(4px)\" }}>\n <Radio size={26} strokeWidth={1.6} />\n </div>\n </div>\n <div className=\"absolute top-3 left-3 flex items-center gap-2\">\n <Badge className=\"text-[10px] inline-flex items-center gap-1\" style={{ background: \"var(--red-500)\", color: \"var(--gray-0)\" }}>\n <span className=\"w-1.5 h-1.5 rounded-full animate-pulse\" style={{ background: \"var(--gray-0)\" }} /> LIVE\n </Badge>\n <Badge className=\"text-[10px]\" style={{ background: \"color-mix(in srgb, var(--gray-950) 60%, transparent)\", color: \"var(--gray-0)\", backdropFilter: \"blur(4px)\" }}>\n <Users size={9} className=\"mr-1\" />\n {b.viewers} watching\n </Badge>\n </div>\n </div>\n </Card>\n </Link>\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">{b.title}</CardTitle>\n <CardDescription>{b.description}</CardDescription>\n </CardHeader>\n <CardContent>\n <div className=\"flex items-center gap-3 mb-4\">\n <div className=\"w-9 h-9 rounded-full flex items-center justify-center text-[11px] font-semibold shrink-0\" style={{ background: \"var(--teal-500)\", color: \"var(--gray-950)\" }}>{b.hostInitials}</div>\n <div className=\"min-w-0\">\n <div className=\"text-[13px] font-medium truncate\">{b.host}</div>\n <div className=\"text-[11px]\" style={{ color: \"var(--color-text-muted)\" }}>Host</div>\n </div>\n </div>\n <Link href={`/portal/tv/${b.id}`}>\n <Button variant=\"brand\" className=\"w-full\">Join live now <ArrowRight size={13} /></Button>\n </Link>\n <p className=\"text-[10px] text-center mt-3\" style={{ color: \"var(--color-text-muted)\" }}>Free to attend · receive-only viewing</p>\n </CardContent>\n </Card>\n </>\n );\n}"
36
+ },
37
+ {
38
+ "id": "BroadcastCard",
39
+ "line": 124,
40
+ "code": "function BroadcastCard({ b }: { b: Broadcast }) {\n const Icon = b.state === \"live\" ? Radio : b.state === \"scheduled\" ? Calendar : Tv;\n return (\n <Link href={`/portal/tv/${b.id}`} className=\"group block\">\n <Card className=\"overflow-hidden h-full transition-shadow hover:shadow-md\">\n <div className=\"aspect-video relative\" style={{ background: \"var(--color-bg-subtle)\" }}>\n <div className=\"absolute inset-0 flex items-center justify-center\" style={{ color: \"var(--color-text-muted)\" }}>\n <Icon size={32} strokeWidth={1.4} />\n </div>\n {b.state === \"ended\" && b.viewers && (\n <Badge className=\"absolute top-3 right-3 text-[10px]\" variant=\"outline\" style={{ background: \"var(--color-bg-default)\" }}>\n <Users size={9} className=\"mr-1\" /> {b.viewers} attended\n </Badge>\n )}\n {b.access === \"cohort\" && (\n <Badge className=\"absolute top-3 left-3 text-[10px]\" variant=\"outline\" style={{ background: \"var(--color-bg-default)\" }}>\n <Lock size={9} className=\"mr-1\" /> Cohort\n </Badge>\n )}\n </div>\n <CardContent className=\"pt-4\">\n <div className=\"text-[13px] font-semibold mb-1 line-clamp-2 transition-colors group-hover:text-[var(--color-text-brand)]\">{b.title}</div>\n <div className=\"text-[11px] mb-2\" style={{ color: \"var(--color-text-muted)\" }}>\n {b.host} · {fmtBroadcastTime(b.scheduledFor)} · {b.durationMin}m\n </div>\n <p className=\"text-[11px] line-clamp-2\" style={{ color: \"var(--color-text-secondary)\" }}>{b.description}</p>\n </CardContent>\n </Card>\n </Link>\n );\n}"
41
+ }
42
+ ],
43
+ "content": "\"use client\";\n\nimport Link from \"next/link\";\nimport { Tv, Calendar, Users, Radio, ArrowRight, Lock } 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 { PortalShell } from \"@/components/portal/portal-shell\";\nimport { EmptyState } from \"@/components/portal/empty-state\";\nimport { BROADCASTS, fmtBroadcastTime, type Broadcast } from \"@/lib/broadcasts-mock\";\n\nexport default function MentixTvPage() {\n const live = BROADCASTS.filter((b) => b.state === \"live\");\n const scheduled = BROADCASTS.filter((b) => b.state === \"scheduled\");\n const past = BROADCASTS.filter((b) => b.state === \"ended\");\n\n return (\n <PortalShell kind=\"trainee\">\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 flex items-center gap-3\" style={{ letterSpacing: \"-0.025em\" }}>\n <Tv size={26} strokeWidth={1.6} style={{ color: \"var(--color-text-brand)\" }} />\n Mentix TV\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n Live and on-demand expert sessions, masterclasses, and product education.\n </p>\n </div>\n </div>\n\n {/* Live now (hero) */}\n {live.length > 0 && (\n <section className=\"mb-8\">\n <p className=\"text-[11px] font-semibold uppercase tracking-[0.15em] mb-3 inline-flex items-center gap-2\" style={{ color: \"var(--red-500)\" }}>\n <span className=\"w-1.5 h-1.5 rounded-full animate-pulse\" style={{ background: \"var(--red-500)\" }} />\n Live now\n </p>\n <div className=\"grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-4\">\n {live.map((b) => <LiveHero key={b.id} b={b} />)}\n </div>\n </section>\n )}\n\n {/* Scheduled */}\n <section className=\"mb-8\">\n <p className=\"text-[11px] font-semibold uppercase tracking-[0.15em] mb-3\" style={{ color: \"var(--color-text-muted)\" }}>Coming up</p>\n {scheduled.length === 0 ? (\n <EmptyState icon={Calendar} title=\"No upcoming broadcasts\" description=\"Check back soon for new masterclasses and product education sessions.\" />\n ) : (\n <div className=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3\">\n {scheduled.map((b) => <BroadcastCard key={b.id} b={b} />)}\n </div>\n )}\n </section>\n\n {/* Past */}\n <section>\n <p className=\"text-[11px] font-semibold uppercase tracking-[0.15em] mb-3\" style={{ color: \"var(--color-text-muted)\" }}>Past broadcasts</p>\n {past.length === 0 ? (\n <EmptyState icon={Tv} title=\"No past broadcasts yet\" description=\"Watch later when sessions are over.\" />\n ) : (\n <div className=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3\">\n {past.map((b) => <BroadcastCard key={b.id} b={b} />)}\n </div>\n )}\n </section>\n </PortalShell>\n );\n}\n\nfunction LiveHero({ b }: { b: Broadcast }) {\n return (\n <>\n <Link href={`/portal/tv/${b.id}`} className=\"group\">\n <Card className=\"overflow-hidden\">\n <div className=\"aspect-video relative\" style={{ background: \"var(--color-bg-subtle)\" }}>\n {b.ytId ? (\n <img\n src={`https://i.ytimg.com/vi/${b.ytId}/maxresdefault.jpg`}\n alt={b.title}\n className=\"absolute inset-0 w-full h-full object-cover\"\n />\n ) : null}\n <div className=\"absolute inset-0 flex items-center justify-center transition-opacity group-hover:bg-[color-mix(in_srgb,_var(--gray-950)_30%,_transparent)]\">\n <div className=\"w-16 h-16 rounded-full flex items-center justify-center transition-transform group-hover:scale-110\" style={{ background: \"color-mix(in srgb, var(--gray-950) 60%, transparent)\", color: \"var(--gray-0)\", backdropFilter: \"blur(4px)\" }}>\n <Radio size={26} strokeWidth={1.6} />\n </div>\n </div>\n <div className=\"absolute top-3 left-3 flex items-center gap-2\">\n <Badge className=\"text-[10px] inline-flex items-center gap-1\" style={{ background: \"var(--red-500)\", color: \"var(--gray-0)\" }}>\n <span className=\"w-1.5 h-1.5 rounded-full animate-pulse\" style={{ background: \"var(--gray-0)\" }} /> LIVE\n </Badge>\n <Badge className=\"text-[10px]\" style={{ background: \"color-mix(in srgb, var(--gray-950) 60%, transparent)\", color: \"var(--gray-0)\", backdropFilter: \"blur(4px)\" }}>\n <Users size={9} className=\"mr-1\" />\n {b.viewers} watching\n </Badge>\n </div>\n </div>\n </Card>\n </Link>\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">{b.title}</CardTitle>\n <CardDescription>{b.description}</CardDescription>\n </CardHeader>\n <CardContent>\n <div className=\"flex items-center gap-3 mb-4\">\n <div className=\"w-9 h-9 rounded-full flex items-center justify-center text-[11px] font-semibold shrink-0\" style={{ background: \"var(--teal-500)\", color: \"var(--gray-950)\" }}>{b.hostInitials}</div>\n <div className=\"min-w-0\">\n <div className=\"text-[13px] font-medium truncate\">{b.host}</div>\n <div className=\"text-[11px]\" style={{ color: \"var(--color-text-muted)\" }}>Host</div>\n </div>\n </div>\n <Link href={`/portal/tv/${b.id}`}>\n <Button variant=\"brand\" className=\"w-full\">Join live now <ArrowRight size={13} /></Button>\n </Link>\n <p className=\"text-[10px] text-center mt-3\" style={{ color: \"var(--color-text-muted)\" }}>Free to attend · receive-only viewing</p>\n </CardContent>\n </Card>\n </>\n );\n}\n\nfunction BroadcastCard({ b }: { b: Broadcast }) {\n const Icon = b.state === \"live\" ? Radio : b.state === \"scheduled\" ? Calendar : Tv;\n return (\n <Link href={`/portal/tv/${b.id}`} className=\"group block\">\n <Card className=\"overflow-hidden h-full transition-shadow hover:shadow-md\">\n <div className=\"aspect-video relative\" style={{ background: \"var(--color-bg-subtle)\" }}>\n <div className=\"absolute inset-0 flex items-center justify-center\" style={{ color: \"var(--color-text-muted)\" }}>\n <Icon size={32} strokeWidth={1.4} />\n </div>\n {b.state === \"ended\" && b.viewers && (\n <Badge className=\"absolute top-3 right-3 text-[10px]\" variant=\"outline\" style={{ background: \"var(--color-bg-default)\" }}>\n <Users size={9} className=\"mr-1\" /> {b.viewers} attended\n </Badge>\n )}\n {b.access === \"cohort\" && (\n <Badge className=\"absolute top-3 left-3 text-[10px]\" variant=\"outline\" style={{ background: \"var(--color-bg-default)\" }}>\n <Lock size={9} className=\"mr-1\" /> Cohort\n </Badge>\n )}\n </div>\n <CardContent className=\"pt-4\">\n <div className=\"text-[13px] font-semibold mb-1 line-clamp-2 transition-colors group-hover:text-[var(--color-text-brand)]\">{b.title}</div>\n <div className=\"text-[11px] mb-2\" style={{ color: \"var(--color-text-muted)\" }}>\n {b.host} · {fmtBroadcastTime(b.scheduledFor)} · {b.durationMin}m\n </div>\n <p className=\"text-[11px] line-clamp-2\" style={{ color: \"var(--color-text-secondary)\" }}>{b.description}</p>\n </CardContent>\n </Card>\n </Link>\n );\n}\n"
44
+ }
@@ -0,0 +1,35 @@
1
+ {
2
+ "revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
3
+ "file": "components/admin/admin-shell.tsx",
4
+ "sha256": "c3b0ede5784478e68a7c13602f2b73d1ac83f3bee7e3e5b29395708fc4655ace",
5
+ "imports": [
6
+ "next/link",
7
+ "next/navigation",
8
+ "react",
9
+ "framer-motion",
10
+ "lucide-react",
11
+ "@/components/ui/button",
12
+ "@/components/design-system/theme-toggle",
13
+ "@/components/portal/notifications-dropdown",
14
+ "@/components/portal/user-menu"
15
+ ],
16
+ "tokens": [
17
+ "--color-bg-brand-muted",
18
+ "--color-bg-default",
19
+ "--color-bg-subtle",
20
+ "--color-border-default",
21
+ "--color-text-brand",
22
+ "--color-text-muted",
23
+ "--color-text-primary",
24
+ "--color-text-secondary",
25
+ "--gray-950"
26
+ ],
27
+ "fragments": [
28
+ {
29
+ "id": "AdminShell",
30
+ "line": 43,
31
+ "code": "export function AdminShell({ children }: { children: ReactNode }) {\n const pathname = usePathname();\n\n const [collapsed, setCollapsed] = useState(false);\n const [mobileOpen, setMobileOpen] = useState(false);\n\n useEffect(() => {\n try {\n const saved = localStorage.getItem(\"mentix.sidebar.collapsed\");\n if (saved === \"1\") setCollapsed(true);\n } catch {}\n }, []);\n const toggleCollapsed = () => {\n setCollapsed((c) => {\n const next = !c;\n try { localStorage.setItem(\"mentix.sidebar.collapsed\", next ? \"1\" : \"0\"); } catch {}\n return next;\n });\n };\n\n useEffect(() => { setMobileOpen(false); }, [pathname]);\n\n const sidebarWidth = collapsed ? \"w-[60px]\" : \"w-60\";\n const mainMargin = collapsed ? \"lg:ml-[60px]\" : \"lg:ml-60\";\n\n return (\n <div className=\"min-h-screen flex\" style={{ background: \"var(--color-bg-default)\", color: \"var(--color-text-primary)\" }}>\n {mobileOpen && (\n <button\n aria-label=\"Close menu\"\n className=\"fixed inset-0 z-30 lg:hidden\"\n style={{ background: \"color-mix(in srgb, var(--gray-950) 50%, transparent)\" }}\n onClick={() => setMobileOpen(false)}\n />\n )}\n\n <aside\n className={`fixed top-0 left-0 h-screen ${sidebarWidth} flex flex-col border-r z-40 transition-all duration-200\n ${mobileOpen ? \"translate-x-0\" : \"-translate-x-full\"} lg:translate-x-0`}\n style={{ background: \"var(--color-bg-subtle)\", borderColor: \"var(--color-border-default)\" }}\n >\n <div className={`h-14 flex items-center justify-between border-b shrink-0 ${collapsed ? \"px-2.5\" : \"px-5\"}`} style={{ borderColor: \"var(--color-border-default)\" }}>\n {!collapsed ? (\n <>\n <Link href=\"/admin/onboarding\" className=\"flex items-center gap-2.5 group min-w-0\">\n <img src=\"/mentix-logo.png\" alt=\"\" style={{ height: 22, width: 22 }} />\n <span className=\"text-[13px] font-semibold tracking-tight truncate\">\n Mentix <span style={{ color: \"var(--color-text-brand)\" }}>Admin</span>\n </span>\n </Link>\n <Button variant=\"ghost\" size=\"icon-sm\" onClick={toggleCollapsed} aria-label=\"Collapse sidebar\" className=\"hidden lg:inline-flex\">\n <PanelLeftClose size={14} strokeWidth={1.7} />\n </Button>\n </>\n ) : (\n <button\n type=\"button\"\n onClick={toggleCollapsed}\n aria-label=\"Expand sidebar\"\n title=\"Expand sidebar\"\n className=\"mx-auto flex items-center justify-center w-9 h-9 rounded-md transition-colors hover:bg-[var(--color-bg-default)]\"\n >\n <img src=\"/mentix-logo.png\" alt=\"Mentix\" style={{ height: 22, width: 22 }} />\n </button>\n )}\n </div>\n\n <nav className={`flex-1 overflow-y-auto py-4 space-y-0.5 ${collapsed ? \"px-2\" : \"px-3\"}`}>\n {NAV.map((item) => {\n const Icon = item.icon;\n const active = pathname === item.href;\n return (\n <Link\n key={item.label}\n href={item.href}\n className={`flex items-center rounded-md text-[13px] transition-colors ${collapsed ? \"justify-center w-10 h-10\" : \"gap-2.5 px-2.5 py-2\"}`}\n style={{\n color: active ? \"var(--color-text-brand)\" : \"var(--color-text-secondary)\",\n background: active ? \"var(--color-bg-brand-muted)\" : \"transparent\",\n fontWeight: active ? 500 : 400,\n }}\n title={collapsed ? item.label : undefined}\n >\n <Icon size={15} strokeWidth={1.7} />\n {!collapsed && <span className=\"truncate\">{item.label}</span>}\n </Link>\n );\n })}\n </nav>\n\n {!collapsed && (\n <div className=\"px-3 py-3 border-t\" style={{ borderColor: \"var(--color-border-default)\" }}>\n <Link\n href=\"/admin/settings\"\n className=\"flex items-center gap-2.5 px-2.5 py-2 rounded-md text-[13px] transition-colors hover:bg-[var(--color-bg-default)]\"\n style={{ color: \"var(--color-text-muted)\" }}\n >\n <Settings size={15} strokeWidth={1.7} />\n Settings\n </Link>\n </div>\n )}\n </aside>\n\n <main className={`flex-1 min-w-0 min-h-screen ${mainMargin} transition-[margin] duration-200`}>\n <header\n className=\"h-14 sticky top-0 z-20 flex items-center justify-between px-4 sm:px-6 lg:px-8 border-b\"\n style={{ background: \"var(--color-bg-default)\", borderColor: \"var(--color-border-default)\" }}\n >\n <div className=\"flex items-center gap-2\">\n <Button variant=\"ghost\" size=\"icon-sm\" onClick={() => setMobileOpen(true)} aria-label=\"Open menu\" className=\"lg:hidden\">\n <Menu size={16} strokeWidth={1.8} />\n </Button>\n {collapsed && (\n <Button variant=\"ghost\" size=\"icon-sm\" onClick={toggleCollapsed} aria-label=\"Expand sidebar\" className=\"hidden lg:inline-flex\">\n <PanelLeftOpen size={16} strokeWidth={1.8} />\n </Button>\n )}\n <div className=\"hidden sm:flex items-center gap-3 text-[12px]\" style={{ color: \"var(--color-text-muted)\" }}>\n <span style={{ color: \"var(--color-text-secondary)\" }}>Mentix Admin</span>\n <span>/</span>\n <span style={{ color: \"var(--color-text-primary)\" }}>Operations</span>\n </div>\n </div>\n <div className=\"flex items-center gap-1.5\">\n <ThemeToggle />\n <NotificationsDropdown items={ADMIN_NOTIFS} notifHref=\"/admin/notifications\" />\n <UserMenu user={ADMIN_USER} />\n </div>\n </header>\n\n <AnimatePresence mode=\"wait\">\n <motion.div\n key={pathname}\n initial={{ opacity: 0, y: 8 }}\n animate={{ opacity: 1, y: 0 }}\n exit={{ opacity: 0, y: -4 }}\n transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}\n className=\"px-4 sm:px-6 lg:px-8 py-6 lg:py-8\"\n >\n {children}\n </motion.div>\n </AnimatePresence>\n </main>\n </div>\n );\n}"
32
+ }
33
+ ],
34
+ "content": "\"use client\";\n\nimport Link from \"next/link\";\nimport { usePathname } from \"next/navigation\";\nimport { ReactNode, useEffect, useState } from \"react\";\nimport { motion, AnimatePresence } from \"framer-motion\";\nimport {\n LayoutDashboard, ShieldCheck, Users, FileSearch, Bell, Settings,\n Menu, PanelLeftClose, PanelLeftOpen, Coins, Receipt, BarChart3, Tv,\n} from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { ThemeToggle } from \"@/components/design-system/theme-toggle\";\nimport { NotificationsDropdown, type Notif } from \"@/components/portal/notifications-dropdown\";\nimport { UserMenu } from \"@/components/portal/user-menu\";\n\nconst NAV = [\n { label: \"Dashboard\", href: \"/admin/onboarding\", icon: LayoutDashboard },\n { label: \"Verification\", href: \"/admin/onboarding\", icon: ShieldCheck },\n { label: \"Subscriptions\", href: \"/admin/subscriptions\", icon: Coins },\n { label: \"Transactions\", href: \"/admin/transactions\", icon: Receipt },\n { label: \"Analytics\", href: \"/admin/analytics\", icon: BarChart3 },\n { label: \"Mentix TV\", href: \"/admin/broadcasts\", icon: Tv },\n { label: \"All users\", href: \"/admin/users\", icon: Users },\n { label: \"Audit log\", href: \"/admin/audit\", icon: FileSearch },\n];\n\nconst ADMIN_NOTIFS: Notif[] = [\n { id: \"a1\", kind: \"alert\", title: \"SLA breach: Dr. Yamamoto\", body: \"Under verification > 7 days\", time: \"3 min ago\", unread: true, href: \"/admin/onboarding/app-002\" },\n { id: \"a2\", kind: \"system\", title: \"Escalation queue: 2 new\", body: \"Generic broadcast un-matched\", time: \"1 h ago\", unread: true, href: \"/admin/onboarding\" },\n { id: \"a3\", kind: \"session\", title: \"New subscription created\", body: \"Medtronic Europe · Enterprise\", time: \"Today\", unread: false },\n];\n\nconst ADMIN_USER = {\n name: \"Eleanor Whitmore\",\n initials: \"SK\",\n role: \"Admin\",\n id: \"MX-A001\",\n email: \"sarah.kim@mentix.world\",\n profileHref: \"/admin/settings\",\n settingsHref: \"/admin/settings\",\n};\n\nexport function AdminShell({ children }: { children: ReactNode }) {\n const pathname = usePathname();\n\n const [collapsed, setCollapsed] = useState(false);\n const [mobileOpen, setMobileOpen] = useState(false);\n\n useEffect(() => {\n try {\n const saved = localStorage.getItem(\"mentix.sidebar.collapsed\");\n if (saved === \"1\") setCollapsed(true);\n } catch {}\n }, []);\n const toggleCollapsed = () => {\n setCollapsed((c) => {\n const next = !c;\n try { localStorage.setItem(\"mentix.sidebar.collapsed\", next ? \"1\" : \"0\"); } catch {}\n return next;\n });\n };\n\n useEffect(() => { setMobileOpen(false); }, [pathname]);\n\n const sidebarWidth = collapsed ? \"w-[60px]\" : \"w-60\";\n const mainMargin = collapsed ? \"lg:ml-[60px]\" : \"lg:ml-60\";\n\n return (\n <div className=\"min-h-screen flex\" style={{ background: \"var(--color-bg-default)\", color: \"var(--color-text-primary)\" }}>\n {mobileOpen && (\n <button\n aria-label=\"Close menu\"\n className=\"fixed inset-0 z-30 lg:hidden\"\n style={{ background: \"color-mix(in srgb, var(--gray-950) 50%, transparent)\" }}\n onClick={() => setMobileOpen(false)}\n />\n )}\n\n <aside\n className={`fixed top-0 left-0 h-screen ${sidebarWidth} flex flex-col border-r z-40 transition-all duration-200\n ${mobileOpen ? \"translate-x-0\" : \"-translate-x-full\"} lg:translate-x-0`}\n style={{ background: \"var(--color-bg-subtle)\", borderColor: \"var(--color-border-default)\" }}\n >\n <div className={`h-14 flex items-center justify-between border-b shrink-0 ${collapsed ? \"px-2.5\" : \"px-5\"}`} style={{ borderColor: \"var(--color-border-default)\" }}>\n {!collapsed ? (\n <>\n <Link href=\"/admin/onboarding\" className=\"flex items-center gap-2.5 group min-w-0\">\n <img src=\"/mentix-logo.png\" alt=\"\" style={{ height: 22, width: 22 }} />\n <span className=\"text-[13px] font-semibold tracking-tight truncate\">\n Mentix <span style={{ color: \"var(--color-text-brand)\" }}>Admin</span>\n </span>\n </Link>\n <Button variant=\"ghost\" size=\"icon-sm\" onClick={toggleCollapsed} aria-label=\"Collapse sidebar\" className=\"hidden lg:inline-flex\">\n <PanelLeftClose size={14} strokeWidth={1.7} />\n </Button>\n </>\n ) : (\n <button\n type=\"button\"\n onClick={toggleCollapsed}\n aria-label=\"Expand sidebar\"\n title=\"Expand sidebar\"\n className=\"mx-auto flex items-center justify-center w-9 h-9 rounded-md transition-colors hover:bg-[var(--color-bg-default)]\"\n >\n <img src=\"/mentix-logo.png\" alt=\"Mentix\" style={{ height: 22, width: 22 }} />\n </button>\n )}\n </div>\n\n <nav className={`flex-1 overflow-y-auto py-4 space-y-0.5 ${collapsed ? \"px-2\" : \"px-3\"}`}>\n {NAV.map((item) => {\n const Icon = item.icon;\n const active = pathname === item.href;\n return (\n <Link\n key={item.label}\n href={item.href}\n className={`flex items-center rounded-md text-[13px] transition-colors ${collapsed ? \"justify-center w-10 h-10\" : \"gap-2.5 px-2.5 py-2\"}`}\n style={{\n color: active ? \"var(--color-text-brand)\" : \"var(--color-text-secondary)\",\n background: active ? \"var(--color-bg-brand-muted)\" : \"transparent\",\n fontWeight: active ? 500 : 400,\n }}\n title={collapsed ? item.label : undefined}\n >\n <Icon size={15} strokeWidth={1.7} />\n {!collapsed && <span className=\"truncate\">{item.label}</span>}\n </Link>\n );\n })}\n </nav>\n\n {!collapsed && (\n <div className=\"px-3 py-3 border-t\" style={{ borderColor: \"var(--color-border-default)\" }}>\n <Link\n href=\"/admin/settings\"\n className=\"flex items-center gap-2.5 px-2.5 py-2 rounded-md text-[13px] transition-colors hover:bg-[var(--color-bg-default)]\"\n style={{ color: \"var(--color-text-muted)\" }}\n >\n <Settings size={15} strokeWidth={1.7} />\n Settings\n </Link>\n </div>\n )}\n </aside>\n\n <main className={`flex-1 min-w-0 min-h-screen ${mainMargin} transition-[margin] duration-200`}>\n <header\n className=\"h-14 sticky top-0 z-20 flex items-center justify-between px-4 sm:px-6 lg:px-8 border-b\"\n style={{ background: \"var(--color-bg-default)\", borderColor: \"var(--color-border-default)\" }}\n >\n <div className=\"flex items-center gap-2\">\n <Button variant=\"ghost\" size=\"icon-sm\" onClick={() => setMobileOpen(true)} aria-label=\"Open menu\" className=\"lg:hidden\">\n <Menu size={16} strokeWidth={1.8} />\n </Button>\n {collapsed && (\n <Button variant=\"ghost\" size=\"icon-sm\" onClick={toggleCollapsed} aria-label=\"Expand sidebar\" className=\"hidden lg:inline-flex\">\n <PanelLeftOpen size={16} strokeWidth={1.8} />\n </Button>\n )}\n <div className=\"hidden sm:flex items-center gap-3 text-[12px]\" style={{ color: \"var(--color-text-muted)\" }}>\n <span style={{ color: \"var(--color-text-secondary)\" }}>Mentix Admin</span>\n <span>/</span>\n <span style={{ color: \"var(--color-text-primary)\" }}>Operations</span>\n </div>\n </div>\n <div className=\"flex items-center gap-1.5\">\n <ThemeToggle />\n <NotificationsDropdown items={ADMIN_NOTIFS} notifHref=\"/admin/notifications\" />\n <UserMenu user={ADMIN_USER} />\n </div>\n </header>\n\n <AnimatePresence mode=\"wait\">\n <motion.div\n key={pathname}\n initial={{ opacity: 0, y: 8 }}\n animate={{ opacity: 1, y: 0 }}\n exit={{ opacity: 0, y: -4 }}\n transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}\n className=\"px-4 sm:px-6 lg:px-8 py-6 lg:py-8\"\n >\n {children}\n </motion.div>\n </AnimatePresence>\n </main>\n </div>\n );\n}\n"
35
+ }
@@ -0,0 +1,105 @@
1
+ {
2
+ "revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
3
+ "file": "components/auth/auth-primitives.tsx",
4
+ "sha256": "0915d2c5daed0b0c873661c1499828ed390ffd2c1a035bd74320a6b6f1cf8a57",
5
+ "imports": [
6
+ "react",
7
+ "lucide-react",
8
+ "@/components/ui/input",
9
+ "@/components/ui/label",
10
+ "@/components/ui/button",
11
+ "@/lib/utils"
12
+ ],
13
+ "tokens": [
14
+ "--amber-500",
15
+ "--color-border-default",
16
+ "--color-border-strong",
17
+ "--color-status-error-bg",
18
+ "--color-status-error-border",
19
+ "--color-status-error-text",
20
+ "--color-status-info-bg",
21
+ "--color-status-info-border",
22
+ "--color-status-info-text",
23
+ "--color-status-success-bg",
24
+ "--color-status-success-border",
25
+ "--color-status-success-text",
26
+ "--color-status-warning-bg",
27
+ "--color-status-warning-border",
28
+ "--color-status-warning-text",
29
+ "--color-text-muted",
30
+ "--color-text-primary",
31
+ "--color-text-secondary",
32
+ "--gray-950",
33
+ "--green-500",
34
+ "--red-500",
35
+ "--teal-500"
36
+ ],
37
+ "fragments": [
38
+ {
39
+ "id": "Spinner",
40
+ "line": 12,
41
+ "code": "export function Spinner({ className }: { className?: string }) {\n return <Loader2 className={cn(\"animate-spin\", className)} strokeWidth={2} aria-hidden />;\n}"
42
+ },
43
+ {
44
+ "id": "OrDivider",
45
+ "line": 18,
46
+ "code": "export function OrDivider({ text = \"or continue with\" }: { text?: string }) {\n return (\n <div className=\"flex items-center gap-3 my-5\" role=\"separator\">\n <div className=\"flex-1 h-px\" style={{ background: \"var(--color-border-default)\" }} />\n <span className=\"text-[11px] uppercase tracking-wider\" style={{ color: \"var(--color-text-muted)\" }}>{text}</span>\n <div className=\"flex-1 h-px\" style={{ background: \"var(--color-border-default)\" }} />\n </div>\n );\n}"
47
+ },
48
+ {
49
+ "id": "InlineAlert",
50
+ "line": 39,
51
+ "code": "export function InlineAlert({ variant = \"info\", title, children, action }: { variant?: AlertVariant; title?: string; children?: ReactNode; action?: ReactNode }) {\n const cfg = alertConfig[variant];\n const Icon = cfg.icon;\n return (\n <div\n role={variant === \"error\" ? \"alert\" : \"status\"}\n className=\"flex items-start gap-3 rounded-lg px-3.5 py-3 text-sm\"\n style={{ background: cfg.bg, color: cfg.text, border: `1px solid ${cfg.border}` }}\n >\n <Icon className=\"shrink-0 mt-0.5\" size={16} strokeWidth={2} aria-hidden />\n <div className=\"flex-1 min-w-0\">\n {title && <div className=\"font-medium leading-tight mb-0.5\">{title}</div>}\n {children && <div className=\"leading-relaxed\" style={{ opacity: title ? 0.9 : 1 }}>{children}</div>}\n </div>\n {action && <div className=\"shrink-0\">{action}</div>}\n </div>\n );\n}"
52
+ },
53
+ {
54
+ "id": "Field",
55
+ "line": 60,
56
+ "code": "export function Field({ label, htmlFor, error, hint, optional, children }: { label: string; htmlFor: string; error?: string; hint?: string; optional?: boolean; children: ReactNode }) {\n return (\n <div className=\"flex flex-col gap-1.5\">\n <div className=\"flex items-baseline justify-between\">\n <Label htmlFor={htmlFor} className=\"text-[13px]\">{label}</Label>\n {optional && <span className=\"text-[11px]\" style={{ color: \"var(--color-text-muted)\" }}>Optional</span>}\n </div>\n {children}\n {error ? (\n <p className=\"text-[12px] flex items-center gap-1.5\" style={{ color: \"var(--color-status-error-text)\" }}>\n <AlertCircle size={12} strokeWidth={2} aria-hidden /> {error}\n </p>\n ) : hint ? (\n <p className=\"text-[12px]\" style={{ color: \"var(--color-text-muted)\" }}>{hint}</p>\n ) : null}\n </div>\n );\n}"
57
+ },
58
+ {
59
+ "id": "PasswordInput",
60
+ "line": 81,
61
+ "code": "export function PasswordInput({ id, value, onChange, placeholder, autoComplete, ariaInvalid }: { id: string; value: string; onChange: (v: string) => void; placeholder?: string; autoComplete?: string; ariaInvalid?: boolean }) {\n const [show, setShow] = useState(false);\n return (\n <div className=\"relative\">\n <Input\n id={id}\n type={show ? \"text\" : \"password\"}\n value={value}\n onChange={(e) => onChange(e.target.value)}\n placeholder={placeholder}\n autoComplete={autoComplete}\n aria-invalid={ariaInvalid}\n className=\"h-10 pr-10\"\n />\n <button\n type=\"button\"\n onClick={() => setShow(s => !s)}\n aria-label={show ? \"Hide password\" : \"Show password\"}\n className=\"absolute right-2 top-1/2 -translate-y-1/2 w-7 h-7 rounded-md flex items-center justify-center transition-opacity hover:opacity-70\"\n style={{ color: \"var(--color-text-muted)\" }}\n tabIndex={-1}\n >\n {show ? <EyeOff size={15} strokeWidth={1.8} /> : <Eye size={15} strokeWidth={1.8} />}\n </button>\n </div>\n );\n}"
62
+ },
63
+ {
64
+ "id": "passwordStrength",
65
+ "line": 111,
66
+ "code": "export function passwordStrength(pw: string): { score: 0 | 1 | 2 | 3 | 4; label: string; checks: { id: string; ok: boolean; text: string }[] } {\n const checks = [\n { id: \"len\", ok: pw.length >= 8, text: \"At least 8 characters\" },\n { id: \"case\", ok: /[a-z]/.test(pw) && /[A-Z]/.test(pw), text: \"Upper & lowercase letters\" },\n { id: \"num\", ok: /[0-9]/.test(pw), text: \"A number\" },\n { id: \"sym\", ok: /[^A-Za-z0-9]/.test(pw), text: \"A symbol\" },\n ];\n const score = checks.filter(c => c.ok).length as 0 | 1 | 2 | 3 | 4;\n const label = [\"Too weak\", \"Weak\", \"Fair\", \"Strong\", \"Excellent\"][score];\n return { score, label, checks };\n}"
67
+ },
68
+ {
69
+ "id": "PasswordStrength",
70
+ "line": 123,
71
+ "code": "export function PasswordStrength({ password }: { password: string }) {\n const { score, label, checks } = passwordStrength(password);\n const colors = [\"var(--color-border-default)\", \"var(--red-500)\", \"var(--amber-500)\", \"var(--teal-500)\", \"var(--green-500)\"];\n return (\n <div className=\"mt-2 space-y-2\">\n <div className=\"flex items-center gap-1.5\">\n {[0, 1, 2, 3].map((i) => (\n <div key={i} className=\"flex-1 h-1 rounded-full transition-colors\"\n style={{ background: i < score ? colors[score] : \"var(--color-border-default)\" }}\n />\n ))}\n <span className=\"text-[11px] ml-1 min-w-[60px] text-right\" style={{ color: \"var(--color-text-secondary)\" }}>{password ? label : \"\"}</span>\n </div>\n <ul className=\"grid grid-cols-2 gap-x-3 gap-y-1\">\n {checks.map(c => (\n <li key={c.id} className=\"text-[11px] flex items-center gap-1.5\" style={{ color: c.ok ? \"var(--color-text-primary)\" : \"var(--color-text-muted)\" }}>\n <Check size={11} strokeWidth={2.5} style={{ color: c.ok ? \"var(--green-500)\" : \"var(--color-border-default)\" }} />\n {c.text}\n </li>\n ))}\n </ul>\n </div>\n );\n}"
72
+ },
73
+ {
74
+ "id": "Checkbox",
75
+ "line": 150,
76
+ "code": "export function Checkbox({ id, checked, onChange, children }: { id: string; checked: boolean; onChange: (v: boolean) => void; children?: ReactNode }) {\n return (\n <label htmlFor={id} className=\"flex items-start gap-2.5 cursor-pointer select-none\">\n <span className=\"relative inline-flex shrink-0 mt-[1px]\">\n <input\n id={id}\n type=\"checkbox\"\n checked={checked}\n onChange={(e) => onChange(e.target.checked)}\n className=\"peer sr-only\"\n />\n <span\n aria-hidden\n className=\"w-4 h-4 rounded-[5px] border flex items-center justify-center transition-colors peer-focus-visible:ring-3 peer-focus-visible:ring-offset-0\"\n style={{\n background: checked ? \"var(--teal-500)\" : \"transparent\",\n borderColor: checked ? \"var(--teal-500)\" : \"var(--color-border-strong)\",\n }}\n >\n {checked && <Check size={11} strokeWidth={3} style={{ color: \"var(--gray-950)\" }} aria-hidden />}\n </span>\n </span>\n {children && <span className=\"text-[13px] leading-snug\" style={{ color: \"var(--color-text-secondary)\" }}>{children}</span>}\n </label>\n );\n}"
77
+ },
78
+ {
79
+ "id": "SocialButtons",
80
+ "line": 179,
81
+ "code": "export function SocialButtons({ onClick, loading, layout = \"stacked\" }: { onClick?: (provider: \"google\" | \"apple\" | \"github\") => void; loading?: \"google\" | \"apple\" | \"github\" | null; layout?: \"stacked\" | \"grid\" }) {\n const items = [\n { id: \"google\" as const, label: \"Continue with Google\", icon: <GoogleIcon /> },\n { id: \"apple\" as const, label: \"Continue with Apple\", icon: <AppleIcon /> },\n { id: \"github\" as const, label: \"Continue with GitHub\", icon: <GitHubIcon /> },\n ];\n if (layout === \"grid\") {\n return (\n <div className=\"grid grid-cols-3 gap-2\">\n {items.map(it => (\n <Button\n key={it.id}\n type=\"button\"\n variant=\"outline\"\n size=\"lg\"\n disabled={!!loading}\n onClick={() => onClick?.(it.id)}\n aria-label={it.label}\n className=\"rounded-lg\"\n >\n {loading === it.id ? <Spinner className=\"w-4 h-4\" /> : it.icon}\n </Button>\n ))}\n </div>\n );\n }\n return (\n <div className=\"flex flex-col gap-2\">\n {items.map(it => (\n <Button\n key={it.id}\n type=\"button\"\n variant=\"outline\"\n size=\"lg\"\n disabled={!!loading}\n onClick={() => onClick?.(it.id)}\n className=\"rounded-lg\"\n >\n {loading === it.id ? <Spinner className=\"w-4 h-4\" /> : it.icon}\n {it.label}\n </Button>\n ))}\n </div>\n );\n}"
82
+ },
83
+ {
84
+ "id": "GoogleIcon",
85
+ "line": 225,
86
+ "code": "function GoogleIcon() {\n return (\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 48 48\" aria-hidden>\n <path fill=\"#FFC107\" d=\"M43.6 20.5H42V20H24v8h11.3C33.7 32.5 29.3 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c3 0 5.8 1.1 7.9 3l5.7-5.7C34.2 6.1 29.4 4 24 4 12.9 4 4 12.9 4 24s8.9 20 20 20 20-8.9 20-20c0-1.2-.1-2.4-.4-3.5z\"/>\n <path fill=\"#FF3D00\" d=\"M6.3 14.7l6.6 4.8C14.7 16 19 12.5 24 12.5c3 0 5.8 1.1 7.9 3l5.7-5.7C34.2 6.1 29.4 4 24 4 16.3 4 9.7 8.3 6.3 14.7z\"/>\n <path fill=\"#4CAF50\" d=\"M24 44c5.2 0 9.9-2 13.5-5.2l-6.2-5.3C29.2 35.3 26.7 36 24 36c-5.3 0-9.7-3.5-11.3-8.3l-6.5 5C9.6 39.6 16.2 44 24 44z\"/>\n <path fill=\"#1976D2\" d=\"M43.6 20.5H42V20H24v8h11.3c-.8 2.2-2.2 4.1-4 5.5l6.2 5.3C41.4 35.6 44 30.2 44 24c0-1.2-.1-2.4-.4-3.5z\"/>\n </svg>\n );\n}"
87
+ },
88
+ {
89
+ "id": "AppleIcon",
90
+ "line": 235,
91
+ "code": "function AppleIcon() {\n return (\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden>\n <path d=\"M17.05 12.04c-.03-2.7 2.21-4 2.31-4.06-1.26-1.84-3.22-2.09-3.92-2.12-1.67-.17-3.26.98-4.11.98-.86 0-2.16-.96-3.55-.93-1.83.03-3.52 1.06-4.46 2.7-1.9 3.3-.49 8.18 1.36 10.86.9 1.31 1.98 2.79 3.39 2.74 1.36-.05 1.87-.88 3.52-.88s2.11.88 3.55.85c1.47-.02 2.4-1.34 3.3-2.66 1.04-1.52 1.47-3 1.49-3.07-.03-.01-2.86-1.1-2.88-4.35M14.27 4.32c.75-.91 1.26-2.18 1.12-3.43-1.08.04-2.4.72-3.18 1.63-.7.8-1.31 2.09-1.15 3.33 1.21.09 2.45-.61 3.21-1.53\"/>\n </svg>\n );\n}"
92
+ },
93
+ {
94
+ "id": "GitHubIcon",
95
+ "line": 242,
96
+ "code": "function GitHubIcon() {\n return (\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden>\n <path d=\"M12 .5C5.65.5.5 5.65.5 12c0 5.09 3.29 9.4 7.86 10.93.58.1.79-.25.79-.56v-1.96c-3.2.7-3.88-1.54-3.88-1.54-.52-1.32-1.28-1.68-1.28-1.68-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.03 1.76 2.69 1.25 3.35.96.1-.74.4-1.25.73-1.54-2.55-.29-5.24-1.28-5.24-5.69 0-1.26.45-2.29 1.18-3.1-.12-.29-.51-1.46.11-3.04 0 0 .96-.31 3.15 1.18.91-.25 1.89-.38 2.86-.38.97 0 1.95.13 2.86.38 2.19-1.49 3.15-1.18 3.15-1.18.62 1.58.23 2.75.11 3.04.73.81 1.18 1.84 1.18 3.1 0 4.42-2.7 5.39-5.27 5.68.41.36.78 1.06.78 2.14v3.18c0 .31.21.67.8.56C20.21 21.4 23.5 17.09 23.5 12 23.5 5.65 18.35.5 12 .5z\"/>\n </svg>\n );\n}"
97
+ },
98
+ {
99
+ "id": "OtpInput",
100
+ "line": 252,
101
+ "code": "export function OtpInput({ length = 6, value, onChange, disabled, error }: { length?: number; value: string; onChange: (v: string) => void; disabled?: boolean; error?: boolean }) {\n const refs = useRef<(HTMLInputElement | null)[]>([]);\n\n const setDigit = (idx: number, d: string) => {\n const v = value.padEnd(length, \" \").split(\"\");\n v[idx] = d;\n const next = v.join(\"\").slice(0, length).replace(/\\s+$/, \"\");\n onChange(next);\n };\n\n const handleChange = (idx: number) => (e: ChangeEvent<HTMLInputElement>) => {\n const d = e.target.value.replace(/\\D/g, \"\").slice(-1);\n if (!d) {\n setDigit(idx, \" \");\n return;\n }\n setDigit(idx, d);\n if (idx < length - 1) refs.current[idx + 1]?.focus();\n };\n\n const handleKeyDown = (idx: number) => (e: KeyboardEvent<HTMLInputElement>) => {\n if (e.key === \"Backspace\" && !value[idx] && idx > 0) {\n refs.current[idx - 1]?.focus();\n } else if (e.key === \"ArrowLeft\" && idx > 0) {\n refs.current[idx - 1]?.focus();\n } else if (e.key === \"ArrowRight\" && idx < length - 1) {\n refs.current[idx + 1]?.focus();\n }\n };\n\n const handlePaste = (e: ClipboardEvent<HTMLInputElement>) => {\n e.preventDefault();\n const digits = e.clipboardData.getData(\"text\").replace(/\\D/g, \"\").slice(0, length);\n if (digits) {\n onChange(digits);\n refs.current[Math.min(digits.length, length - 1)]?.focus();\n }\n };\n\n return (\n <div className=\"flex items-center gap-2\" onPaste={handlePaste}>\n {Array.from({ length }).map((_, idx) => (\n <Input\n key={idx}\n ref={(el) => { refs.current[idx] = el; }}\n type=\"text\"\n inputMode=\"numeric\"\n maxLength={1}\n value={value[idx] || \"\"}\n onChange={handleChange(idx)}\n onKeyDown={handleKeyDown(idx)}\n onFocus={(e) => (e.target as HTMLInputElement).select()}\n disabled={disabled}\n aria-invalid={!!error}\n aria-label={`Digit ${idx + 1}`}\n className=\"w-11 h-12 text-center text-base font-semibold\"\n />\n ))}\n </div>\n );\n}"
102
+ }
103
+ ],
104
+ "content": "\"use client\";\n\nimport { ReactNode, useState, useRef, KeyboardEvent, ClipboardEvent, ChangeEvent } from \"react\";\nimport { Eye, EyeOff, Check, AlertCircle, Info, CheckCircle2, Loader2 } from \"lucide-react\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\n/* ─── Spinner ────────────────────────────────────────────────────────────── */\n\nexport function Spinner({ className }: { className?: string }) {\n return <Loader2 className={cn(\"animate-spin\", className)} strokeWidth={2} aria-hidden />;\n}\n\n/* ─── Divider with text ──────────────────────────────────────────────────── */\n\nexport function OrDivider({ text = \"or continue with\" }: { text?: string }) {\n return (\n <div className=\"flex items-center gap-3 my-5\" role=\"separator\">\n <div className=\"flex-1 h-px\" style={{ background: \"var(--color-border-default)\" }} />\n <span className=\"text-[11px] uppercase tracking-wider\" style={{ color: \"var(--color-text-muted)\" }}>{text}</span>\n <div className=\"flex-1 h-px\" style={{ background: \"var(--color-border-default)\" }} />\n </div>\n );\n}\n\n/* ─── Inline alert (error/info/success/warning) ─────────────────────────── */\n\ntype AlertVariant = \"error\" | \"info\" | \"success\" | \"warning\";\n\nconst alertConfig: Record<AlertVariant, { icon: typeof Info; bg: string; text: string; border: string }> = {\n error: { icon: AlertCircle, bg: \"var(--color-status-error-bg)\", text: \"var(--color-status-error-text)\", border: \"var(--color-status-error-border)\" },\n info: { icon: Info, bg: \"var(--color-status-info-bg)\", text: \"var(--color-status-info-text)\", border: \"var(--color-status-info-border)\" },\n success: { icon: CheckCircle2, bg: \"var(--color-status-success-bg)\", text: \"var(--color-status-success-text)\", border: \"var(--color-status-success-border)\" },\n warning: { icon: AlertCircle, bg: \"var(--color-status-warning-bg)\", text: \"var(--color-status-warning-text)\", border: \"var(--color-status-warning-border)\" },\n};\n\nexport function InlineAlert({ variant = \"info\", title, children, action }: { variant?: AlertVariant; title?: string; children?: ReactNode; action?: ReactNode }) {\n const cfg = alertConfig[variant];\n const Icon = cfg.icon;\n return (\n <div\n role={variant === \"error\" ? \"alert\" : \"status\"}\n className=\"flex items-start gap-3 rounded-lg px-3.5 py-3 text-sm\"\n style={{ background: cfg.bg, color: cfg.text, border: `1px solid ${cfg.border}` }}\n >\n <Icon className=\"shrink-0 mt-0.5\" size={16} strokeWidth={2} aria-hidden />\n <div className=\"flex-1 min-w-0\">\n {title && <div className=\"font-medium leading-tight mb-0.5\">{title}</div>}\n {children && <div className=\"leading-relaxed\" style={{ opacity: title ? 0.9 : 1 }}>{children}</div>}\n </div>\n {action && <div className=\"shrink-0\">{action}</div>}\n </div>\n );\n}\n\n/* ─── Field wrapper (label + input + error) ─────────────────────────────── */\n\nexport function Field({ label, htmlFor, error, hint, optional, children }: { label: string; htmlFor: string; error?: string; hint?: string; optional?: boolean; children: ReactNode }) {\n return (\n <div className=\"flex flex-col gap-1.5\">\n <div className=\"flex items-baseline justify-between\">\n <Label htmlFor={htmlFor} className=\"text-[13px]\">{label}</Label>\n {optional && <span className=\"text-[11px]\" style={{ color: \"var(--color-text-muted)\" }}>Optional</span>}\n </div>\n {children}\n {error ? (\n <p className=\"text-[12px] flex items-center gap-1.5\" style={{ color: \"var(--color-status-error-text)\" }}>\n <AlertCircle size={12} strokeWidth={2} aria-hidden /> {error}\n </p>\n ) : hint ? (\n <p className=\"text-[12px]\" style={{ color: \"var(--color-text-muted)\" }}>{hint}</p>\n ) : null}\n </div>\n );\n}\n\n/* ─── Password field with show/hide ─────────────────────────────────────── */\n\nexport function PasswordInput({ id, value, onChange, placeholder, autoComplete, ariaInvalid }: { id: string; value: string; onChange: (v: string) => void; placeholder?: string; autoComplete?: string; ariaInvalid?: boolean }) {\n const [show, setShow] = useState(false);\n return (\n <div className=\"relative\">\n <Input\n id={id}\n type={show ? \"text\" : \"password\"}\n value={value}\n onChange={(e) => onChange(e.target.value)}\n placeholder={placeholder}\n autoComplete={autoComplete}\n aria-invalid={ariaInvalid}\n className=\"h-10 pr-10\"\n />\n <button\n type=\"button\"\n onClick={() => setShow(s => !s)}\n aria-label={show ? \"Hide password\" : \"Show password\"}\n className=\"absolute right-2 top-1/2 -translate-y-1/2 w-7 h-7 rounded-md flex items-center justify-center transition-opacity hover:opacity-70\"\n style={{ color: \"var(--color-text-muted)\" }}\n tabIndex={-1}\n >\n {show ? <EyeOff size={15} strokeWidth={1.8} /> : <Eye size={15} strokeWidth={1.8} />}\n </button>\n </div>\n );\n}\n\n/* ─── Password strength meter ────────────────────────────────────────────── */\n\nexport function passwordStrength(pw: string): { score: 0 | 1 | 2 | 3 | 4; label: string; checks: { id: string; ok: boolean; text: string }[] } {\n const checks = [\n { id: \"len\", ok: pw.length >= 8, text: \"At least 8 characters\" },\n { id: \"case\", ok: /[a-z]/.test(pw) && /[A-Z]/.test(pw), text: \"Upper & lowercase letters\" },\n { id: \"num\", ok: /[0-9]/.test(pw), text: \"A number\" },\n { id: \"sym\", ok: /[^A-Za-z0-9]/.test(pw), text: \"A symbol\" },\n ];\n const score = checks.filter(c => c.ok).length as 0 | 1 | 2 | 3 | 4;\n const label = [\"Too weak\", \"Weak\", \"Fair\", \"Strong\", \"Excellent\"][score];\n return { score, label, checks };\n}\n\nexport function PasswordStrength({ password }: { password: string }) {\n const { score, label, checks } = passwordStrength(password);\n const colors = [\"var(--color-border-default)\", \"var(--red-500)\", \"var(--amber-500)\", \"var(--teal-500)\", \"var(--green-500)\"];\n return (\n <div className=\"mt-2 space-y-2\">\n <div className=\"flex items-center gap-1.5\">\n {[0, 1, 2, 3].map((i) => (\n <div key={i} className=\"flex-1 h-1 rounded-full transition-colors\"\n style={{ background: i < score ? colors[score] : \"var(--color-border-default)\" }}\n />\n ))}\n <span className=\"text-[11px] ml-1 min-w-[60px] text-right\" style={{ color: \"var(--color-text-secondary)\" }}>{password ? label : \"\"}</span>\n </div>\n <ul className=\"grid grid-cols-2 gap-x-3 gap-y-1\">\n {checks.map(c => (\n <li key={c.id} className=\"text-[11px] flex items-center gap-1.5\" style={{ color: c.ok ? \"var(--color-text-primary)\" : \"var(--color-text-muted)\" }}>\n <Check size={11} strokeWidth={2.5} style={{ color: c.ok ? \"var(--green-500)\" : \"var(--color-border-default)\" }} />\n {c.text}\n </li>\n ))}\n </ul>\n </div>\n );\n}\n\n/* ─── Checkbox (inline, design-token styled) ─────────────────────────────── */\n\nexport function Checkbox({ id, checked, onChange, children }: { id: string; checked: boolean; onChange: (v: boolean) => void; children?: ReactNode }) {\n return (\n <label htmlFor={id} className=\"flex items-start gap-2.5 cursor-pointer select-none\">\n <span className=\"relative inline-flex shrink-0 mt-[1px]\">\n <input\n id={id}\n type=\"checkbox\"\n checked={checked}\n onChange={(e) => onChange(e.target.checked)}\n className=\"peer sr-only\"\n />\n <span\n aria-hidden\n className=\"w-4 h-4 rounded-[5px] border flex items-center justify-center transition-colors peer-focus-visible:ring-3 peer-focus-visible:ring-offset-0\"\n style={{\n background: checked ? \"var(--teal-500)\" : \"transparent\",\n borderColor: checked ? \"var(--teal-500)\" : \"var(--color-border-strong)\",\n }}\n >\n {checked && <Check size={11} strokeWidth={3} style={{ color: \"var(--gray-950)\" }} aria-hidden />}\n </span>\n </span>\n {children && <span className=\"text-[13px] leading-snug\" style={{ color: \"var(--color-text-secondary)\" }}>{children}</span>}\n </label>\n );\n}\n\n/* ─── Social SSO buttons ─────────────────────────────────────────────────── */\n\nexport function SocialButtons({ onClick, loading, layout = \"stacked\" }: { onClick?: (provider: \"google\" | \"apple\" | \"github\") => void; loading?: \"google\" | \"apple\" | \"github\" | null; layout?: \"stacked\" | \"grid\" }) {\n const items = [\n { id: \"google\" as const, label: \"Continue with Google\", icon: <GoogleIcon /> },\n { id: \"apple\" as const, label: \"Continue with Apple\", icon: <AppleIcon /> },\n { id: \"github\" as const, label: \"Continue with GitHub\", icon: <GitHubIcon /> },\n ];\n if (layout === \"grid\") {\n return (\n <div className=\"grid grid-cols-3 gap-2\">\n {items.map(it => (\n <Button\n key={it.id}\n type=\"button\"\n variant=\"outline\"\n size=\"lg\"\n disabled={!!loading}\n onClick={() => onClick?.(it.id)}\n aria-label={it.label}\n className=\"rounded-lg\"\n >\n {loading === it.id ? <Spinner className=\"w-4 h-4\" /> : it.icon}\n </Button>\n ))}\n </div>\n );\n }\n return (\n <div className=\"flex flex-col gap-2\">\n {items.map(it => (\n <Button\n key={it.id}\n type=\"button\"\n variant=\"outline\"\n size=\"lg\"\n disabled={!!loading}\n onClick={() => onClick?.(it.id)}\n className=\"rounded-lg\"\n >\n {loading === it.id ? <Spinner className=\"w-4 h-4\" /> : it.icon}\n {it.label}\n </Button>\n ))}\n </div>\n );\n}\n\nfunction GoogleIcon() {\n return (\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 48 48\" aria-hidden>\n <path fill=\"#FFC107\" d=\"M43.6 20.5H42V20H24v8h11.3C33.7 32.5 29.3 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c3 0 5.8 1.1 7.9 3l5.7-5.7C34.2 6.1 29.4 4 24 4 12.9 4 4 12.9 4 24s8.9 20 20 20 20-8.9 20-20c0-1.2-.1-2.4-.4-3.5z\"/>\n <path fill=\"#FF3D00\" d=\"M6.3 14.7l6.6 4.8C14.7 16 19 12.5 24 12.5c3 0 5.8 1.1 7.9 3l5.7-5.7C34.2 6.1 29.4 4 24 4 16.3 4 9.7 8.3 6.3 14.7z\"/>\n <path fill=\"#4CAF50\" d=\"M24 44c5.2 0 9.9-2 13.5-5.2l-6.2-5.3C29.2 35.3 26.7 36 24 36c-5.3 0-9.7-3.5-11.3-8.3l-6.5 5C9.6 39.6 16.2 44 24 44z\"/>\n <path fill=\"#1976D2\" d=\"M43.6 20.5H42V20H24v8h11.3c-.8 2.2-2.2 4.1-4 5.5l6.2 5.3C41.4 35.6 44 30.2 44 24c0-1.2-.1-2.4-.4-3.5z\"/>\n </svg>\n );\n}\nfunction AppleIcon() {\n return (\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden>\n <path d=\"M17.05 12.04c-.03-2.7 2.21-4 2.31-4.06-1.26-1.84-3.22-2.09-3.92-2.12-1.67-.17-3.26.98-4.11.98-.86 0-2.16-.96-3.55-.93-1.83.03-3.52 1.06-4.46 2.7-1.9 3.3-.49 8.18 1.36 10.86.9 1.31 1.98 2.79 3.39 2.74 1.36-.05 1.87-.88 3.52-.88s2.11.88 3.55.85c1.47-.02 2.4-1.34 3.3-2.66 1.04-1.52 1.47-3 1.49-3.07-.03-.01-2.86-1.1-2.88-4.35M14.27 4.32c.75-.91 1.26-2.18 1.12-3.43-1.08.04-2.4.72-3.18 1.63-.7.8-1.31 2.09-1.15 3.33 1.21.09 2.45-.61 3.21-1.53\"/>\n </svg>\n );\n}\nfunction GitHubIcon() {\n return (\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden>\n <path d=\"M12 .5C5.65.5.5 5.65.5 12c0 5.09 3.29 9.4 7.86 10.93.58.1.79-.25.79-.56v-1.96c-3.2.7-3.88-1.54-3.88-1.54-.52-1.32-1.28-1.68-1.28-1.68-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.03 1.76 2.69 1.25 3.35.96.1-.74.4-1.25.73-1.54-2.55-.29-5.24-1.28-5.24-5.69 0-1.26.45-2.29 1.18-3.1-.12-.29-.51-1.46.11-3.04 0 0 .96-.31 3.15 1.18.91-.25 1.89-.38 2.86-.38.97 0 1.95.13 2.86.38 2.19-1.49 3.15-1.18 3.15-1.18.62 1.58.23 2.75.11 3.04.73.81 1.18 1.84 1.18 3.1 0 4.42-2.7 5.39-5.27 5.68.41.36.78 1.06.78 2.14v3.18c0 .31.21.67.8.56C20.21 21.4 23.5 17.09 23.5 12 23.5 5.65 18.35.5 12 .5z\"/>\n </svg>\n );\n}\n\n/* ─── OTP input (6-digit) ────────────────────────────────────────────────── */\n\nexport function OtpInput({ length = 6, value, onChange, disabled, error }: { length?: number; value: string; onChange: (v: string) => void; disabled?: boolean; error?: boolean }) {\n const refs = useRef<(HTMLInputElement | null)[]>([]);\n\n const setDigit = (idx: number, d: string) => {\n const v = value.padEnd(length, \" \").split(\"\");\n v[idx] = d;\n const next = v.join(\"\").slice(0, length).replace(/\\s+$/, \"\");\n onChange(next);\n };\n\n const handleChange = (idx: number) => (e: ChangeEvent<HTMLInputElement>) => {\n const d = e.target.value.replace(/\\D/g, \"\").slice(-1);\n if (!d) {\n setDigit(idx, \" \");\n return;\n }\n setDigit(idx, d);\n if (idx < length - 1) refs.current[idx + 1]?.focus();\n };\n\n const handleKeyDown = (idx: number) => (e: KeyboardEvent<HTMLInputElement>) => {\n if (e.key === \"Backspace\" && !value[idx] && idx > 0) {\n refs.current[idx - 1]?.focus();\n } else if (e.key === \"ArrowLeft\" && idx > 0) {\n refs.current[idx - 1]?.focus();\n } else if (e.key === \"ArrowRight\" && idx < length - 1) {\n refs.current[idx + 1]?.focus();\n }\n };\n\n const handlePaste = (e: ClipboardEvent<HTMLInputElement>) => {\n e.preventDefault();\n const digits = e.clipboardData.getData(\"text\").replace(/\\D/g, \"\").slice(0, length);\n if (digits) {\n onChange(digits);\n refs.current[Math.min(digits.length, length - 1)]?.focus();\n }\n };\n\n return (\n <div className=\"flex items-center gap-2\" onPaste={handlePaste}>\n {Array.from({ length }).map((_, idx) => (\n <Input\n key={idx}\n ref={(el) => { refs.current[idx] = el; }}\n type=\"text\"\n inputMode=\"numeric\"\n maxLength={1}\n value={value[idx] || \"\"}\n onChange={handleChange(idx)}\n onKeyDown={handleKeyDown(idx)}\n onFocus={(e) => (e.target as HTMLInputElement).select()}\n disabled={disabled}\n aria-invalid={!!error}\n aria-label={`Digit ${idx + 1}`}\n className=\"w-11 h-12 text-center text-base font-semibold\"\n />\n ))}\n </div>\n );\n}\n"
105
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
3
+ "file": "components/auth/auth-shell.tsx",
4
+ "sha256": "a1d23d39ecae97f45ccc44cdd759e4ff8bff99ff702435045c270fb75529d768",
5
+ "imports": [
6
+ "react",
7
+ "@/components/marketing/marketing-nav",
8
+ "@/components/marketing/marketing-footer"
9
+ ],
10
+ "tokens": [
11
+ "--color-bg-default",
12
+ "--color-border-muted",
13
+ "--color-text-brand",
14
+ "--color-text-primary",
15
+ "--color-text-secondary"
16
+ ],
17
+ "fragments": [
18
+ {
19
+ "id": "AuthShell",
20
+ "line": 19,
21
+ "code": "export function AuthShell({\n children,\n kicker,\n title,\n subtitle,\n footer,\n width = \"narrow\",\n}: AuthShellProps) {\n return (\n <div className=\"min-h-screen flex flex-col\" style={{ background: \"var(--color-bg-default)\", color: \"var(--color-text-primary)\" }}>\n <MarketingNav sticky animate={false} />\n\n <main className=\"flex-1 flex items-start justify-center px-6 py-14 md:py-20\">\n <div className={`w-full ${width === \"wide\" ? \"max-w-[560px]\" : \"max-w-[420px]\"}`}>\n {kicker && (\n <p className=\"text-[10px] font-semibold uppercase tracking-[0.12em] mb-3\"\n style={{ color: \"var(--color-text-brand)\" }}>\n {kicker}\n </p>\n )}\n {title && (\n <h1 className=\"text-[1.75rem] font-semibold mb-2\"\n style={{ letterSpacing: \"-0.025em\", lineHeight: 1.15 }}>\n {title}\n </h1>\n )}\n {subtitle && (\n <div className=\"text-sm mb-8\" style={{ color: \"var(--color-text-secondary)\", lineHeight: 1.55 }}>\n {subtitle}\n </div>\n )}\n {children}\n {footer && (\n <div className=\"mt-8 pt-6 border-t text-sm text-center\"\n style={{ borderColor: \"var(--color-border-muted)\", color: \"var(--color-text-secondary)\" }}>\n {footer}\n </div>\n )}\n </div>\n </main>\n\n <MarketingFooterCompact />\n </div>\n );\n}"
22
+ }
23
+ ],
24
+ "content": "\"use client\";\n\nimport { ReactNode } from \"react\";\nimport { MarketingNav } from \"@/components/marketing/marketing-nav\";\nimport { MarketingFooterCompact } from \"@/components/marketing/marketing-footer\";\n\ninterface AuthShellProps {\n children: ReactNode;\n /** Optional kicker shown above heading (e.g. \"Step 2 of 3\") */\n kicker?: string;\n title?: ReactNode;\n subtitle?: ReactNode;\n /** Slot below the form (e.g. \"Don't have an account? Sign up\") */\n footer?: ReactNode;\n /** Width of the form column — \"narrow\" (default, 420px) or \"wide\" (560px) */\n width?: \"narrow\" | \"wide\";\n}\n\nexport function AuthShell({\n children,\n kicker,\n title,\n subtitle,\n footer,\n width = \"narrow\",\n}: AuthShellProps) {\n return (\n <div className=\"min-h-screen flex flex-col\" style={{ background: \"var(--color-bg-default)\", color: \"var(--color-text-primary)\" }}>\n <MarketingNav sticky animate={false} />\n\n <main className=\"flex-1 flex items-start justify-center px-6 py-14 md:py-20\">\n <div className={`w-full ${width === \"wide\" ? \"max-w-[560px]\" : \"max-w-[420px]\"}`}>\n {kicker && (\n <p className=\"text-[10px] font-semibold uppercase tracking-[0.12em] mb-3\"\n style={{ color: \"var(--color-text-brand)\" }}>\n {kicker}\n </p>\n )}\n {title && (\n <h1 className=\"text-[1.75rem] font-semibold mb-2\"\n style={{ letterSpacing: \"-0.025em\", lineHeight: 1.15 }}>\n {title}\n </h1>\n )}\n {subtitle && (\n <div className=\"text-sm mb-8\" style={{ color: \"var(--color-text-secondary)\", lineHeight: 1.55 }}>\n {subtitle}\n </div>\n )}\n {children}\n {footer && (\n <div className=\"mt-8 pt-6 border-t text-sm text-center\"\n style={{ borderColor: \"var(--color-border-muted)\", color: \"var(--color-text-secondary)\" }}>\n {footer}\n </div>\n )}\n </div>\n </main>\n\n <MarketingFooterCompact />\n </div>\n );\n}\n"
25
+ }
@@ -0,0 +1,52 @@
1
+ {
2
+ "revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
3
+ "file": "components/auth/onboarding-primitives.tsx",
4
+ "sha256": "06e6eb76142ecd9fa8a2ed0c6d8884cff877963e0803510c134ad76b421504d9",
5
+ "imports": [
6
+ "react",
7
+ "lucide-react",
8
+ "@/lib/utils"
9
+ ],
10
+ "tokens": [
11
+ "--color-bg-brand-muted",
12
+ "--color-bg-subtle",
13
+ "--color-border-default",
14
+ "--color-status-success-bg",
15
+ "--color-status-success-border",
16
+ "--color-status-success-text",
17
+ "--color-text-brand",
18
+ "--color-text-muted",
19
+ "--color-text-secondary",
20
+ "--gray-950",
21
+ "--green-500",
22
+ "--teal-500"
23
+ ],
24
+ "fragments": [
25
+ {
26
+ "id": "Select",
27
+ "line": 9,
28
+ "code": "export function Select({ id, value, onChange, options, placeholder, ariaInvalid }: {\n id: string;\n value: string;\n onChange: (v: string) => void;\n options: { value: string; label: string }[];\n placeholder?: string;\n ariaInvalid?: boolean;\n}) {\n return (\n <div className=\"relative\">\n <select\n id={id}\n value={value}\n onChange={(e) => onChange(e.target.value)}\n aria-invalid={ariaInvalid}\n className={cn(\n \"h-10 w-full rounded-lg border border-input bg-transparent px-3 pr-9 text-sm outline-none transition-colors\",\n \"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50\",\n \"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50\",\n \"aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20\",\n \"appearance-none cursor-pointer\",\n \"dark:bg-input/30\"\n )}\n >\n {placeholder && <option value=\"\" disabled>{placeholder}</option>}\n {options.map((o) => (\n <option key={o.value} value={o.value}>{o.label}</option>\n ))}\n </select>\n <ChevronDown\n size={14}\n strokeWidth={1.8}\n className=\"absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none\"\n style={{ color: \"var(--color-text-muted)\" }}\n />\n </div>\n );\n}"
29
+ },
30
+ {
31
+ "id": "MultiSelect",
32
+ "line": 50,
33
+ "code": "export function MultiSelect({ value, onChange, options, max }: {\n value: string[];\n onChange: (v: string[]) => void;\n options: string[];\n max?: number;\n}) {\n const toggle = (opt: string) => {\n if (value.includes(opt)) onChange(value.filter((v) => v !== opt));\n else if (!max || value.length < max) onChange([...value, opt]);\n };\n\n return (\n <div className=\"flex flex-wrap gap-2\">\n {options.map((opt) => {\n const active = value.includes(opt);\n return (\n <button\n key={opt}\n type=\"button\"\n onClick={() => toggle(opt)}\n aria-pressed={active}\n className=\"inline-flex items-center gap-1.5 h-8 px-3 rounded-full text-[12px] font-medium transition-all border focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-[var(--teal-500)]/30\"\n style={{\n borderColor: active ? \"var(--teal-500)\" : \"var(--color-border-default)\",\n background: active ? \"var(--color-bg-brand-muted)\" : \"transparent\",\n color: active ? \"var(--color-text-brand)\" : \"var(--color-text-secondary)\",\n }}\n >\n {active && <Check size={11} strokeWidth={3} />}\n {opt}\n </button>\n );\n })}\n </div>\n );\n}"
34
+ },
35
+ {
36
+ "id": "FileDropZone",
37
+ "line": 89,
38
+ "code": "export function FileDropZone({ label, accept, file, onFile, hint }: {\n label: string;\n accept?: string;\n file: { name: string; size: number } | null;\n onFile: (f: { name: string; size: number } | null) => void;\n hint?: string;\n}) {\n const [dragOver, setDragOver] = useState(false);\n const inputRef = useRef<HTMLInputElement>(null);\n\n const handleFiles = (files: FileList | null) => {\n const f = files?.[0];\n if (f) onFile({ name: f.name, size: f.size });\n };\n\n const onDrop = (e: DragEvent) => {\n e.preventDefault();\n setDragOver(false);\n handleFiles(e.dataTransfer.files);\n };\n\n if (file) {\n return (\n <div\n className=\"rounded-xl border p-3.5 flex items-center gap-3\"\n style={{ borderColor: \"var(--color-status-success-border)\", background: \"var(--color-status-success-bg)\" }}\n >\n <div\n className=\"w-9 h-9 rounded-lg flex items-center justify-center shrink-0\"\n style={{ background: \"color-mix(in srgb, var(--green-500) 18%, transparent)\", color: \"var(--color-status-success-text)\" }}\n >\n <FileText size={16} strokeWidth={1.8} />\n </div>\n <div className=\"flex-1 min-w-0\">\n <div className=\"text-[13px] font-medium truncate\" style={{ color: \"var(--color-status-success-text)\" }}>\n {file.name}\n </div>\n <div className=\"text-[11px]\" style={{ color: \"var(--color-status-success-text)\", opacity: 0.75 }}>\n {formatBytes(file.size)} · Uploaded\n </div>\n </div>\n <button\n type=\"button\"\n onClick={() => onFile(null)}\n aria-label=\"Remove file\"\n className=\"w-7 h-7 rounded-md flex items-center justify-center transition-opacity hover:opacity-70\"\n style={{ color: \"var(--color-status-success-text)\" }}\n >\n <X size={14} />\n </button>\n </div>\n );\n }\n\n return (\n <button\n type=\"button\"\n onClick={() => inputRef.current?.click()}\n onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}\n onDragLeave={() => setDragOver(false)}\n onDrop={onDrop}\n className=\"w-full rounded-xl border-2 border-dashed p-6 text-center transition-all hover:bg-[var(--color-bg-subtle)] focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-[var(--teal-500)]/30\"\n style={{\n borderColor: dragOver ? \"var(--teal-500)\" : \"var(--color-border-default)\",\n background: dragOver ? \"var(--color-bg-brand-muted)\" : \"transparent\",\n }}\n >\n <Upload size={22} strokeWidth={1.5} className=\"mx-auto mb-2\" style={{ color: \"var(--color-text-secondary)\" }} />\n <div className=\"text-[13px] font-medium mb-1\">{label}</div>\n {hint && <div className=\"text-[11px]\" style={{ color: \"var(--color-text-muted)\" }}>{hint}</div>}\n <input\n ref={inputRef}\n type=\"file\"\n accept={accept}\n className=\"sr-only\"\n onChange={(e) => handleFiles(e.target.files)}\n />\n </button>\n );\n}"
39
+ },
40
+ {
41
+ "id": "formatBytes",
42
+ "line": 170,
43
+ "code": "function formatBytes(n: number) {\n if (n < 1024) return `${n} B`;\n if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;\n return `${(n / 1024 / 1024).toFixed(1)} MB`;\n}"
44
+ },
45
+ {
46
+ "id": "StepProgress",
47
+ "line": 178,
48
+ "code": "export function StepProgress({ current, total, labels }: { current: number; total: number; labels?: string[] }) {\n return (\n <div\n className=\"flex items-center gap-2\"\n role=\"progressbar\"\n aria-label={`Step ${current} of ${total}`}\n aria-valuemin={1}\n aria-valuemax={total}\n aria-valuenow={current}\n >\n {Array.from({ length: total }).map((_, i) => {\n const idx = i + 1;\n const done = idx < current;\n const active = idx === current;\n return (\n <div key={i} className=\"flex items-center gap-2 flex-1\">\n <div\n className=\"w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-semibold shrink-0 transition-colors\"\n style={{\n background: done ? \"var(--teal-500)\" : active ? \"var(--teal-500)\" : \"var(--color-bg-subtle)\",\n color: done || active ? \"var(--gray-950)\" : \"var(--color-text-muted)\",\n border: active ? \"0\" : `1px solid ${done ? \"var(--teal-500)\" : \"var(--color-border-default)\"}`,\n }}\n >\n {done ? <Check size={11} strokeWidth={3} /> : idx}\n </div>\n {i < total - 1 && (\n <div\n className=\"flex-1 h-px transition-colors\"\n style={{ background: done ? \"var(--teal-500)\" : \"var(--color-border-default)\" }}\n />\n )}\n </div>\n );\n })}\n </div>\n );\n}"
49
+ }
50
+ ],
51
+ "content": "\"use client\";\n\nimport { ReactNode, useState, useRef, ChangeEvent, DragEvent } from \"react\";\nimport { Check, ChevronDown, Upload, X, FileText } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\n/* ─── Select (native, design-token styled) ───────────────────────────────── */\n\nexport function Select({ id, value, onChange, options, placeholder, ariaInvalid }: {\n id: string;\n value: string;\n onChange: (v: string) => void;\n options: { value: string; label: string }[];\n placeholder?: string;\n ariaInvalid?: boolean;\n}) {\n return (\n <div className=\"relative\">\n <select\n id={id}\n value={value}\n onChange={(e) => onChange(e.target.value)}\n aria-invalid={ariaInvalid}\n className={cn(\n \"h-10 w-full rounded-lg border border-input bg-transparent px-3 pr-9 text-sm outline-none transition-colors\",\n \"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50\",\n \"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50\",\n \"aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20\",\n \"appearance-none cursor-pointer\",\n \"dark:bg-input/30\"\n )}\n >\n {placeholder && <option value=\"\" disabled>{placeholder}</option>}\n {options.map((o) => (\n <option key={o.value} value={o.value}>{o.label}</option>\n ))}\n </select>\n <ChevronDown\n size={14}\n strokeWidth={1.8}\n className=\"absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none\"\n style={{ color: \"var(--color-text-muted)\" }}\n />\n </div>\n );\n}\n\n/* ─── MultiSelect (chip-based) ───────────────────────────────────────────── */\n\nexport function MultiSelect({ value, onChange, options, max }: {\n value: string[];\n onChange: (v: string[]) => void;\n options: string[];\n max?: number;\n}) {\n const toggle = (opt: string) => {\n if (value.includes(opt)) onChange(value.filter((v) => v !== opt));\n else if (!max || value.length < max) onChange([...value, opt]);\n };\n\n return (\n <div className=\"flex flex-wrap gap-2\">\n {options.map((opt) => {\n const active = value.includes(opt);\n return (\n <button\n key={opt}\n type=\"button\"\n onClick={() => toggle(opt)}\n aria-pressed={active}\n className=\"inline-flex items-center gap-1.5 h-8 px-3 rounded-full text-[12px] font-medium transition-all border focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-[var(--teal-500)]/30\"\n style={{\n borderColor: active ? \"var(--teal-500)\" : \"var(--color-border-default)\",\n background: active ? \"var(--color-bg-brand-muted)\" : \"transparent\",\n color: active ? \"var(--color-text-brand)\" : \"var(--color-text-secondary)\",\n }}\n >\n {active && <Check size={11} strokeWidth={3} />}\n {opt}\n </button>\n );\n })}\n </div>\n );\n}\n\n/* ─── File Drop Zone ─────────────────────────────────────────────────────── */\n\nexport function FileDropZone({ label, accept, file, onFile, hint }: {\n label: string;\n accept?: string;\n file: { name: string; size: number } | null;\n onFile: (f: { name: string; size: number } | null) => void;\n hint?: string;\n}) {\n const [dragOver, setDragOver] = useState(false);\n const inputRef = useRef<HTMLInputElement>(null);\n\n const handleFiles = (files: FileList | null) => {\n const f = files?.[0];\n if (f) onFile({ name: f.name, size: f.size });\n };\n\n const onDrop = (e: DragEvent) => {\n e.preventDefault();\n setDragOver(false);\n handleFiles(e.dataTransfer.files);\n };\n\n if (file) {\n return (\n <div\n className=\"rounded-xl border p-3.5 flex items-center gap-3\"\n style={{ borderColor: \"var(--color-status-success-border)\", background: \"var(--color-status-success-bg)\" }}\n >\n <div\n className=\"w-9 h-9 rounded-lg flex items-center justify-center shrink-0\"\n style={{ background: \"color-mix(in srgb, var(--green-500) 18%, transparent)\", color: \"var(--color-status-success-text)\" }}\n >\n <FileText size={16} strokeWidth={1.8} />\n </div>\n <div className=\"flex-1 min-w-0\">\n <div className=\"text-[13px] font-medium truncate\" style={{ color: \"var(--color-status-success-text)\" }}>\n {file.name}\n </div>\n <div className=\"text-[11px]\" style={{ color: \"var(--color-status-success-text)\", opacity: 0.75 }}>\n {formatBytes(file.size)} · Uploaded\n </div>\n </div>\n <button\n type=\"button\"\n onClick={() => onFile(null)}\n aria-label=\"Remove file\"\n className=\"w-7 h-7 rounded-md flex items-center justify-center transition-opacity hover:opacity-70\"\n style={{ color: \"var(--color-status-success-text)\" }}\n >\n <X size={14} />\n </button>\n </div>\n );\n }\n\n return (\n <button\n type=\"button\"\n onClick={() => inputRef.current?.click()}\n onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}\n onDragLeave={() => setDragOver(false)}\n onDrop={onDrop}\n className=\"w-full rounded-xl border-2 border-dashed p-6 text-center transition-all hover:bg-[var(--color-bg-subtle)] focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-[var(--teal-500)]/30\"\n style={{\n borderColor: dragOver ? \"var(--teal-500)\" : \"var(--color-border-default)\",\n background: dragOver ? \"var(--color-bg-brand-muted)\" : \"transparent\",\n }}\n >\n <Upload size={22} strokeWidth={1.5} className=\"mx-auto mb-2\" style={{ color: \"var(--color-text-secondary)\" }} />\n <div className=\"text-[13px] font-medium mb-1\">{label}</div>\n {hint && <div className=\"text-[11px]\" style={{ color: \"var(--color-text-muted)\" }}>{hint}</div>}\n <input\n ref={inputRef}\n type=\"file\"\n accept={accept}\n className=\"sr-only\"\n onChange={(e) => handleFiles(e.target.files)}\n />\n </button>\n );\n}\n\nfunction formatBytes(n: number) {\n if (n < 1024) return `${n} B`;\n if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;\n return `${(n / 1024 / 1024).toFixed(1)} MB`;\n}\n\n/* ─── Progress dots (4 steps) ────────────────────────────────────────────── */\n\nexport function StepProgress({ current, total, labels }: { current: number; total: number; labels?: string[] }) {\n return (\n <div\n className=\"flex items-center gap-2\"\n role=\"progressbar\"\n aria-label={`Step ${current} of ${total}`}\n aria-valuemin={1}\n aria-valuemax={total}\n aria-valuenow={current}\n >\n {Array.from({ length: total }).map((_, i) => {\n const idx = i + 1;\n const done = idx < current;\n const active = idx === current;\n return (\n <div key={i} className=\"flex items-center gap-2 flex-1\">\n <div\n className=\"w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-semibold shrink-0 transition-colors\"\n style={{\n background: done ? \"var(--teal-500)\" : active ? \"var(--teal-500)\" : \"var(--color-bg-subtle)\",\n color: done || active ? \"var(--gray-950)\" : \"var(--color-text-muted)\",\n border: active ? \"0\" : `1px solid ${done ? \"var(--teal-500)\" : \"var(--color-border-default)\"}`,\n }}\n >\n {done ? <Check size={11} strokeWidth={3} /> : idx}\n </div>\n {i < total - 1 && (\n <div\n className=\"flex-1 h-px transition-colors\"\n style={{ background: done ? \"var(--teal-500)\" : \"var(--color-border-default)\" }}\n />\n )}\n </div>\n );\n })}\n </div>\n );\n}\n"
52
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
3
+ "file": "components/auth/onboarding-shell.tsx",
4
+ "sha256": "f664051f11313308966d95caf13af6edf179d788337db7e8e49488a6ffd1abfd",
5
+ "imports": [
6
+ "react",
7
+ "next/link",
8
+ "lucide-react",
9
+ "@/components/ui/button",
10
+ "@/components/auth/auth-shell",
11
+ "@/components/auth/auth-primitives",
12
+ "@/components/auth/onboarding-primitives"
13
+ ],
14
+ "tokens": [
15
+ "--color-border-muted",
16
+ "--color-text-brand",
17
+ "--color-text-muted",
18
+ "--color-text-primary"
19
+ ],
20
+ "fragments": [
21
+ {
22
+ "id": "OnboardingShell",
23
+ "line": 41,
24
+ "code": "export function OnboardingShell({\n role, stepKey, title, subtitle, children,\n onSave, saving = false, canContinue = true,\n previousHref, saveLabel = \"Save & continue\",\n}: OnboardingShellProps) {\n const stepIdx = STEPS.findIndex((s) => s.key === stepKey);\n const currentStep = stepIdx + 1;\n\n return (\n <AuthShell\n width=\"wide\"\n kicker={`${ROLE_LABELS[role]} onboarding · Step ${currentStep} of ${STEPS.length}`}\n title={title}\n subtitle={subtitle}\n >\n {/* Progress dots */}\n <div className=\"mb-7\">\n <StepProgress current={currentStep} total={STEPS.length} />\n <div className=\"grid grid-cols-4 gap-2 mt-2\">\n {STEPS.map((s, i) => (\n <div\n key={s.key}\n className=\"text-[10px] font-medium text-center\"\n style={{\n color: i + 1 === currentStep\n ? \"var(--color-text-primary)\"\n : i + 1 < currentStep\n ? \"var(--color-text-brand)\"\n : \"var(--color-text-muted)\",\n }}\n >\n {s.label}\n </div>\n ))}\n </div>\n </div>\n\n <div className=\"space-y-5\">{children}</div>\n\n {/* Footer actions */}\n <div className=\"flex items-center gap-2 mt-8 pt-6 border-t\" style={{ borderColor: \"var(--color-border-muted)\" }}>\n {previousHref ? (\n <Link href={previousHref}>\n <Button type=\"button\" variant=\"outline\" size=\"lg\">\n <ArrowLeft size={14} /> Back\n </Button>\n </Link>\n ) : (\n <Link href=\"/onboarding\">\n <Button type=\"button\" variant=\"outline\" size=\"lg\">\n Exit\n </Button>\n </Link>\n )}\n <Button\n type=\"button\"\n variant=\"brand\"\n size=\"lg\"\n className=\"flex-1\"\n disabled={!canContinue || saving}\n onClick={onSave}\n >\n {saving ? <><Spinner className=\"w-4 h-4 mr-1\" /> Saving…</> : <>{saveLabel} <ArrowRight size={14} /></>}\n </Button>\n </div>\n\n <p className=\"text-[11px] text-center mt-5\" style={{ color: \"var(--color-text-muted)\" }}>\n Progress saves automatically. You can return anytime from{\" \"}\n <Link href=\"/onboarding\" className=\"font-medium hover:underline underline-offset-4\" style={{ color: \"var(--color-text-brand)\" }}>\n your onboarding hub\n </Link>.\n </p>\n </AuthShell>\n );\n}"
25
+ }
26
+ ],
27
+ "content": "\"use client\";\n\nimport { ReactNode } from \"react\";\nimport Link from \"next/link\";\nimport { ArrowLeft, ArrowRight } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { AuthShell } from \"@/components/auth/auth-shell\";\nimport { Spinner } from \"@/components/auth/auth-primitives\";\nimport { StepProgress } from \"@/components/auth/onboarding-primitives\";\n\nexport type Role = \"trainee\" | \"mentor\" | \"sponsor\";\n\nexport const STEPS = [\n { key: \"profile\", label: \"Profile\" },\n { key: \"credentials\", label: \"Credentials\" },\n { key: \"legal\", label: \"Legal\" },\n { key: \"orientation\", label: \"Orientation\" },\n] as const;\n\nexport type StepKey = (typeof STEPS)[number][\"key\"];\n\nexport const ROLE_LABELS: Record<Role, string> = {\n trainee: \"Trainee\",\n mentor: \"Mentor\",\n sponsor: \"Sponsor\",\n};\n\ninterface OnboardingShellProps {\n role: Role;\n stepKey: StepKey;\n title: string;\n subtitle?: ReactNode;\n children: ReactNode;\n onSave?: () => void;\n saving?: boolean;\n canContinue?: boolean;\n previousHref?: string;\n saveLabel?: string;\n}\n\nexport function OnboardingShell({\n role, stepKey, title, subtitle, children,\n onSave, saving = false, canContinue = true,\n previousHref, saveLabel = \"Save & continue\",\n}: OnboardingShellProps) {\n const stepIdx = STEPS.findIndex((s) => s.key === stepKey);\n const currentStep = stepIdx + 1;\n\n return (\n <AuthShell\n width=\"wide\"\n kicker={`${ROLE_LABELS[role]} onboarding · Step ${currentStep} of ${STEPS.length}`}\n title={title}\n subtitle={subtitle}\n >\n {/* Progress dots */}\n <div className=\"mb-7\">\n <StepProgress current={currentStep} total={STEPS.length} />\n <div className=\"grid grid-cols-4 gap-2 mt-2\">\n {STEPS.map((s, i) => (\n <div\n key={s.key}\n className=\"text-[10px] font-medium text-center\"\n style={{\n color: i + 1 === currentStep\n ? \"var(--color-text-primary)\"\n : i + 1 < currentStep\n ? \"var(--color-text-brand)\"\n : \"var(--color-text-muted)\",\n }}\n >\n {s.label}\n </div>\n ))}\n </div>\n </div>\n\n <div className=\"space-y-5\">{children}</div>\n\n {/* Footer actions */}\n <div className=\"flex items-center gap-2 mt-8 pt-6 border-t\" style={{ borderColor: \"var(--color-border-muted)\" }}>\n {previousHref ? (\n <Link href={previousHref}>\n <Button type=\"button\" variant=\"outline\" size=\"lg\">\n <ArrowLeft size={14} /> Back\n </Button>\n </Link>\n ) : (\n <Link href=\"/onboarding\">\n <Button type=\"button\" variant=\"outline\" size=\"lg\">\n Exit\n </Button>\n </Link>\n )}\n <Button\n type=\"button\"\n variant=\"brand\"\n size=\"lg\"\n className=\"flex-1\"\n disabled={!canContinue || saving}\n onClick={onSave}\n >\n {saving ? <><Spinner className=\"w-4 h-4 mr-1\" /> Saving…</> : <>{saveLabel} <ArrowRight size={14} /></>}\n </Button>\n </div>\n\n <p className=\"text-[11px] text-center mt-5\" style={{ color: \"var(--color-text-muted)\" }}>\n Progress saves automatically. You can return anytime from{\" \"}\n <Link href=\"/onboarding\" className=\"font-medium hover:underline underline-offset-4\" style={{ color: \"var(--color-text-brand)\" }}>\n your onboarding hub\n </Link>.\n </p>\n </AuthShell>\n );\n}\n"
28
+ }
@@ -0,0 +1,41 @@
1
+ {
2
+ "revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
3
+ "file": "components/auth/status-indicator.tsx",
4
+ "sha256": "1f558a2c5816d7479a8245f039abed6ae20231730d3b90daac8703cc2f7fa4be",
5
+ "imports": [
6
+ "lucide-react",
7
+ "@/lib/verification",
8
+ "@/components/auth/onboarding-shell"
9
+ ],
10
+ "tokens": [
11
+ "--color-bg-default",
12
+ "--color-bg-subtle",
13
+ "--color-border-default",
14
+ "--color-status-error-bg",
15
+ "--color-status-error-text",
16
+ "--color-status-info-bg",
17
+ "--color-status-info-text",
18
+ "--color-status-success-bg",
19
+ "--color-status-success-text",
20
+ "--color-status-warning-bg",
21
+ "--color-status-warning-text",
22
+ "--color-text-brand",
23
+ "--color-text-muted",
24
+ "--color-text-primary",
25
+ "--gray-950",
26
+ "--teal-500"
27
+ ],
28
+ "fragments": [
29
+ {
30
+ "id": "StatusPill",
31
+ "line": 9,
32
+ "code": "export function StatusPill({ status, size = \"md\" }: { status: VerificationStatus; size?: \"sm\" | \"md\" }) {\n const variant = STATUS_VARIANT[status];\n const map = {\n info: { bg: \"var(--color-status-info-bg)\", fg: \"var(--color-status-info-text)\" },\n warning: { bg: \"var(--color-status-warning-bg)\", fg: \"var(--color-status-warning-text)\" },\n success: { bg: \"var(--color-status-success-bg)\", fg: \"var(--color-status-success-text)\" },\n error: { bg: \"var(--color-status-error-bg)\", fg: \"var(--color-status-error-text)\" },\n muted: { bg: \"var(--color-bg-subtle)\", fg: \"var(--color-text-muted)\" },\n };\n const { bg, fg } = map[variant];\n const sizeStyle = size === \"sm\"\n ? \"text-[10px] px-1.5 py-0.5\"\n : \"text-[11px] px-2 py-1\";\n return (\n <span\n className={`inline-flex items-center gap-1.5 rounded-md font-medium uppercase tracking-wide whitespace-nowrap ${sizeStyle}`}\n style={{ background: bg, color: fg }}\n >\n <span className=\"w-1.5 h-1.5 rounded-full\" style={{ background: fg }} />\n {STATUS_LABEL[status]}\n </span>\n );\n}"
33
+ },
34
+ {
35
+ "id": "StatusFlow",
36
+ "line": 35,
37
+ "code": "export function StatusFlow({ role, current }: { role: Role; current: VerificationStatus }) {\n const flow = flowFor(role);\n // Special-case: if current is on_hold or rejected, show all as muted\n const terminalState = current === \"on_hold\" || current === \"rejected\";\n const currentIdx = terminalState ? -1 : flow.indexOf(current);\n\n return (\n <div className=\"rounded-2xl border p-5\" style={{ borderColor: \"var(--color-border-default)\", background: \"var(--color-bg-subtle)\" }}>\n <div className=\"flex items-center justify-between mb-3.5\">\n <p className=\"text-[11px] font-semibold uppercase tracking-[0.12em]\" style={{ color: \"var(--color-text-muted)\" }}>\n Verification status\n </p>\n <StatusPill status={current} />\n </div>\n\n <div\n className=\"flex items-center gap-2 overflow-x-auto\"\n role=\"progressbar\"\n aria-label=\"Verification progress\"\n aria-valuenow={Math.max(currentIdx + 1, 1)}\n aria-valuemin={1}\n aria-valuemax={flow.length}\n tabIndex={0}\n >\n {flow.map((s, i) => {\n const done = !terminalState && i < currentIdx;\n const active = !terminalState && i === currentIdx;\n return (\n <div key={s} className=\"flex items-center gap-2 shrink-0\">\n <div className=\"flex flex-col items-center gap-1\">\n <div\n className=\"w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-semibold transition-colors\"\n style={{\n background: done || active ? \"var(--teal-500)\" : \"var(--color-bg-default)\",\n color: done || active ? \"var(--gray-950)\" : \"var(--color-text-muted)\",\n border: !done && !active ? `1px solid var(--color-border-default)` : \"0\",\n }}\n >\n {done ? <CheckCircle2 size={11} strokeWidth={3} /> : i + 1}\n </div>\n <span\n className=\"text-[10px] font-medium whitespace-nowrap\"\n style={{\n color: active\n ? \"var(--color-text-primary)\"\n : done\n ? \"var(--color-text-brand)\"\n : \"var(--color-text-muted)\",\n }}\n >\n {STATUS_LABEL[s]}\n </span>\n </div>\n {i < flow.length - 1 && (\n <div\n className=\"w-6 h-px\"\n style={{ background: done ? \"var(--teal-500)\" : \"var(--color-border-default)\", marginBottom: \"16px\" }}\n />\n )}\n </div>\n );\n })}\n </div>\n </div>\n );\n}"
38
+ }
39
+ ],
40
+ "content": "\"use client\";\n\nimport { CheckCircle2 } from \"lucide-react\";\nimport { VerificationStatus, STATUS_LABEL, STATUS_VARIANT, flowFor } from \"@/lib/verification\";\nimport type { Role } from \"@/components/auth/onboarding-shell\";\n\n/* ─── Status pill ────────────────────────────────────────────────────────── */\n\nexport function StatusPill({ status, size = \"md\" }: { status: VerificationStatus; size?: \"sm\" | \"md\" }) {\n const variant = STATUS_VARIANT[status];\n const map = {\n info: { bg: \"var(--color-status-info-bg)\", fg: \"var(--color-status-info-text)\" },\n warning: { bg: \"var(--color-status-warning-bg)\", fg: \"var(--color-status-warning-text)\" },\n success: { bg: \"var(--color-status-success-bg)\", fg: \"var(--color-status-success-text)\" },\n error: { bg: \"var(--color-status-error-bg)\", fg: \"var(--color-status-error-text)\" },\n muted: { bg: \"var(--color-bg-subtle)\", fg: \"var(--color-text-muted)\" },\n };\n const { bg, fg } = map[variant];\n const sizeStyle = size === \"sm\"\n ? \"text-[10px] px-1.5 py-0.5\"\n : \"text-[11px] px-2 py-1\";\n return (\n <span\n className={`inline-flex items-center gap-1.5 rounded-md font-medium uppercase tracking-wide whitespace-nowrap ${sizeStyle}`}\n style={{ background: bg, color: fg }}\n >\n <span className=\"w-1.5 h-1.5 rounded-full\" style={{ background: fg }} />\n {STATUS_LABEL[status]}\n </span>\n );\n}\n\n/* ─── Horizontal status flow visualizer ──────────────────────────────────── */\n\nexport function StatusFlow({ role, current }: { role: Role; current: VerificationStatus }) {\n const flow = flowFor(role);\n // Special-case: if current is on_hold or rejected, show all as muted\n const terminalState = current === \"on_hold\" || current === \"rejected\";\n const currentIdx = terminalState ? -1 : flow.indexOf(current);\n\n return (\n <div className=\"rounded-2xl border p-5\" style={{ borderColor: \"var(--color-border-default)\", background: \"var(--color-bg-subtle)\" }}>\n <div className=\"flex items-center justify-between mb-3.5\">\n <p className=\"text-[11px] font-semibold uppercase tracking-[0.12em]\" style={{ color: \"var(--color-text-muted)\" }}>\n Verification status\n </p>\n <StatusPill status={current} />\n </div>\n\n <div\n className=\"flex items-center gap-2 overflow-x-auto\"\n role=\"progressbar\"\n aria-label=\"Verification progress\"\n aria-valuenow={Math.max(currentIdx + 1, 1)}\n aria-valuemin={1}\n aria-valuemax={flow.length}\n tabIndex={0}\n >\n {flow.map((s, i) => {\n const done = !terminalState && i < currentIdx;\n const active = !terminalState && i === currentIdx;\n return (\n <div key={s} className=\"flex items-center gap-2 shrink-0\">\n <div className=\"flex flex-col items-center gap-1\">\n <div\n className=\"w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-semibold transition-colors\"\n style={{\n background: done || active ? \"var(--teal-500)\" : \"var(--color-bg-default)\",\n color: done || active ? \"var(--gray-950)\" : \"var(--color-text-muted)\",\n border: !done && !active ? `1px solid var(--color-border-default)` : \"0\",\n }}\n >\n {done ? <CheckCircle2 size={11} strokeWidth={3} /> : i + 1}\n </div>\n <span\n className=\"text-[10px] font-medium whitespace-nowrap\"\n style={{\n color: active\n ? \"var(--color-text-primary)\"\n : done\n ? \"var(--color-text-brand)\"\n : \"var(--color-text-muted)\",\n }}\n >\n {STATUS_LABEL[s]}\n </span>\n </div>\n {i < flow.length - 1 && (\n <div\n className=\"w-6 h-px\"\n style={{ background: done ? \"var(--teal-500)\" : \"var(--color-border-default)\", marginBottom: \"16px\" }}\n />\n )}\n </div>\n );\n })}\n </div>\n </div>\n );\n}\n"
41
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
3
+ "file": "components/portal/empty-state.tsx",
4
+ "sha256": "a861cfc783445cd6e9c5b5a72de93ef07eb550304ab9c1af705d343abaa68977",
5
+ "imports": [
6
+ "react",
7
+ "lucide-react"
8
+ ],
9
+ "tokens": [
10
+ "--color-bg-subtle",
11
+ "--color-border-default",
12
+ "--color-text-muted",
13
+ "--color-text-primary",
14
+ "--color-text-secondary"
15
+ ],
16
+ "fragments": [
17
+ {
18
+ "id": "EmptyState",
19
+ "line": 13,
20
+ "code": "export function EmptyState({ icon: Icon = Inbox, title, description, action }: EmptyStateProps) {\n return (\n <div\n className=\"rounded-2xl border-dashed border flex flex-col items-center justify-center text-center px-6 py-12\"\n style={{ borderColor: \"var(--color-border-default)\" }}\n >\n <div\n className=\"w-14 h-14 rounded-full flex items-center justify-center mb-4\"\n style={{ background: \"var(--color-bg-subtle)\", color: \"var(--color-text-muted)\" }}\n >\n <Icon size={22} strokeWidth={1.5} />\n </div>\n <h3 className=\"text-[15px] font-semibold mb-1\" style={{ color: \"var(--color-text-primary)\", letterSpacing: \"-0.01em\" }}>\n {title}\n </h3>\n {description && (\n <p className=\"text-[13px] mb-4 max-w-sm\" style={{ color: \"var(--color-text-secondary)\", lineHeight: 1.5 }}>\n {description}\n </p>\n )}\n {action && <div className=\"mt-2\">{action}</div>}\n </div>\n );\n}"
21
+ }
22
+ ],
23
+ "content": "\"use client\";\n\nimport { ReactNode } from \"react\";\nimport { Inbox } from \"lucide-react\";\n\ninterface EmptyStateProps {\n icon?: typeof Inbox;\n title: string;\n description?: string;\n action?: ReactNode;\n}\n\nexport function EmptyState({ icon: Icon = Inbox, title, description, action }: EmptyStateProps) {\n return (\n <div\n className=\"rounded-2xl border-dashed border flex flex-col items-center justify-center text-center px-6 py-12\"\n style={{ borderColor: \"var(--color-border-default)\" }}\n >\n <div\n className=\"w-14 h-14 rounded-full flex items-center justify-center mb-4\"\n style={{ background: \"var(--color-bg-subtle)\", color: \"var(--color-text-muted)\" }}\n >\n <Icon size={22} strokeWidth={1.5} />\n </div>\n <h3 className=\"text-[15px] font-semibold mb-1\" style={{ color: \"var(--color-text-primary)\", letterSpacing: \"-0.01em\" }}>\n {title}\n </h3>\n {description && (\n <p className=\"text-[13px] mb-4 max-w-sm\" style={{ color: \"var(--color-text-secondary)\", lineHeight: 1.5 }}>\n {description}\n </p>\n )}\n {action && <div className=\"mt-2\">{action}</div>}\n </div>\n );\n}\n"
24
+ }