@intelligo-dev/cli 1.0.0-beta.1 → 1.0.0-beta.14

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 (223) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +102 -0
  3. package/dist/bin.js +122 -37
  4. package/dist/bin.js.map +1 -1
  5. package/dist/commands/add.d.ts +26 -1
  6. package/dist/commands/add.d.ts.map +1 -1
  7. package/dist/commands/add.js +70 -4
  8. package/dist/commands/add.js.map +1 -1
  9. package/dist/commands/create-flow.d.ts +37 -0
  10. package/dist/commands/create-flow.d.ts.map +1 -0
  11. package/dist/commands/create-flow.js +212 -0
  12. package/dist/commands/create-flow.js.map +1 -0
  13. package/dist/commands/create.d.ts +27 -6
  14. package/dist/commands/create.d.ts.map +1 -1
  15. package/dist/commands/create.js +53 -13
  16. package/dist/commands/create.js.map +1 -1
  17. package/dist/commands/doctor.d.ts +14 -3
  18. package/dist/commands/doctor.d.ts.map +1 -1
  19. package/dist/commands/doctor.js +295 -37
  20. package/dist/commands/doctor.js.map +1 -1
  21. package/dist/commands/migrate-check.d.ts +73 -1
  22. package/dist/commands/migrate-check.d.ts.map +1 -1
  23. package/dist/commands/migrate-check.js +130 -5
  24. package/dist/commands/migrate-check.js.map +1 -1
  25. package/dist/commands/migrate.d.ts +97 -0
  26. package/dist/commands/migrate.d.ts.map +1 -0
  27. package/dist/commands/migrate.js +141 -0
  28. package/dist/commands/migrate.js.map +1 -0
  29. package/dist/commands/sync-messages.d.ts +37 -0
  30. package/dist/commands/sync-messages.d.ts.map +1 -0
  31. package/dist/commands/sync-messages.js +88 -0
  32. package/dist/commands/sync-messages.js.map +1 -0
  33. package/dist/commands/sync-scaffold.d.ts +36 -0
  34. package/dist/commands/sync-scaffold.d.ts.map +1 -0
  35. package/dist/commands/sync-scaffold.js +53 -0
  36. package/dist/commands/sync-scaffold.js.map +1 -0
  37. package/dist/commands/sync.d.ts +103 -0
  38. package/dist/commands/sync.d.ts.map +1 -0
  39. package/dist/commands/sync.js +361 -0
  40. package/dist/commands/sync.js.map +1 -0
  41. package/dist/commands/upgrade-check.d.ts +5 -5
  42. package/dist/commands/upgrade-check.d.ts.map +1 -1
  43. package/dist/commands/upgrade-check.js +22 -6
  44. package/dist/commands/upgrade-check.js.map +1 -1
  45. package/dist/env-files.d.ts +12 -0
  46. package/dist/env-files.d.ts.map +1 -0
  47. package/dist/env-files.js +29 -0
  48. package/dist/env-files.js.map +1 -0
  49. package/dist/index.d.ts +9 -0
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +9 -0
  52. package/dist/index.js.map +1 -1
  53. package/dist/manifest.d.ts +36 -15
  54. package/dist/manifest.d.ts.map +1 -1
  55. package/dist/manifest.js +32 -16
  56. package/dist/manifest.js.map +1 -1
  57. package/dist/migrations.d.ts +3 -6
  58. package/dist/migrations.d.ts.map +1 -1
  59. package/dist/migrations.js +3 -6
  60. package/dist/migrations.js.map +1 -1
  61. package/dist/model-catalogue.d.ts +14 -0
  62. package/dist/model-catalogue.d.ts.map +1 -0
  63. package/dist/model-catalogue.js +34 -0
  64. package/dist/model-catalogue.js.map +1 -0
  65. package/dist/module-exports.d.ts +63 -0
  66. package/dist/module-exports.d.ts.map +1 -0
  67. package/dist/module-exports.js +231 -0
  68. package/dist/module-exports.js.map +1 -0
  69. package/dist/registry-bundle.d.ts +43 -0
  70. package/dist/registry-bundle.d.ts.map +1 -0
  71. package/dist/registry-bundle.js +74 -0
  72. package/dist/registry-bundle.js.map +1 -0
  73. package/dist/registry-items.d.ts +84 -0
  74. package/dist/registry-items.d.ts.map +1 -0
  75. package/dist/registry-items.js +187 -0
  76. package/dist/registry-items.js.map +1 -0
  77. package/package.json +36 -10
  78. package/src/bin.ts +316 -0
  79. package/src/commands/add.ts +255 -0
  80. package/src/commands/create-flow.ts +284 -0
  81. package/src/commands/create.ts +149 -0
  82. package/src/commands/doctor.ts +487 -0
  83. package/src/commands/migrate-check.ts +298 -0
  84. package/src/commands/migrate.ts +228 -0
  85. package/src/commands/sync-messages.ts +114 -0
  86. package/src/commands/sync-scaffold.ts +83 -0
  87. package/src/commands/sync.ts +534 -0
  88. package/src/commands/upgrade-check.ts +160 -0
  89. package/src/env-files.ts +31 -0
  90. package/src/index.ts +39 -0
  91. package/src/manifest.ts +147 -0
  92. package/src/migrations-dir.ts +25 -0
  93. package/src/migrations.ts +133 -0
  94. package/src/model-catalogue.ts +35 -0
  95. package/src/module-exports.ts +266 -0
  96. package/src/registry-bundle.ts +103 -0
  97. package/src/registry-items.ts +237 -0
  98. package/templates/admin-page/admin-page.tsx.tpl +2 -3
  99. package/templates/app-scaffold/assistant-route.ts.tpl +2 -2
  100. package/templates/app-scaffold/auth-route.ts.tpl +10 -0
  101. package/templates/app-scaffold/components.json.tpl +6 -2
  102. package/templates/app-scaffold/db-schema.ts.tpl +29 -0
  103. package/templates/app-scaffold/drizzle-journal.json.tpl +5 -0
  104. package/templates/app-scaffold/drizzle.config.ts.tpl +45 -0
  105. package/templates/app-scaffold/env.example.tpl +65 -1
  106. package/templates/app-scaffold/globals.css.tpl +172 -281
  107. package/templates/app-scaffold/i18n-request.ts.tpl +25 -0
  108. package/templates/app-scaffold/instrumentation.ts.tpl +21 -0
  109. package/templates/app-scaffold/intelligo.ts.tpl +95 -41
  110. package/templates/app-scaffold/layout.tsx.tpl +18 -1
  111. package/templates/app-scaffold/lib-utils.ts.tpl +1 -6
  112. package/templates/app-scaffold/next.config.mjs.tpl +2 -0
  113. package/templates/app-scaffold/package.json.tpl +31 -17
  114. package/templates/app-scaffold/page.tsx.tpl +16 -0
  115. package/templates/app-scaffold/plans.ts.tpl +32 -13
  116. package/templates/app-scaffold/{middleware.ts.tpl → proxy.ts.tpl} +6 -0
  117. package/templates/app-scaffold/sonner.tsx.tpl +42 -20
  118. package/templates/app-scaffold/stripe-webhook-route.ts.tpl +24 -0
  119. package/templates/app-scaffold/theme-provider.tsx.tpl +6 -4
  120. package/templates/app-scaffold/tsconfig.json.tpl +20 -4
  121. package/templates/app-scaffold/use-mobile.ts.tpl +19 -0
  122. package/templates/app-scaffold/workspace-bootstrap.ts.tpl +30 -0
  123. package/templates/maintenance/maintenance-route.ts.tpl +119 -0
  124. package/templates/manifest.json +75 -17
  125. package/templates/registry/ai-agent-activity.json +23 -0
  126. package/templates/registry/ai-agent-progress.json +21 -0
  127. package/templates/registry/ai-approval-card.json +27 -0
  128. package/templates/registry/ai-artifact.json +22 -0
  129. package/templates/registry/ai-branch.json +21 -0
  130. package/templates/registry/ai-citations.json +24 -0
  131. package/templates/registry/ai-code-block.json +23 -0
  132. package/templates/registry/ai-composer-menu.json +19 -0
  133. package/templates/registry/ai-file-diff.json +25 -0
  134. package/templates/registry/ai-image-generation.json +23 -0
  135. package/templates/registry/ai-markdown.json +23 -0
  136. package/templates/registry/ai-message-bubble.json +23 -0
  137. package/templates/registry/ai-message-scroller.json +22 -0
  138. package/templates/registry/ai-message.json +21 -0
  139. package/templates/registry/ai-motion.json +19 -0
  140. package/templates/registry/ai-prompt-input.json +28 -0
  141. package/templates/registry/ai-reasoning-text.json +22 -0
  142. package/templates/registry/ai-reasoning.json +22 -0
  143. package/templates/registry/ai-shimmer-text.json +19 -0
  144. package/templates/registry/ai-sidebar.json +25 -0
  145. package/templates/registry/ai-speech-input.json +21 -0
  146. package/templates/registry/ai-streaming-response.json +24 -0
  147. package/templates/registry/ai-suggestion.json +19 -0
  148. package/templates/registry/ai-todo-list.json +22 -0
  149. package/templates/registry/ai-tool-approval.json +26 -0
  150. package/templates/registry/ai-tool-result.json +25 -0
  151. package/templates/registry/alert-dialog.json +23 -0
  152. package/templates/registry/animated-list.json +21 -0
  153. package/templates/registry/app-shell.json +93 -0
  154. package/templates/registry/artifacts.json +82 -0
  155. package/templates/registry/attachment.json +22 -0
  156. package/templates/registry/auth-email-verification.json +39 -0
  157. package/templates/registry/auth-login.json +72 -0
  158. package/templates/registry/auth-password-reset.json +53 -0
  159. package/templates/registry/auth-signup.json +41 -0
  160. package/templates/registry/billing-settings.json +60 -0
  161. package/templates/registry/button.json +22 -0
  162. package/templates/registry/chat-eve.json +19 -0
  163. package/templates/registry/chat-panel.json +30 -0
  164. package/templates/registry/chat-share.json +42 -0
  165. package/templates/registry/chat-widget.json +34 -0
  166. package/templates/registry/chat.json +326 -0
  167. package/templates/registry/checkbox.json +22 -0
  168. package/templates/registry/checkout.json +40 -0
  169. package/templates/registry/collapsible.json +19 -0
  170. package/templates/registry/command.json +23 -0
  171. package/templates/registry/copy-button.json +21 -0
  172. package/templates/registry/dashboard.json +69 -0
  173. package/templates/registry/dialog.json +24 -0
  174. package/templates/registry/document-viewer.json +22 -0
  175. package/templates/registry/dropdown-menu.json +23 -0
  176. package/templates/registry/expandable-tabs.json +22 -0
  177. package/templates/registry/feature-gating.json +73 -0
  178. package/templates/registry/file-upload.json +24 -0
  179. package/templates/registry/hold-action-button.json +22 -0
  180. package/templates/registry/input-group.json +23 -0
  181. package/templates/registry/input.json +19 -0
  182. package/templates/registry/intelligo.json +187 -0
  183. package/templates/registry/invitation-accept.json +64 -0
  184. package/templates/registry/language-switcher.json +29 -0
  185. package/templates/registry/morphing-modal.json +24 -0
  186. package/templates/registry/notification-stack.json +24 -0
  187. package/templates/registry/notifications.json +87 -0
  188. package/templates/registry/onboarding.json +83 -0
  189. package/templates/registry/otp-input.json +22 -0
  190. package/templates/registry/page-header.json +15 -0
  191. package/templates/registry/payment-poll.json +52 -0
  192. package/templates/registry/popover-morph.json +22 -0
  193. package/templates/registry/popover.json +22 -0
  194. package/templates/registry/pricing.json +111 -0
  195. package/templates/registry/privacy-settings.json +65 -0
  196. package/templates/registry/profile-settings.json +68 -0
  197. package/templates/registry/progress.json +19 -0
  198. package/templates/registry/radio-group.json +22 -0
  199. package/templates/registry/registry.json +2945 -0
  200. package/templates/registry/route-error.json +65 -0
  201. package/templates/registry/select-morph.json +23 -0
  202. package/templates/registry/select.json +23 -0
  203. package/templates/registry/settings-shell.json +47 -0
  204. package/templates/registry/sheet.json +24 -0
  205. package/templates/registry/sidebar.json +28 -0
  206. package/templates/registry/smoke.json +34 -0
  207. package/templates/registry/spinner.json +16 -0
  208. package/templates/registry/stat-card.json +18 -0
  209. package/templates/registry/status-badge.json +18 -0
  210. package/templates/registry/switch.json +20 -0
  211. package/templates/registry/tabs.json +23 -0
  212. package/templates/registry/team-settings.json +97 -0
  213. package/templates/registry/textarea.json +16 -0
  214. package/templates/registry/tooltip.json +22 -0
  215. package/templates/registry/trial-banner.json +43 -0
  216. package/templates/registry/usage.json +84 -0
  217. package/templates/registry/workspace-settings.json +71 -0
  218. package/templates/registry-items.json +116 -0
  219. package/templates/registry-requires.json +180 -0
  220. package/templates/usage-page/usage-page.tsx.tpl +23 -8
  221. package/templates/vitest/server-only.ts.tpl +7 -0
  222. package/templates/vitest/vitest.config.ts.tpl +37 -0
  223. package/templates/billing-page/billing-page.tsx.tpl +0 -72
@@ -0,0 +1,72 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "auth-login",
4
+ "title": "Auth: Login",
5
+ "description": "Email/password and OAuth login. Ships the shared auth route layout, the de-branded AuthCard shell, and the auth-validation.ts zod schemas reused by auth-signup, auth-password-reset, and auth-email-verification - install this item first.",
6
+ "dependencies": [
7
+ "@intelligo-dev/auth",
8
+ "react-hook-form@^7.71.1",
9
+ "@hookform/resolvers@^5.2.2",
10
+ "zod@^4.6.5",
11
+ "lucide-react",
12
+ "next-intl"
13
+ ],
14
+ "registryDependencies": [
15
+ "alert",
16
+ "@intelligo/button",
17
+ "@intelligo/input",
18
+ "@intelligo/spinner",
19
+ "field"
20
+ ],
21
+ "files": [
22
+ {
23
+ "path": "base/auth-login/layout.tsx",
24
+ "content": "/**\n * Minimal centered layout shared by every auth-* route: no nav, no\n * sidebar, just the page content inside the `main` landmark, behind a\n * skip link that is visible only when focused. Each auth-* page\n * composes its own `AuthCard` inside this shell.\n */\n\nimport { getTranslations } from \"next-intl/server\";\nimport type { ReactNode } from \"react\";\n\nconst SKIP_LINK_CLASS =\n \"sr-only focus:not-sr-only focus:fixed focus:top-3 focus:left-3 focus:z-50 focus:rounded-lg focus:border focus:border-ring focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:text-foreground focus:shadow-md focus:outline-none focus:ring-3 focus:ring-ring/40\";\n\nexport default async function AuthLayout({\n children,\n}: {\n children: ReactNode;\n}) {\n const t = await getTranslations(\"auth-login\");\n return (\n <>\n <a href=\"#main-content\" className={SKIP_LINK_CLASS}>\n {t(\"skipToContent\")}\n </a>\n <main\n id=\"main-content\"\n tabIndex={-1}\n className=\"min-h-screen bg-background outline-none\"\n >\n {children}\n </main>\n </>\n );\n}\n",
25
+ "type": "registry:page",
26
+ "target": "app/[locale]/(auth)/layout.tsx"
27
+ },
28
+ {
29
+ "path": "base/auth-login/page.tsx",
30
+ "content": "import type { Metadata } from \"next\";\nimport { getLocale, getTranslations } from \"next-intl/server\";\n\nimport { getAuthSession } from \"@intelligo-dev/auth\";\n\nimport { redirect } from \"@/i18n/navigation\";\nimport { returnPath } from \"@/lib/auth-validation\";\nimport { AuthCard } from \"@/components/auth/auth-card\";\nimport { SocialLoginButtons } from \"@/components/auth/social-login-buttons\";\nimport { LoginForm } from \"@/components/auth/login-form\";\n\nexport async function generateMetadata(): Promise<Metadata> {\n const t = await getTranslations(\"auth-login\");\n return {\n title: t(\"metadata.title\"),\n description: t(\"metadata.description\"),\n };\n}\n\n/**\n * A signed-in visitor has no use for this page.\n *\n * Done here rather than in middleware because middleware only sees the\n * session cookie, not the session. Redirecting on the cookie while the\n * authenticated app layout redirects on the real session is an infinite\n * loop for anyone holding a stale one — revoked elsewhere, expired row,\n * rotated secret — and the loop locks them out of the page that would\n * fix it.\n */\nasync function redirectIfSignedIn(next: string): Promise<void> {\n if (await getAuthSession())\n redirect({ href: next, locale: await getLocale() });\n}\n\nexport default async function LoginPage({\n searchParams,\n}: {\n searchParams: Promise<{ next?: string | string[] }>;\n}) {\n // Where the reader was going — an invitation link, say — when they\n // were sent here to sign in.\n const raw = (await searchParams).next;\n const next = returnPath(Array.isArray(raw) ? raw[0] : raw);\n await redirectIfSignedIn(next);\n\n const t = await getTranslations(\"auth-login\");\n\n // OAuth provider buttons only render for providers actually configured\n // on the server — checked here, not in the client component, so an\n // unconfigured client id/secret never reaches the browser at all.\n const providers: string[] = [];\n if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {\n providers.push(\"google\");\n }\n if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) {\n providers.push(\"github\");\n }\n\n return (\n <AuthCard title={t(\"card.title\")} description={t(\"card.description\")}>\n <div className=\"space-y-6\">\n <SocialLoginButtons providers={providers} next={next} />\n <LoginForm next={next} />\n </div>\n </AuthCard>\n );\n}\n",
31
+ "type": "registry:page",
32
+ "target": "app/[locale]/(auth)/login/page.tsx"
33
+ },
34
+ {
35
+ "path": "base/auth-login/components/auth-card.tsx",
36
+ "content": "/**\n * Shared auth page shell, unbranded.\n *\n * Ships with `auth-login`; `auth-signup`, `auth-password-reset`, and\n * `auth-email-verification` all import it from `@/components/auth/auth-card`\n * once installed (see those items' descriptions for the cross-item\n * dependency). A consumer that wants a logo or product name above the\n * card composes it around this component (or in `app/(auth)/layout.tsx`)\n * rather than editing this file — keeping it unbranded is what lets every\n * auth-* item reuse it as-is.\n */\n\nimport type { ReactNode } from \"react\";\n\ninterface AuthCardProps {\n title: string;\n description?: string;\n children: ReactNode;\n footer?: ReactNode;\n}\n\nexport function AuthCard({\n title,\n description,\n children,\n footer,\n}: AuthCardProps) {\n return (\n <div className=\"flex min-h-screen flex-col items-center justify-center px-4 py-12\">\n <div className=\"w-full max-w-md\">\n <div className=\"rounded-xl border border-border bg-card p-8 shadow-sm\">\n <div className=\"mb-6 text-center\">\n <h1 className=\"text-2xl font-bold text-foreground\">{title}</h1>\n {description && (\n <p className=\"mt-1 text-sm text-muted-foreground\">\n {description}\n </p>\n )}\n </div>\n {children}\n {footer && <div className=\"mt-6\">{footer}</div>}\n </div>\n </div>\n </div>\n );\n}\n",
37
+ "type": "registry:component",
38
+ "target": "components/auth/auth-card.tsx"
39
+ },
40
+ {
41
+ "path": "base/auth-login/components/login-form.tsx",
42
+ "content": "\"use client\";\n\n/**\n * Email + password login form. Calls the Better-Auth client SDK\n * (`@intelligo-dev/auth/client`) directly — no raw `fetch(\"/api/auth/*\")`.\n */\n\nimport { useState } from \"react\";\nimport { useTranslations } from \"next-intl\";\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { useForm } from \"react-hook-form\";\n\nimport { authClient } from \"@intelligo-dev/auth/client\";\n\nimport { Link, useRouter } from \"@/i18n/navigation\";\nimport { Alert, AlertDescription } from \"@/components/ui/alert\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Field, FieldError, FieldLabel } from \"@/components/ui/field\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { loginSchema, type LoginInput } from \"@/lib/auth-validation\";\n\n/** `next`: where to go once signed in — a path on this site. */\nexport function LoginForm({ next = \"/dashboard\" }: { next?: string }) {\n const t = useTranslations(\"auth-login\");\n const router = useRouter();\n const [formError, setFormError] = useState<string | null>(null);\n const [isLoading, setIsLoading] = useState(false);\n\n const {\n register,\n handleSubmit,\n formState: { errors },\n } = useForm<LoginInput>({\n resolver: zodResolver(loginSchema),\n });\n\n async function onSubmit(data: LoginInput) {\n try {\n setFormError(null);\n setIsLoading(true);\n\n const result = await authClient.signIn.email({\n email: data.email,\n password: data.password,\n callbackURL: next,\n });\n\n if (result.error) {\n setFormError(\n result.error.message ?? t(\"loginForm.errors.invalidCredentials\")\n );\n setIsLoading(false);\n return;\n }\n\n router.push(next);\n } catch (error) {\n console.error(\"Login error:\", error);\n setFormError(t(\"loginForm.errors.unexpected\"));\n setIsLoading(false);\n }\n }\n\n return (\n <form onSubmit={handleSubmit(onSubmit)} noValidate className=\"space-y-4\">\n {formError && (\n <Alert variant=\"destructive\">\n <AlertDescription>{formError}</AlertDescription>\n </Alert>\n )}\n\n <Field data-invalid={errors.email ? true : undefined}>\n <FieldLabel htmlFor=\"email\">{t(\"loginForm.emailLabel\")}</FieldLabel>\n <Input\n id=\"email\"\n type=\"email\"\n autoComplete=\"email\"\n disabled={isLoading}\n aria-invalid={errors.email ? true : undefined}\n aria-describedby={errors.email ? \"email-error\" : undefined}\n {...register(\"email\")}\n />\n {errors.email && (\n <FieldError id=\"email-error\">\n {t(`validation.${errors.email.message}`)}\n </FieldError>\n )}\n </Field>\n\n <Field data-invalid={errors.password ? true : undefined}>\n <div className=\"flex items-center justify-between\">\n <FieldLabel htmlFor=\"password\">\n {t(\"loginForm.passwordLabel\")}\n </FieldLabel>\n <Link\n href=\"/forgot-password\"\n className=\"text-xs font-medium text-primary hover:text-primary/80\"\n >\n {t(\"loginForm.forgotPassword\")}\n </Link>\n </div>\n <Input\n id=\"password\"\n type=\"password\"\n autoComplete=\"current-password\"\n disabled={isLoading}\n aria-invalid={errors.password ? true : undefined}\n aria-describedby={errors.password ? \"password-error\" : undefined}\n {...register(\"password\")}\n />\n {errors.password && (\n <FieldError id=\"password-error\">\n {t(`validation.${errors.password.message}`)}\n </FieldError>\n )}\n </Field>\n\n <Button\n type=\"submit\"\n className=\"w-full\"\n disabled={isLoading}\n aria-busy={isLoading || undefined}\n >\n {isLoading ? (\n <>\n <Spinner data-icon=\"inline-start\" />\n {t(\"loginForm.submitting\")}\n </>\n ) : (\n t(\"loginForm.submit\")\n )}\n </Button>\n\n <p className=\"text-center text-sm text-muted-foreground\">\n {t(\"loginForm.noAccount\")}{\" \"}\n <Link\n href={\n next === \"/dashboard\"\n ? \"/signup\"\n : `/signup?next=${encodeURIComponent(next)}`\n }\n className=\"font-medium text-primary hover:text-primary/80\"\n >\n {t(\"loginForm.signUpLink\")}\n </Link>\n </p>\n </form>\n );\n}\n",
43
+ "type": "registry:component",
44
+ "target": "components/auth/login-form.tsx"
45
+ },
46
+ {
47
+ "path": "base/auth-login/components/social-login-buttons.tsx",
48
+ "content": "\"use client\";\n\n/**\n * OAuth provider buttons — renders only the providers the server page\n * passes in via `providers` (see `page.tsx`: the env-var check is\n * server-side, so a provider with no client id/secret configured never\n * reaches the client at all). Calls the Better-Auth client SDK directly;\n * no raw `fetch(\"/api/auth/*\")`.\n *\n * Ships with `auth-login`; `auth-signup` reuses this same file from\n * `@/components/auth/social-login-buttons` once installed, so its copy\n * text lives under the `auth-login` message namespace regardless of\n * which page renders it — see that item's description for the\n * cross-item dependency.\n */\n\nimport { useState } from \"react\";\nimport { useTranslations } from \"next-intl\";\n\nimport { authClient } from \"@intelligo-dev/auth/client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Spinner } from \"@/components/ui/spinner\";\n\ntype Provider = \"google\" | \"github\";\n\ninterface SocialLoginButtonsProps {\n providers: string[];\n /** Where to go once signed in — a path on this site. */\n next?: string;\n}\n\nexport function SocialLoginButtons({\n providers,\n next = \"/dashboard\",\n}: SocialLoginButtonsProps) {\n const t = useTranslations(\"auth-login\");\n const [loadingProvider, setLoadingProvider] = useState<Provider | null>(null);\n const [failed, setFailed] = useState(false);\n\n if (providers.length === 0) {\n return null;\n }\n\n async function handleSocialLogin(provider: Provider) {\n setFailed(false);\n setLoadingProvider(provider);\n try {\n // On success the browser leaves for the provider. A provider the\n // server does not have configured answers `{ error }` instead of\n // throwing, and the buttons must come back either way.\n const { error } = await authClient.signIn.social({\n provider,\n callbackURL: next,\n });\n if (!error) return;\n } catch {\n // Same outcome as `{ error }`: stay on the page and say so.\n }\n setFailed(true);\n setLoadingProvider(null);\n }\n\n return (\n <div className=\"space-y-4\">\n {failed ? (\n <p role=\"alert\" className=\"text-sm text-destructive\">\n {t(\"socialLogin.failed\")}\n </p>\n ) : null}\n <div className=\"grid gap-2\">\n {providers.includes(\"google\") && (\n <Button\n type=\"button\"\n variant=\"outline\"\n onClick={() => handleSocialLogin(\"google\")}\n disabled={loadingProvider !== null}\n className=\"w-full\"\n >\n {loadingProvider === \"google\" ? (\n <Spinner />\n ) : (\n <svg\n width=\"16\"\n height=\"16\"\n viewBox=\"0 0 18 18\"\n fill=\"none\"\n aria-hidden=\"true\"\n >\n <path\n d=\"M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844a4.14 4.14 0 0 1-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615Z\"\n fill=\"#4285F4\"\n />\n <path\n d=\"M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18Z\"\n fill=\"#34A853\"\n />\n <path\n d=\"M3.964 10.71A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.997 8.997 0 0 0 0 9c0 1.452.348 2.827.957 4.042l3.007-2.332Z\"\n fill=\"#FBBC05\"\n />\n <path\n d=\"M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.958L3.964 6.29C4.672 4.163 6.656 3.58 9 3.58Z\"\n fill=\"#EA4335\"\n />\n </svg>\n )}\n {loadingProvider === \"google\"\n ? t(\"socialLogin.connecting\")\n : t(\"socialLogin.continueWithGoogle\")}\n </Button>\n )}\n\n {providers.includes(\"github\") && (\n <Button\n type=\"button\"\n variant=\"outline\"\n onClick={() => handleSocialLogin(\"github\")}\n disabled={loadingProvider !== null}\n className=\"w-full\"\n >\n {loadingProvider === \"github\" ? (\n <Spinner />\n ) : (\n <svg\n className=\"size-4\"\n fill=\"currentColor\"\n viewBox=\"0 0 24 24\"\n aria-hidden=\"true\"\n >\n <path d=\"M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z\" />\n </svg>\n )}\n {loadingProvider === \"github\"\n ? t(\"socialLogin.connecting\")\n : t(\"socialLogin.continueWithGithub\")}\n </Button>\n )}\n </div>\n\n <div className=\"flex items-center gap-4\">\n <div className=\"h-px flex-1 bg-border\" />\n <span className=\"text-xs font-medium uppercase tracking-wider text-muted-foreground\">\n {t(\"socialLogin.orContinueWith\")}\n </span>\n <div className=\"h-px flex-1 bg-border\" />\n </div>\n </div>\n );\n}\n",
49
+ "type": "registry:component",
50
+ "target": "components/auth/social-login-buttons.tsx"
51
+ },
52
+ {
53
+ "path": "base/auth-login/lib/auth-validation.ts",
54
+ "content": "/**\n * Shared client-side validation schemas for the auth-* registry items.\n *\n * Ships with `auth-login`; `auth-signup` and `auth-password-reset` import\n * it from `@/lib/auth-validation` once installed (see those items'\n * descriptions for the cross-item dependency). Plain zod, no\n * `@intelligo-dev/*` imports.\n *\n * Validation copy is user-facing, so these schemas do not carry literal\n * English strings — they carry stable error CODES (plain identifiers, not\n * translated text), and the form component that owns each schema maps a\n * code to `t(\\`validation.${code}\\`)` under its own registry item's\n * message namespace. A schema file has no component tree to\n * call `useTranslations`/`getTranslations` from, so translation happens\n * one layer up, in the form.\n */\n\nimport { z } from \"zod\";\n\n/** At least one uppercase letter, one lowercase letter, and one digit. */\nconst passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$/;\n\nexport const loginSchema = z.object({\n email: z.string().email(\"emailInvalid\"),\n password: z.string().min(8, \"passwordTooShort\"),\n});\n\nexport type LoginInput = z.infer<typeof loginSchema>;\n\nexport const signupSchema = z\n .object({\n name: z.string().min(2, \"nameTooShort\").max(50, \"nameTooLong\"),\n email: z.string().email(\"emailInvalid\"),\n password: z\n .string()\n .min(8, \"passwordTooShort\")\n .max(128, \"passwordTooLong\")\n .regex(passwordRegex, \"passwordWeak\"),\n confirmPassword: z.string(),\n })\n .refine((data) => data.password === data.confirmPassword, {\n message: \"passwordMismatch\",\n path: [\"confirmPassword\"],\n });\n\nexport type SignupInput = z.infer<typeof signupSchema>;\n\nexport const forgotPasswordSchema = z.object({\n email: z.string().email(\"emailInvalid\"),\n});\n\nexport type ForgotPasswordInput = z.infer<typeof forgotPasswordSchema>;\n\nexport const resetPasswordSchema = z\n .object({\n password: z\n .string()\n .min(8, \"passwordTooShort\")\n .max(128, \"passwordTooLong\")\n .regex(passwordRegex, \"passwordWeak\"),\n confirmPassword: z.string(),\n })\n .refine((data) => data.password === data.confirmPassword, {\n message: \"passwordMismatch\",\n path: [\"confirmPassword\"],\n });\n\nexport type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;\n\n/**\n * Where to go after signing in: `next` when it is a path on this site,\n * `/dashboard` otherwise. A full URL, or `//host`, would let a link send\n * a reader who just signed in to somebody else's site.\n */\nexport function returnPath(next: string | null | undefined): string {\n return typeof next === \"string\" && /^\\/(?![/\\\\])/.test(next)\n ? next\n : \"/dashboard\";\n}\n",
55
+ "type": "registry:file",
56
+ "target": "lib/auth-validation.ts"
57
+ },
58
+ {
59
+ "path": "base/auth-login/messages/en.json",
60
+ "content": "{\n \"skipToContent\": \"Skip to content\",\n \"metadata\": {\n \"title\": \"Log in\",\n \"description\": \"Log in to your account.\"\n },\n \"card\": {\n \"title\": \"Welcome back\",\n \"description\": \"Log in to your account\"\n },\n \"loginForm\": {\n \"emailLabel\": \"Email\",\n \"passwordLabel\": \"Password\",\n \"forgotPassword\": \"Forgot password?\",\n \"submit\": \"Sign in\",\n \"submitting\": \"Signing in…\",\n \"noAccount\": \"Don't have an account?\",\n \"signUpLink\": \"Sign up\",\n \"errors\": {\n \"invalidCredentials\": \"Incorrect email or password.\",\n \"unexpected\": \"Something went wrong. Please try again.\"\n }\n },\n \"socialLogin\": {\n \"connecting\": \"Connecting…\",\n \"continueWithGoogle\": \"Continue with Google\",\n \"continueWithGithub\": \"Continue with GitHub\",\n \"orContinueWith\": \"Or continue with\",\n \"failed\": \"Couldn't start that sign-in. Please try again or use your email.\"\n },\n \"validation\": {\n \"emailInvalid\": \"Please enter a valid email address\",\n \"passwordTooShort\": \"Password must be at least 8 characters\"\n }\n}\n",
61
+ "type": "registry:file",
62
+ "target": "messages/en/auth-login.json"
63
+ },
64
+ {
65
+ "path": "base/auth-login/auth-error.tsx",
66
+ "content": "\"use client\";\n\n/**\n * Error boundary for the auth route group, which renders outside the\n * app shell. Renders the shared `RouteError` from the `route-error`\n * item — install it alongside this one.\n */\n\nimport { RouteError } from \"@/components/shared/route-error\";\n\nexport default function SegmentError({\n error,\n reset,\n}: {\n error: Error & { digest?: string };\n reset: () => void;\n}) {\n return (\n <RouteError\n error={error}\n reset={reset}\n scope=\"auth\"\n fullScreen\n homeHref=\"/login\"\n homeKey=\"goToLogin\"\n />\n );\n}\n",
67
+ "type": "registry:page",
68
+ "target": "app/[locale]/(auth)/error.tsx"
69
+ }
70
+ ],
71
+ "type": "registry:block"
72
+ }
@@ -0,0 +1,53 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "auth-password-reset",
4
+ "title": "Auth: Password Reset",
5
+ "description": "Forgot-password and reset-password pages, backed by the Better-Auth client SDK's requestPasswordReset/resetPassword actions. Requires the auth-login item (provides the shared AuthCard shell and lib/auth-validation.ts schemas); install it first.",
6
+ "dependencies": [
7
+ "@intelligo-dev/auth",
8
+ "react-hook-form@^7.71.1",
9
+ "@hookform/resolvers@^5.2.2",
10
+ "lucide-react",
11
+ "next-intl"
12
+ ],
13
+ "registryDependencies": [
14
+ "alert",
15
+ "@intelligo/button",
16
+ "@intelligo/input",
17
+ "@intelligo/spinner",
18
+ "field"
19
+ ],
20
+ "files": [
21
+ {
22
+ "path": "base/auth-password-reset/forgot-page.tsx",
23
+ "content": "import type { Metadata } from \"next\";\nimport { getTranslations } from \"next-intl/server\";\n\nimport { AuthCard } from \"@/components/auth/auth-card\";\nimport { ForgotPasswordForm } from \"@/components/auth/forgot-password-form\";\n\nexport async function generateMetadata(): Promise<Metadata> {\n const t = await getTranslations(\"auth-password-reset\");\n return {\n title: t(\"forgotPage.metadata.title\"),\n description: t(\"forgotPage.metadata.description\"),\n };\n}\n\nexport default async function ForgotPasswordPage() {\n const t = await getTranslations(\"auth-password-reset\");\n\n return (\n <AuthCard\n title={t(\"forgotPage.card.title\")}\n description={t(\"forgotPage.card.description\")}\n >\n <ForgotPasswordForm />\n </AuthCard>\n );\n}\n",
24
+ "type": "registry:page",
25
+ "target": "app/[locale]/(auth)/forgot-password/page.tsx"
26
+ },
27
+ {
28
+ "path": "base/auth-password-reset/reset-page.tsx",
29
+ "content": "import type { Metadata } from \"next\";\nimport { Suspense } from \"react\";\nimport { getTranslations } from \"next-intl/server\";\n\nimport { AuthCard } from \"@/components/auth/auth-card\";\nimport { ResetPasswordForm } from \"@/components/auth/reset-password-form\";\n\nexport async function generateMetadata(): Promise<Metadata> {\n const t = await getTranslations(\"auth-password-reset\");\n return {\n title: t(\"resetPage.metadata.title\"),\n description: t(\"resetPage.metadata.description\"),\n };\n}\n\nexport default async function ResetPasswordPage() {\n const t = await getTranslations(\"auth-password-reset\");\n\n return (\n <AuthCard\n title={t(\"resetPage.card.title\")}\n description={t(\"resetPage.card.description\")}\n >\n {/* useSearchParams (inside ResetPasswordForm) requires a Suspense\n boundary — it opts the subtree out of full static rendering. */}\n <Suspense\n fallback={\n <div className=\"text-center text-sm text-muted-foreground\">\n {t(\"resetPage.loading\")}\n </div>\n }\n >\n <ResetPasswordForm />\n </Suspense>\n </AuthCard>\n );\n}\n",
30
+ "type": "registry:page",
31
+ "target": "app/[locale]/(auth)/reset-password/page.tsx"
32
+ },
33
+ {
34
+ "path": "base/auth-password-reset/components/forgot-password-form.tsx",
35
+ "content": "\"use client\";\n\n/**\n * Email-only form that starts the password reset flow through\n * Better-Auth's `requestPasswordReset`. `redirectTo` is where the\n * emailed link lands: `/reset-password?token=...` on a valid token,\n * `/reset-password?error=INVALID_TOKEN` on an expired or invalid one.\n */\n\nimport { useState } from \"react\";\nimport { useTranslations } from \"next-intl\";\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { useForm } from \"react-hook-form\";\n\nimport { authClient } from \"@intelligo-dev/auth/client\";\n\nimport { Link } from \"@/i18n/navigation\";\nimport { Alert, AlertDescription } from \"@/components/ui/alert\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Field, FieldError, FieldLabel } from \"@/components/ui/field\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport {\n forgotPasswordSchema,\n type ForgotPasswordInput,\n} from \"@/lib/auth-validation\";\n\nexport function ForgotPasswordForm() {\n const t = useTranslations(\"auth-password-reset\");\n const [formError, setFormError] = useState<string | null>(null);\n const [success, setSuccess] = useState(false);\n const [isLoading, setIsLoading] = useState(false);\n\n const {\n register,\n handleSubmit,\n formState: { errors },\n } = useForm<ForgotPasswordInput>({\n resolver: zodResolver(forgotPasswordSchema),\n });\n\n async function onSubmit(data: ForgotPasswordInput) {\n try {\n setFormError(null);\n setIsLoading(true);\n\n const result = await authClient.requestPasswordReset({\n email: data.email,\n redirectTo: \"/reset-password\",\n });\n\n if (result.error) {\n setFormError(result.error.message ?? t(\"forgotForm.errors.unexpected\"));\n setIsLoading(false);\n return;\n }\n\n setSuccess(true);\n setIsLoading(false);\n } catch (error) {\n console.error(\"Forgot password error:\", error);\n setFormError(t(\"forgotForm.errors.unexpected\"));\n setIsLoading(false);\n }\n }\n\n if (success) {\n return (\n <div className=\"space-y-4\">\n <Alert>\n <AlertDescription>{t(\"forgotForm.successMessage\")}</AlertDescription>\n </Alert>\n <Button\n variant=\"outline\"\n className=\"w-full\"\n render={<Link href=\"/login\" />}\n nativeButton={false}\n >\n {t(\"forgotForm.backToLogin\")}\n </Button>\n </div>\n );\n }\n\n return (\n <form onSubmit={handleSubmit(onSubmit)} noValidate className=\"space-y-4\">\n {formError && (\n <Alert variant=\"destructive\">\n <AlertDescription>{formError}</AlertDescription>\n </Alert>\n )}\n\n <Field data-invalid={errors.email ? true : undefined}>\n <FieldLabel htmlFor=\"email\">{t(\"forgotForm.emailLabel\")}</FieldLabel>\n <Input\n id=\"email\"\n type=\"email\"\n autoComplete=\"email\"\n disabled={isLoading}\n aria-invalid={errors.email ? true : undefined}\n aria-describedby={errors.email ? \"email-error\" : undefined}\n {...register(\"email\")}\n />\n {errors.email && (\n <FieldError id=\"email-error\">\n {t(`validation.${errors.email.message}`)}\n </FieldError>\n )}\n </Field>\n\n <Button\n type=\"submit\"\n className=\"w-full\"\n disabled={isLoading}\n aria-busy={isLoading || undefined}\n >\n {isLoading ? (\n <>\n <Spinner data-icon=\"inline-start\" />\n {t(\"forgotForm.submitting\")}\n </>\n ) : (\n t(\"forgotForm.submit\")\n )}\n </Button>\n\n <p className=\"text-center text-sm text-muted-foreground\">\n {t(\"forgotForm.rememberPassword\")}{\" \"}\n <Link\n href=\"/login\"\n className=\"font-medium text-primary hover:text-primary/80\"\n >\n {t(\"forgotForm.logInLink\")}\n </Link>\n </p>\n </form>\n );\n}\n",
36
+ "type": "registry:component",
37
+ "target": "components/auth/forgot-password-form.tsx"
38
+ },
39
+ {
40
+ "path": "base/auth-password-reset/components/reset-password-form.tsx",
41
+ "content": "\"use client\";\n\n/**\n * Password reset completion form, reached from the emailed link.\n * Better-Auth's `/reset-password/:token` redirect appends\n * `?token=<value>` on a valid token or `?error=INVALID_TOKEN` /\n * `?error=TOKEN_EXPIRED` otherwise, so both states are read from the\n * search params.\n */\n\nimport { useState } from \"react\";\nimport { useSearchParams } from \"next/navigation\";\nimport { useTranslations } from \"next-intl\";\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { useForm } from \"react-hook-form\";\n\nimport { authClient } from \"@intelligo-dev/auth/client\";\n\nimport { useRouter } from \"@/i18n/navigation\";\nimport { Alert, AlertDescription } from \"@/components/ui/alert\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Field, FieldError, FieldLabel } from \"@/components/ui/field\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport {\n resetPasswordSchema,\n type ResetPasswordInput,\n} from \"@/lib/auth-validation\";\n\nexport function ResetPasswordForm() {\n const t = useTranslations(\"auth-password-reset\");\n const router = useRouter();\n const searchParams = useSearchParams();\n const token = searchParams.get(\"token\");\n const linkError = searchParams.get(\"error\");\n\n const [formError, setFormError] = useState<string | null>(null);\n const [isLoading, setIsLoading] = useState(false);\n\n const {\n register,\n handleSubmit,\n formState: { errors },\n } = useForm<ResetPasswordInput>({\n resolver: zodResolver(resetPasswordSchema),\n });\n\n async function onSubmit(data: ResetPasswordInput) {\n if (!token) {\n setFormError(t(\"resetForm.errors.invalidLink\"));\n return;\n }\n\n try {\n setFormError(null);\n setIsLoading(true);\n\n const result = await authClient.resetPassword({\n newPassword: data.password,\n token,\n });\n\n if (result.error) {\n setFormError(result.error.message ?? t(\"resetForm.errors.invalidLink\"));\n setIsLoading(false);\n return;\n }\n\n router.push(\"/login?reset=success\");\n } catch (error) {\n console.error(\"Reset password error:\", error);\n setFormError(t(\"resetForm.errors.invalidLink\"));\n setIsLoading(false);\n }\n }\n\n if (!token || linkError) {\n return (\n <Alert variant=\"destructive\">\n <AlertDescription>\n {t(\"resetForm.errors.invalidLinkFromForgotPage\")}\n </AlertDescription>\n </Alert>\n );\n }\n\n return (\n <form onSubmit={handleSubmit(onSubmit)} noValidate className=\"space-y-4\">\n {formError && (\n <Alert variant=\"destructive\">\n <AlertDescription>{formError}</AlertDescription>\n </Alert>\n )}\n\n <Field data-invalid={errors.password ? true : undefined}>\n <FieldLabel htmlFor=\"password\">\n {t(\"resetForm.passwordLabel\")}\n </FieldLabel>\n <Input\n id=\"password\"\n type=\"password\"\n autoComplete=\"new-password\"\n disabled={isLoading}\n aria-invalid={errors.password ? true : undefined}\n aria-describedby={errors.password ? \"password-error\" : undefined}\n {...register(\"password\")}\n />\n {errors.password && (\n <FieldError id=\"password-error\">\n {t(`validation.${errors.password.message}`)}\n </FieldError>\n )}\n </Field>\n\n <Field data-invalid={errors.confirmPassword ? true : undefined}>\n <FieldLabel htmlFor=\"confirmPassword\">\n {t(\"resetForm.confirmPasswordLabel\")}\n </FieldLabel>\n <Input\n id=\"confirmPassword\"\n type=\"password\"\n autoComplete=\"new-password\"\n disabled={isLoading}\n aria-invalid={errors.confirmPassword ? true : undefined}\n aria-describedby={\n errors.confirmPassword ? \"confirm-password-error\" : undefined\n }\n {...register(\"confirmPassword\")}\n />\n {errors.confirmPassword && (\n <FieldError id=\"confirm-password-error\">\n {t(`validation.${errors.confirmPassword.message}`)}\n </FieldError>\n )}\n </Field>\n\n <Button\n type=\"submit\"\n className=\"w-full\"\n disabled={isLoading}\n aria-busy={isLoading || undefined}\n >\n {isLoading ? (\n <>\n <Spinner data-icon=\"inline-start\" />\n {t(\"resetForm.submitting\")}\n </>\n ) : (\n t(\"resetForm.submit\")\n )}\n </Button>\n </form>\n );\n}\n",
42
+ "type": "registry:component",
43
+ "target": "components/auth/reset-password-form.tsx"
44
+ },
45
+ {
46
+ "path": "base/auth-password-reset/messages/en.json",
47
+ "content": "{\n \"forgotPage\": {\n \"metadata\": {\n \"title\": \"Forgot password\",\n \"description\": \"Request a password reset link.\"\n },\n \"card\": {\n \"title\": \"Forgot your password?\",\n \"description\": \"Enter your email and we'll send you a reset link\"\n }\n },\n \"resetPage\": {\n \"metadata\": {\n \"title\": \"Reset password\",\n \"description\": \"Choose a new password.\"\n },\n \"card\": {\n \"title\": \"Reset your password\",\n \"description\": \"Enter your new password below\"\n },\n \"loading\": \"Loading…\"\n },\n \"forgotForm\": {\n \"emailLabel\": \"Email\",\n \"submit\": \"Send reset link\",\n \"submitting\": \"Sending reset link…\",\n \"successMessage\": \"If an account exists for that email, we've sent a link to reset your password.\",\n \"backToLogin\": \"Back to login\",\n \"rememberPassword\": \"Remembered your password?\",\n \"logInLink\": \"Log in\",\n \"errors\": {\n \"unexpected\": \"Something went wrong. Please try again.\"\n }\n },\n \"resetForm\": {\n \"passwordLabel\": \"New password\",\n \"confirmPasswordLabel\": \"Confirm new password\",\n \"submit\": \"Reset password\",\n \"submitting\": \"Resetting password…\",\n \"errors\": {\n \"invalidLink\": \"This reset link is invalid or has expired. Request a new one.\",\n \"invalidLinkFromForgotPage\": \"This reset link is invalid or has expired. Request a new one from the forgot password page.\"\n }\n },\n \"validation\": {\n \"emailInvalid\": \"Please enter a valid email address\",\n \"passwordTooShort\": \"Password must be at least 8 characters\",\n \"passwordTooLong\": \"Password must be less than 128 characters\",\n \"passwordWeak\": \"Password must contain at least 1 uppercase letter, 1 lowercase letter, and 1 number\",\n \"passwordMismatch\": \"Passwords do not match\"\n }\n}\n",
48
+ "type": "registry:file",
49
+ "target": "messages/en/auth-password-reset.json"
50
+ }
51
+ ],
52
+ "type": "registry:block"
53
+ }
@@ -0,0 +1,41 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "auth-signup",
4
+ "title": "Auth: Signup",
5
+ "description": "Email/password and OAuth account creation. Requires the auth-login item (provides the shared AuthCard shell, SocialLoginButtons, and lib/auth-validation.ts schemas); install it first. Redirects to auth-email-verification's /verify-email route when the server requires email verification.",
6
+ "dependencies": [
7
+ "@intelligo-dev/auth",
8
+ "react-hook-form@^7.71.1",
9
+ "@hookform/resolvers@^5.2.2",
10
+ "lucide-react",
11
+ "next-intl"
12
+ ],
13
+ "registryDependencies": [
14
+ "alert",
15
+ "@intelligo/button",
16
+ "@intelligo/input",
17
+ "@intelligo/spinner",
18
+ "field"
19
+ ],
20
+ "files": [
21
+ {
22
+ "path": "base/auth-signup/page.tsx",
23
+ "content": "import type { Metadata } from \"next\";\nimport { getLocale, getTranslations } from \"next-intl/server\";\n\nimport { getAuthSession } from \"@intelligo-dev/auth\";\n\nimport { redirect } from \"@/i18n/navigation\";\nimport { returnPath } from \"@/lib/auth-validation\";\nimport { AuthCard } from \"@/components/auth/auth-card\";\nimport { SocialLoginButtons } from \"@/components/auth/social-login-buttons\";\nimport { SignupForm } from \"@/components/auth/signup-form\";\n\nexport async function generateMetadata(): Promise<Metadata> {\n const t = await getTranslations(\"auth-signup\");\n return {\n title: t(\"metadata.title\"),\n description: t(\"metadata.description\"),\n };\n}\n\n/**\n * A signed-in visitor has no use for this page.\n *\n * Done here rather than in middleware because middleware only sees the\n * session cookie, not the session. Redirecting on the cookie while the\n * authenticated app layout redirects on the real session is an infinite\n * loop for anyone holding a stale one — revoked elsewhere, expired row,\n * rotated secret — and the loop locks them out of the page that would\n * fix it.\n */\nasync function redirectIfSignedIn(next: string): Promise<void> {\n if (await getAuthSession())\n redirect({ href: next, locale: await getLocale() });\n}\n\nexport default async function SignupPage({\n searchParams,\n}: {\n searchParams: Promise<{ next?: string | string[] }>;\n}) {\n // Where the reader was going — an invitation link, say — when they\n // were sent here to sign in.\n const raw = (await searchParams).next;\n const next = returnPath(Array.isArray(raw) ? raw[0] : raw);\n await redirectIfSignedIn(next);\n\n const t = await getTranslations(\"auth-signup\");\n\n // OAuth provider buttons only render for providers actually configured\n // on the server — checked here, not in the client component, so an\n // unconfigured client id/secret never reaches the browser at all.\n const providers: string[] = [];\n if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {\n providers.push(\"google\");\n }\n if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) {\n providers.push(\"github\");\n }\n\n return (\n <AuthCard title={t(\"card.title\")} description={t(\"card.description\")}>\n <div className=\"space-y-6\">\n <SocialLoginButtons providers={providers} next={next} />\n <SignupForm next={next} />\n </div>\n </AuthCard>\n );\n}\n",
24
+ "type": "registry:page",
25
+ "target": "app/[locale]/(auth)/signup/page.tsx"
26
+ },
27
+ {
28
+ "path": "base/auth-signup/components/signup-form.tsx",
29
+ "content": "\"use client\";\n\n/**\n * Registration form over `@intelligo-dev/auth/client`.\n *\n * Where it goes next depends on the server's\n * `emailAndPassword.requireEmailVerification`. When it is on,\n * `signUp.email()` creates the user but returns `token: null` (no\n * session), so the form sends the visitor to `/verify-email` with their\n * address. When it is off, or the address is exempt, a session comes\n * back and the form goes straight to the dashboard.\n */\n\nimport { useState } from \"react\";\nimport { useTranslations } from \"next-intl\";\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { useForm } from \"react-hook-form\";\n\nimport { authClient } from \"@intelligo-dev/auth/client\";\n\nimport { Link, useRouter } from \"@/i18n/navigation\";\nimport { Alert, AlertDescription } from \"@/components/ui/alert\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Field, FieldError, FieldLabel } from \"@/components/ui/field\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { signupSchema, type SignupInput } from \"@/lib/auth-validation\";\n\n/** `next`: where to go once signed up — a path on this site. */\nexport function SignupForm({ next = \"/dashboard\" }: { next?: string }) {\n const t = useTranslations(\"auth-signup\");\n const router = useRouter();\n const [formError, setFormError] = useState<string | null>(null);\n const [isLoading, setIsLoading] = useState(false);\n\n const {\n register,\n handleSubmit,\n formState: { errors },\n } = useForm<SignupInput>({\n resolver: zodResolver(signupSchema),\n });\n\n async function onSubmit(data: SignupInput) {\n try {\n setFormError(null);\n setIsLoading(true);\n\n const result = await authClient.signUp.email({\n name: data.name,\n email: data.email,\n password: data.password,\n callbackURL: next,\n });\n\n if (result.error) {\n setFormError(\n result.error.message?.includes(\"already\")\n ? t(\"signupForm.errors.accountExists\")\n : (result.error.message ?? t(\"signupForm.errors.unexpected\"))\n );\n setIsLoading(false);\n return;\n }\n\n if (!result.data?.token) {\n router.push(`/verify-email?email=${encodeURIComponent(data.email)}`);\n return;\n }\n\n router.push(next);\n } catch (error) {\n console.error(\"Signup error:\", error);\n setFormError(t(\"signupForm.errors.unexpected\"));\n setIsLoading(false);\n }\n }\n\n return (\n <form onSubmit={handleSubmit(onSubmit)} noValidate className=\"space-y-4\">\n {formError && (\n <Alert variant=\"destructive\">\n <AlertDescription>{formError}</AlertDescription>\n </Alert>\n )}\n\n <Field data-invalid={errors.name ? true : undefined}>\n <FieldLabel htmlFor=\"name\">{t(\"signupForm.nameLabel\")}</FieldLabel>\n <Input\n id=\"name\"\n type=\"text\"\n autoComplete=\"name\"\n disabled={isLoading}\n aria-invalid={errors.name ? true : undefined}\n aria-describedby={errors.name ? \"name-error\" : undefined}\n {...register(\"name\")}\n />\n {errors.name && (\n <FieldError id=\"name-error\">\n {t(`validation.${errors.name.message}`)}\n </FieldError>\n )}\n </Field>\n\n <Field data-invalid={errors.email ? true : undefined}>\n <FieldLabel htmlFor=\"email\">{t(\"signupForm.emailLabel\")}</FieldLabel>\n <Input\n id=\"email\"\n type=\"email\"\n autoComplete=\"email\"\n disabled={isLoading}\n aria-invalid={errors.email ? true : undefined}\n aria-describedby={errors.email ? \"email-error\" : undefined}\n {...register(\"email\")}\n />\n {errors.email && (\n <FieldError id=\"email-error\">\n {t(`validation.${errors.email.message}`)}\n </FieldError>\n )}\n </Field>\n\n <Field data-invalid={errors.password ? true : undefined}>\n <FieldLabel htmlFor=\"password\">\n {t(\"signupForm.passwordLabel\")}\n </FieldLabel>\n <Input\n id=\"password\"\n type=\"password\"\n autoComplete=\"new-password\"\n disabled={isLoading}\n aria-invalid={errors.password ? true : undefined}\n aria-describedby={errors.password ? \"password-error\" : undefined}\n {...register(\"password\")}\n />\n {errors.password && (\n <FieldError id=\"password-error\">\n {t(`validation.${errors.password.message}`)}\n </FieldError>\n )}\n </Field>\n\n <Field data-invalid={errors.confirmPassword ? true : undefined}>\n <FieldLabel htmlFor=\"confirmPassword\">\n {t(\"signupForm.confirmPasswordLabel\")}\n </FieldLabel>\n <Input\n id=\"confirmPassword\"\n type=\"password\"\n autoComplete=\"new-password\"\n disabled={isLoading}\n aria-invalid={errors.confirmPassword ? true : undefined}\n aria-describedby={\n errors.confirmPassword ? \"confirm-password-error\" : undefined\n }\n {...register(\"confirmPassword\")}\n />\n {errors.confirmPassword && (\n <FieldError id=\"confirm-password-error\">\n {t(`validation.${errors.confirmPassword.message}`)}\n </FieldError>\n )}\n </Field>\n\n <Button\n type=\"submit\"\n className=\"w-full\"\n disabled={isLoading}\n aria-busy={isLoading || undefined}\n >\n {isLoading ? (\n <>\n <Spinner data-icon=\"inline-start\" />\n {t(\"signupForm.submitting\")}\n </>\n ) : (\n t(\"signupForm.submit\")\n )}\n </Button>\n\n <p className=\"text-center text-sm text-muted-foreground\">\n {t(\"signupForm.alreadyHaveAccount\")}{\" \"}\n <Link\n href={\n next === \"/dashboard\"\n ? \"/login\"\n : `/login?next=${encodeURIComponent(next)}`\n }\n className=\"font-medium text-primary hover:text-primary/80\"\n >\n {t(\"signupForm.logInLink\")}\n </Link>\n </p>\n </form>\n );\n}\n",
30
+ "type": "registry:component",
31
+ "target": "components/auth/signup-form.tsx"
32
+ },
33
+ {
34
+ "path": "base/auth-signup/messages/en.json",
35
+ "content": "{\n \"metadata\": {\n \"title\": \"Sign up\",\n \"description\": \"Create an account.\"\n },\n \"card\": {\n \"title\": \"Create an account\",\n \"description\": \"Get started for free\"\n },\n \"signupForm\": {\n \"nameLabel\": \"Name\",\n \"emailLabel\": \"Email\",\n \"passwordLabel\": \"Password\",\n \"confirmPasswordLabel\": \"Confirm password\",\n \"submit\": \"Create account\",\n \"submitting\": \"Creating account…\",\n \"alreadyHaveAccount\": \"Already have an account?\",\n \"logInLink\": \"Log in\",\n \"errors\": {\n \"accountExists\": \"An account with this email already exists.\",\n \"unexpected\": \"Something went wrong. Please try again.\"\n }\n },\n \"validation\": {\n \"nameTooShort\": \"Name must be at least 2 characters\",\n \"nameTooLong\": \"Name must be less than 50 characters\",\n \"emailInvalid\": \"Please enter a valid email address\",\n \"passwordTooShort\": \"Password must be at least 8 characters\",\n \"passwordTooLong\": \"Password must be less than 128 characters\",\n \"passwordWeak\": \"Password must contain at least 1 uppercase letter, 1 lowercase letter, and 1 number\",\n \"passwordMismatch\": \"Passwords do not match\"\n }\n}\n",
36
+ "type": "registry:file",
37
+ "target": "messages/en/auth-signup.json"
38
+ }
39
+ ],
40
+ "type": "registry:block"
41
+ }
@@ -0,0 +1,60 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "billing-settings",
4
+ "title": "Billing Settings",
5
+ "description": "Workspace billing management: current plan, credit balance, credit bundle purchase, and the Stripe billing portal, shaped server-side by role (member/admin/owner). Requires the pricing item (provides lib/billing.ts and actions/billing.ts); install it first.",
6
+ "dependencies": [
7
+ "@intelligo-dev/auth",
8
+ "@intelligo-dev/billing",
9
+ "lucide-react",
10
+ "next-intl"
11
+ ],
12
+ "registryDependencies": [
13
+ "alert",
14
+ "@intelligo/button",
15
+ "card",
16
+ "skeleton",
17
+ "@intelligo/page-header",
18
+ "@intelligo/stat-card",
19
+ "@intelligo/animated-list"
20
+ ],
21
+ "files": [
22
+ {
23
+ "path": "base/billing-settings/page.tsx",
24
+ "content": "import type { Metadata } from \"next\";\nimport { getFormatter, getTranslations } from \"next-intl/server\";\n\nimport { requireWorkspace } from \"@intelligo-dev/auth\";\nimport { getBillingOverview } from \"@intelligo-dev/billing\";\n\nimport { Link } from \"@/i18n/navigation\";\nimport { Card } from \"@/components/ui/card\";\nimport { CreditBundles } from \"@/components/billing/credit-bundles\";\nimport { formatMoney } from \"@/lib/format-money\";\nimport { planName } from \"@/lib/plan-copy\";\nimport { PortalButton } from \"@/components/billing/portal-button\";\nimport {\n PageHeader,\n PageHeaderContent,\n PageHeaderDescription,\n PageHeaderTitle,\n} from \"@/components/ui/page-header\";\nimport {\n StatCard,\n StatCardFooter,\n StatCardHeader,\n StatCardLabel,\n StatCardValue,\n} from \"@/components/ui/stat-card\";\n\nexport async function generateMetadata(): Promise<Metadata> {\n const t = await getTranslations(\"billing-settings\");\n return { title: t(\"meta.title\") };\n}\n\n/**\n * Reads the caller's role from `requireWorkspace()` and the role-shaped\n * billing state from `getBillingOverview` (`@intelligo-dev/billing`).\n * `member` and `admin` get a read-only summary; `owner` gets the full\n * plan/credit/payment-method view.\n *\n * The plan's name is the deployment's `plans` message for its slug when\n * there is one (`lib/plan-copy.ts`), else the catalogue's name, else\n * this item's `freePlan` label for a workspace with no plan.\n *\n * `overview.subscription.status` renders verbatim, untranslated: it is a\n * plain `string` mirrored from Stripe's open-ended status vocabulary, so\n * keying a translation off it risks a missing-message error.\n */\nexport default async function BillingSettingsPage() {\n const t = await getTranslations(\"billing-settings\");\n const format = await getFormatter();\n const { workspace, membership } = await requireWorkspace();\n const overview = await getBillingOverview({\n workspaceId: workspace.id,\n role: membership.role,\n });\n const currentPlanName = planName(\n await getTranslations(\"plans\"),\n overview.planSlug,\n overview.planName ?? t(\"freePlan\")\n );\n\n return (\n <div className=\"space-y-6\">\n <PageHeader>\n <PageHeaderContent>\n <PageHeaderTitle level={2}>{t(\"page.title\")}</PageHeaderTitle>\n <PageHeaderDescription>\n {t(\"page.description\", { workspaceName: workspace.name })}\n </PageHeaderDescription>\n </PageHeaderContent>\n </PageHeader>\n\n {overview.role === \"member\" && (\n <Card className=\"space-y-2 p-6\">\n <p className=\"text-sm font-medium\">\n {t(\"member.currentPlan\", { planName: currentPlanName })}\n </p>\n <p className=\"text-sm text-muted-foreground\">{t(\"member.note\")}</p>\n </Card>\n )}\n\n {overview.role === \"admin\" && (\n <Card className=\"space-y-2 p-6\">\n <p className=\"text-sm font-medium\">\n {t(\"admin.currentPlan\", { planName: currentPlanName })}\n </p>\n <p className=\"text-sm text-muted-foreground\">{t(\"admin.note\")}</p>\n </Card>\n )}\n\n {overview.role === \"owner\" && (\n <div className=\"space-y-6\">\n <Card className=\"space-y-4 p-6\">\n <div className=\"flex items-center justify-between\">\n <div>\n <p className=\"text-sm font-medium text-muted-foreground\">\n {t(\"owner.currentPlanLabel\")}\n </p>\n <p className=\"text-2xl font-semibold\">{currentPlanName}</p>\n </div>\n {overview.subscription && (\n <span className=\"text-sm capitalize text-muted-foreground\">\n {overview.subscription.status}\n </span>\n )}\n </div>\n\n {overview.subscription?.currentPeriodEnd && (\n <p className=\"text-xs text-muted-foreground\">\n {t(\"owner.renews\", {\n date: format.dateTime(\n overview.subscription.currentPeriodEnd,\n {\n dateStyle: \"medium\",\n }\n ),\n })}\n </p>\n )}\n\n <div className=\"flex flex-wrap items-center gap-3 pt-2\">\n <Link\n href=\"/pricing\"\n className=\"text-sm font-medium underline underline-offset-4\"\n >\n {t(\"owner.viewAllPlans\")}\n </Link>\n {overview.subscription?.stripeCustomerId && (\n <PortalButton>{t(\"owner.manageSubscription\")}</PortalButton>\n )}\n </div>\n </Card>\n\n <StatCard>\n <StatCardHeader>\n <StatCardLabel>{t(\"owner.creditBalanceLabel\")}</StatCardLabel>\n <StatCardValue>\n {overview.creditBalance\n ? formatMoney(format, overview.creditBalance)\n : \"—\"}\n </StatCardValue>\n </StatCardHeader>\n <StatCardFooter>{t(\"owner.creditBalanceNote\")}</StatCardFooter>\n </StatCard>\n\n <CreditBundles currentBalance={overview.creditBalance} />\n\n {overview.subscription?.stripeCustomerId && (\n <Card className=\"space-y-2 p-6\">\n <p className=\"text-sm font-medium\">\n {t(\"owner.paymentMethodTitle\")}\n </p>\n <p className=\"text-sm text-muted-foreground\">\n {t(\"owner.paymentMethodNote\")}\n </p>\n <PortalButton variant=\"outline\">\n {t(\"owner.openPortal\")}\n </PortalButton>\n </Card>\n )}\n </div>\n )}\n </div>\n );\n}\n",
25
+ "type": "registry:page",
26
+ "target": "app/[locale]/(app)/settings/billing/page.tsx"
27
+ },
28
+ {
29
+ "path": "base/billing-settings/loading.tsx",
30
+ "content": "import { Card } from \"@/components/ui/card\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\n\nexport default function BillingSettingsLoading() {\n return (\n <div className=\"space-y-6\">\n <div>\n <Skeleton className=\"h-7 w-32\" />\n <Skeleton className=\"mt-2 h-4 w-64\" />\n </div>\n\n <Card className=\"space-y-4 p-6\">\n <Skeleton className=\"h-4 w-24\" />\n <Skeleton className=\"h-8 w-40\" />\n <Skeleton className=\"h-9 w-40\" />\n </Card>\n\n <Card className=\"space-y-4 p-6\">\n <Skeleton className=\"h-4 w-32\" />\n <Skeleton className=\"h-8 w-24\" />\n </Card>\n\n <div className=\"grid grid-cols-1 gap-4 md:grid-cols-3\">\n {[1, 2, 3].map((i) => (\n <Card key={i} className=\"space-y-4 p-6\">\n <Skeleton className=\"h-6 w-32\" />\n <Skeleton className=\"h-8 w-20\" />\n <Skeleton className=\"h-9 w-full\" />\n </Card>\n ))}\n </div>\n </div>\n );\n}\n",
31
+ "type": "registry:page",
32
+ "target": "app/[locale]/(app)/settings/billing/loading.tsx"
33
+ },
34
+ {
35
+ "path": "base/billing-settings/components/portal-button.tsx",
36
+ "content": "\"use client\";\n\n/**\n * Triggers a Stripe Customer Portal session for managing payment\n * methods, invoices, and subscription changes, and redirects the\n * browser there.\n */\n\nimport { useState } from \"react\";\nimport { useTranslations } from \"next-intl\";\n\nimport { Alert, AlertDescription } from \"@/components/ui/alert\";\nimport { Button } from \"@/components/ui/button\";\n\nimport { createPortalSession } from \"@/actions/billing\";\n\ninterface PortalButtonProps {\n children?: React.ReactNode;\n className?: string;\n variant?: \"default\" | \"outline\" | \"ghost\";\n}\n\nexport function PortalButton({\n children,\n className,\n variant = \"default\",\n}: PortalButtonProps) {\n const t = useTranslations(\"billing-settings\");\n const [isLoading, setIsLoading] = useState(false);\n const [error, setError] = useState<string | null>(null);\n\n const handleClick = async () => {\n setError(null);\n setIsLoading(true);\n\n const result = await createPortalSession();\n\n if (!result.success) {\n setError(result.error);\n setIsLoading(false);\n return;\n }\n\n window.location.href = result.data.url;\n };\n\n return (\n <div className=\"space-y-2\">\n <Button\n onClick={handleClick}\n disabled={isLoading}\n className={className}\n variant={variant}\n >\n {isLoading\n ? t(\"portalButton.redirecting\")\n : (children ?? t(\"portalButton.manageBilling\"))}\n </Button>\n\n {error && (\n <Alert variant=\"destructive\">\n <AlertDescription>{error}</AlertDescription>\n </Alert>\n )}\n </div>\n );\n}\n",
37
+ "type": "registry:component",
38
+ "target": "components/billing/portal-button.tsx"
39
+ },
40
+ {
41
+ "path": "base/billing-settings/components/credit-bundles.tsx",
42
+ "content": "\"use client\";\n\n/**\n * This deployment's credit bundles (`CREDIT_BUNDLES` from `@/lib/billing`,\n * installed by the `pricing` item) for one-time purchase.\n *\n * `bundle.name` is deployment config (`lib/billing-config.ts`), not this\n * item's copy, so it renders verbatim; localize it in that file.\n *\n * Each of a bundle's two amounts is formatted in its own currency:\n * `price` is what the buyer pays the payment provider, `grant` is what\n * lands in the ledger. A pack can cost $5 and grant ₮100,000.\n *\n * A bundle in the legacy `{ credits, priceUsd }` shape is read as whole\n * units of `CURRENCY`, priced in dollars.\n */\n\nimport { useState } from \"react\";\nimport { Coins } from \"lucide-react\";\nimport { useFormatter, useTranslations } from \"next-intl\";\n\nimport { Alert, AlertDescription } from \"@/components/ui/alert\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card } from \"@/components/ui/card\";\nimport { AnimatedList, AnimatedListItem } from \"@/components/ui/animated-list\";\n\nimport { createCreditPurchaseSession } from \"@/actions/billing\";\nimport { CREDIT_BUNDLES, CURRENCY } from \"@/lib/billing-config\";\nimport { formatMoney } from \"@/lib/format-money\";\n\n/** Micros are millionths of one major unit. */\nconst MICROS_PER_UNIT = 1_000_000;\n\ntype Bundle = (typeof CREDIT_BUNDLES)[number];\ntype Amount = { amount: number; currency: string };\n\n/** What the workspace receives, in the ledger's own currency. */\nfunction grantOf(bundle: Bundle): Amount {\n return \"grant\" in bundle\n ? bundle.grant\n : { amount: bundle.credits * MICROS_PER_UNIT, currency: CURRENCY };\n}\n\n/** What the buyer pays, in the currency the provider charges. */\nfunction priceOf(bundle: Bundle): Amount {\n return \"price\" in bundle\n ? bundle.price\n : {\n amount: Math.round(bundle.priceUsd * MICROS_PER_UNIT),\n currency: \"USD\",\n };\n}\n\ninterface CreditBundlesProps {\n /** The top-up balance, in the ledger's own currency. */\n currentBalance?: Amount | null;\n}\n\nexport function CreditBundles({ currentBalance }: CreditBundlesProps) {\n const t = useTranslations(\"billing-settings\");\n const format = useFormatter();\n const [loadingId, setLoadingId] = useState<string | null>(null);\n const [error, setError] = useState<string | null>(null);\n\n const handlePurchase = async (bundleId: string) => {\n setError(null);\n setLoadingId(bundleId);\n\n const result = await createCreditPurchaseSession({ bundleId });\n\n if (!result.success) {\n setError(result.error);\n setLoadingId(null);\n return;\n }\n\n window.location.href = result.data.url;\n };\n\n return (\n <div className=\"space-y-4\">\n <div>\n <p className=\"text-sm font-medium\">{t(\"creditBundles.buyMore\")}</p>\n {currentBalance && (\n <p className=\"text-sm text-muted-foreground\">\n {t(\"creditBundles.currentBalance\", {\n balance: formatMoney(format, currentBalance),\n })}\n </p>\n )}\n </div>\n\n <AnimatedList as=\"div\" className=\"grid grid-cols-1 gap-4 md:grid-cols-3\">\n {CREDIT_BUNDLES.map((bundle) => {\n const isLoading = loadingId === bundle.id;\n const grant = grantOf(bundle);\n const price = priceOf(bundle);\n return (\n <AnimatedListItem as=\"div\" key={bundle.id}>\n <Card className=\"h-full space-y-4 p-6\">\n <div className=\"flex items-center gap-2\">\n <Coins className=\"size-5 text-muted-foreground\" />\n <p className=\"font-medium\">{bundle.name}</p>\n </div>\n <p className=\"text-2xl font-semibold text-foreground\">\n {formatMoney(format, grant)}\n </p>\n <p className=\"text-sm text-muted-foreground\">\n {t(\"creditBundles.oneTime\", {\n price: formatMoney(format, price),\n })}\n </p>\n <Button\n onClick={() => handlePurchase(bundle.id)}\n disabled={isLoading}\n variant=\"outline\"\n className=\"w-full\"\n >\n {isLoading\n ? t(\"creditBundles.redirecting\")\n : t(\"creditBundles.purchase\")}\n </Button>\n </Card>\n </AnimatedListItem>\n );\n })}\n </AnimatedList>\n\n {error && (\n <Alert variant=\"destructive\">\n <AlertDescription>{error}</AlertDescription>\n </Alert>\n )}\n </div>\n );\n}\n",
43
+ "type": "registry:component",
44
+ "target": "components/billing/credit-bundles.tsx"
45
+ },
46
+ {
47
+ "path": "base/billing-settings/messages/en.json",
48
+ "content": "{\n \"meta\": {\n \"title\": \"Billing\"\n },\n \"page\": {\n \"title\": \"Billing\",\n \"description\": \"Manage {workspaceName}'s plan and payment details.\"\n },\n \"freePlan\": \"Free\",\n \"member\": {\n \"currentPlan\": \"Current plan: {planName}\",\n \"note\": \"Only workspace owners can manage billing. Ask an owner to change plans or update payment details.\"\n },\n \"admin\": {\n \"currentPlan\": \"Current plan: {planName}\",\n \"note\": \"Ask a workspace owner to change plans or manage payment details.\"\n },\n \"owner\": {\n \"currentPlanLabel\": \"Current plan\",\n \"renews\": \"Renews {date}\",\n \"viewAllPlans\": \"View all plans\",\n \"manageSubscription\": \"Manage subscription\",\n \"creditBalanceLabel\": \"Credit balance\",\n \"creditsUnit\": \"{count, plural, one {credit} other {credits}}\",\n \"creditBalanceNote\": \"Credits are used for pay-as-you-go access alongside your plan.\",\n \"paymentMethodTitle\": \"Payment method & invoices\",\n \"paymentMethodNote\": \"Manage your payment method and view invoices in the billing portal.\",\n \"openPortal\": \"Open billing portal\"\n },\n \"creditBundles\": {\n \"buyMore\": \"Buy more credits\",\n \"currentBalance\": \"Current balance: {balance}\",\n \"oneTime\": \"{price}, one-time\",\n \"purchase\": \"Purchase\",\n \"redirecting\": \"Redirecting…\"\n },\n \"portalButton\": {\n \"manageBilling\": \"Manage billing\",\n \"redirecting\": \"Redirecting…\"\n }\n}\n",
49
+ "type": "registry:file",
50
+ "target": "messages/en/billing-settings.json"
51
+ },
52
+ {
53
+ "path": "base/billing-settings/error.tsx",
54
+ "content": "\"use client\";\n\n/**\n * Error boundary for billing settings. Renders the shared `RouteError` from the\n * `route-error` item — install it alongside this one.\n */\n\nimport { RouteError } from \"@/components/shared/route-error\";\n\nexport default function SegmentError({\n error,\n reset,\n}: {\n error: Error & { digest?: string };\n reset: () => void;\n}) {\n return <RouteError error={error} reset={reset} scope=\"settings-billing\" />;\n}\n",
55
+ "type": "registry:page",
56
+ "target": "app/[locale]/(app)/settings/billing/error.tsx"
57
+ }
58
+ ],
59
+ "type": "registry:block"
60
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "button",
4
+ "title": "Button",
5
+ "description": "The action primitive: presses in and settles back — six variants, eight sizes, composes through render.",
6
+ "dependencies": [
7
+ "@base-ui/react",
8
+ "class-variance-authority"
9
+ ],
10
+ "registryDependencies": [
11
+ "@intelligo/ai-motion"
12
+ ],
13
+ "files": [
14
+ {
15
+ "path": "base/ui/button/button.tsx",
16
+ "content": "/*\n * The button: a control that presses in and settles back, over Base UI's\n * Button so `render` keeps working for links and triggers. The press is a\n * spring (`Press` from ai-motion, a client component) with an optional\n * ripple; a button given its own `render` — a link, a trigger's element —\n * keeps the CSS press. This module stays free of \"use client\" so a server\n * component can import `buttonVariants`.\n */\n\nimport { Button as ButtonPrimitive } from \"@base-ui/react/button\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\n\nimport { Press } from \"@/components/ui/ai-motion\";\nimport { cn } from \"@/lib/utils\";\n\nconst buttonVariants = cva(\n \"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,box-shadow,scale,opacity] duration-fast ease-standard outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/40 active:not-aria-[haspopup]:scale-97 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 in-data-[slot=button-group]:active:scale-100 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n {\n variants: {\n variant: {\n default: \"bg-primary text-primary-foreground hover:bg-primary/88\",\n outline:\n \"border-border bg-transparent hover:bg-accent hover:text-accent-foreground aria-expanded:bg-accent aria-expanded:text-accent-foreground\",\n secondary:\n \"border-border bg-card text-card-foreground hover:border-input hover:bg-accent aria-expanded:bg-accent\",\n ghost:\n \"text-muted-foreground hover:bg-accent hover:text-accent-foreground aria-expanded:bg-accent aria-expanded:text-accent-foreground\",\n destructive:\n \"bg-destructive/10 text-destructive hover:bg-destructive/16 focus-visible:border-destructive/40 focus-visible:ring-destructive/20\",\n link: \"rounded-md text-primary underline-offset-4 hover:underline active:not-aria-[haspopup]:scale-100\",\n },\n size: {\n default:\n \"h-8 gap-1.5 px-3.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3\",\n xs: \"h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3\",\n sm: \"h-7 gap-1 px-3 text-xs has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5 [&_svg:not([class*='size-'])]:size-3.5\",\n lg: \"h-10 gap-2 px-5 has-data-[icon=inline-end]:pr-4 has-data-[icon=inline-start]:pl-4\",\n icon: \"size-8\",\n \"icon-xs\": \"size-6 [&_svg:not([class*='size-'])]:size-3\",\n \"icon-sm\": \"size-7\",\n \"icon-lg\": \"size-10\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"default\",\n },\n }\n);\n\nfunction Button({\n className,\n variant = \"default\",\n size = \"default\",\n ripple = false,\n render,\n ...props\n}: ButtonPrimitive.Props &\n VariantProps<typeof buttonVariants> & {\n /** Spread a ripple from the press point (spring-pressed buttons only). */\n ripple?: boolean;\n }) {\n const springs = render === undefined;\n\n return (\n <ButtonPrimitive\n data-slot=\"button\"\n render={\n springs ? (\n <Press ripple={ripple} pressScale={variant === \"link\" ? 1 : 0.97} />\n ) : (\n render\n )\n }\n className={cn(\n buttonVariants({ variant, size }),\n // The spring owns the press; the CSS one stays for custom elements.\n springs && \"active:not-aria-[haspopup]:scale-100\",\n className\n )}\n {...props}\n />\n );\n}\n\nexport { Button, buttonVariants };\n",
17
+ "type": "registry:ui",
18
+ "target": "components/ui/button.tsx"
19
+ }
20
+ ],
21
+ "type": "registry:ui"
22
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "chat-eve",
4
+ "title": "Chat: eve binding",
5
+ "description": "eve through the chat transport: a streamTurn for lib/chat-server-config.ts that runs a turn as an eve session over HTTP and NDJSON and maps its events to the AI SDK's chunks — text, reasoning, tool calls, approvals, questions, subagents, authorization — so an eve agent renders through the same tool renderers as any other. Consumer-owned; imports nothing from eve. Requires the chat item.",
6
+ "dependencies": [
7
+ "ai@^7.0.103",
8
+ "@intelligo-dev/chat"
9
+ ],
10
+ "files": [
11
+ {
12
+ "path": "base/chat-eve/lib/chat-eve.ts",
13
+ "content": "/**\n * eve, through the chat transport — consumer-owned.\n *\n * eve (Vercel's agent framework) runs an agent as a durable session\n * and streams its own NDJSON events; its parts follow the AI SDK's\n * `UIMessage` convention but are not the AI SDK's types. This file is\n * the bridge: `eveStreamTurn` is a `streamTurn` for\n * `lib/chat-server-config.ts`, so every eve turn still goes through\n * the transport's auth, rate limit, feature gate, admission,\n * persistence and settlement, and renders through the same\n * `TOOL_RENDERERS` / `DATA_RENDERERS` as any other turn.\n *\n * import { eveStreamTurn } from \"@/lib/chat-eve\";\n *\n * export const chatServerConfig: ChatServerConfig = {\n * executions,\n * model: { defaultId: \"eve/agent\" }, // a registered model id to settle against\n * streamTurn: eveStreamTurn({ baseUrl: process.env.EVE_URL! }),\n * …\n * };\n *\n * The eve session id and stream cursor live in the conversation's\n * `metadata.eve`, so a conversation continues its session across\n * turns and a cancelled turn resumes from where the stream stopped.\n *\n * Nothing here is framework code and nothing imports eve: the wire\n * protocol is HTTP + NDJSON (`/eve/v1/session`, `/stream`, `/cancel`),\n * read defensively. The event → chunk table is `mapEveEvent`; extend\n * it when eve grows an event.\n */\n\nimport type { FileUIPart, UIMessage, UIMessageChunk } from \"ai\";\n\nimport type {\n ChatTurn,\n PreparedTurn,\n StreamTurn,\n TokenUsage,\n} from \"@intelligo-dev/chat\";\nimport type { ChatDataChunk } from \"@intelligo-dev/chat/client\";\n\n// ---------------------------------------------------------------------------\n// Protocol\n// ---------------------------------------------------------------------------\n\nexport type EveEvent = {\n type: string;\n data?: Record<string, unknown>;\n meta?: { id?: string; at?: string };\n};\n\nexport type EveCursor = { sessionId: string; streamIndex: number };\n\nexport type EveInputRequest = {\n requestId: string;\n kind: \"question\" | \"session-limit\" | \"tool-approval\";\n prompt: string;\n options?: Array<{\n id: string;\n label: string;\n description?: string;\n style?: string;\n }>;\n allowFreeform?: boolean;\n action?: { callId: string; toolName: string; input?: unknown };\n};\n\nexport type EveInputResponse = {\n requestId: string;\n optionId?: string;\n text?: string;\n};\n\ntype EveMetadata = {\n sessionId?: string;\n streamIndex?: number;\n /** A question the run paused on; the next user message answers it. */\n pendingQuestion?: { requestId: string; options?: EveInputRequest[\"options\"] };\n};\n\nfunction asRecord(value: unknown): Record<string, unknown> {\n return typeof value === \"object\" && value !== null\n ? (value as Record<string, unknown>)\n : {};\n}\n\nfunction str(value: unknown, fallback = \"\"): string {\n return typeof value === \"string\" ? value : fallback;\n}\n\nconst APPROVING = /\\b(approve|allow|yes|confirm|accept|ok)\\b/i;\nconst DENYING = /\\b(deny|reject|no|not|cancel|decline|disallow|never|stop)\\b/i;\n\n/**\n * The option an approval answer picks: eve names them; we match by\n * intent, on whole words, so \"Approve now\" is not read as \"no\". An\n * answer never lands on an option that says the opposite: with none that\n * matches, it is undefined and the answer goes back as text.\n */\nfunction optionFor(\n options: EveInputRequest[\"options\"] | undefined,\n approved: boolean\n): string | undefined {\n if (!options?.length) return undefined;\n const [wanted, opposite] = approved\n ? [APPROVING, DENYING]\n : [DENYING, APPROVING];\n const says = (o: { id: string; label: string }, pattern: RegExp) =>\n pattern.test(o.id) || pattern.test(o.label);\n return options.find((o) => says(o, wanted) && !says(o, opposite))?.id;\n}\n\n// ---------------------------------------------------------------------------\n// Event → UI message chunks\n// ---------------------------------------------------------------------------\n\nexport type EveMapperState = {\n usage: TokenUsage;\n finishReason?: string;\n modelId?: string;\n done: boolean;\n failed?: string;\n cancelled: boolean;\n /** A question the run paused on, to remember for the next turn. */\n pendingQuestion?: EveMetadata[\"pendingQuestion\"];\n};\n\n/**\n * One eve event → the AI SDK chunks it means. Stateful per turn: it\n * remembers which text, reasoning and tool blocks are open so deltas\n * land in the right part and blocks close once.\n */\nexport function createEveEventMapper() {\n const state: EveMapperState = {\n usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },\n done: false,\n cancelled: false,\n };\n const openText = new Map<number, string>();\n const openReasoning = new Map<number, string>();\n const startedTools = new Set<string>();\n const seen = new Set<string>();\n\n function textId(step: number) {\n let id = openText.get(step);\n if (!id) {\n id = `t-${step}-${openText.size}`;\n openText.set(step, id);\n }\n return id;\n }\n\n function map(event: EveEvent): UIMessageChunk[] {\n const id = event.meta?.id;\n if (id) {\n if (seen.has(id)) return [];\n seen.add(id);\n }\n const data = asRecord(event.data);\n const step = typeof data.stepIndex === \"number\" ? data.stepIndex : 0;\n const out: UIMessageChunk[] = [];\n\n switch (event.type) {\n case \"step.started\":\n if (typeof data.modelId === \"string\") state.modelId = data.modelId;\n out.push({ type: \"start-step\" });\n break;\n\n case \"step.completed\": {\n const usage = asRecord(data.usage);\n const input =\n typeof usage.inputTokens === \"number\" ? usage.inputTokens : 0;\n const output =\n typeof usage.outputTokens === \"number\" ? usage.outputTokens : 0;\n state.usage = {\n inputTokens: (state.usage.inputTokens ?? 0) + input,\n outputTokens: (state.usage.outputTokens ?? 0) + output,\n totalTokens: (state.usage.totalTokens ?? 0) + input + output,\n };\n if (typeof data.finishReason === \"string\")\n state.finishReason = data.finishReason;\n const text = openText.get(step);\n if (text) {\n out.push({ type: \"text-end\", id: text });\n openText.delete(step);\n }\n out.push({ type: \"finish-step\" });\n break;\n }\n\n case \"reasoning.appended\": {\n let rid = openReasoning.get(step);\n if (!rid) {\n rid = `r-${step}`;\n openReasoning.set(step, rid);\n out.push({ type: \"reasoning-start\", id: rid });\n }\n out.push({\n type: \"reasoning-delta\",\n id: rid,\n delta: str(data.reasoningDelta),\n });\n break;\n }\n case \"reasoning.completed\": {\n const rid = openReasoning.get(step);\n if (rid) {\n out.push({ type: \"reasoning-end\", id: rid });\n openReasoning.delete(step);\n }\n break;\n }\n\n case \"message.appended\": {\n const tid = openText.get(step);\n const target = tid ?? textId(step);\n if (!tid) out.push({ type: \"text-start\", id: target });\n out.push({\n type: \"text-delta\",\n id: target,\n delta: str(data.messageDelta),\n });\n break;\n }\n case \"message.completed\": {\n const tid = openText.get(step);\n if (tid) {\n out.push({ type: \"text-end\", id: tid });\n openText.delete(step);\n }\n break;\n }\n\n case \"action.input.appended\": {\n const callId = str(data.callId);\n if (!callId) break;\n if (!startedTools.has(callId)) {\n startedTools.add(callId);\n out.push({\n type: \"tool-input-start\",\n toolCallId: callId,\n toolName: str(data.toolName, \"tool\"),\n dynamic: true,\n });\n }\n out.push({\n type: \"tool-input-delta\",\n toolCallId: callId,\n inputTextDelta: str(data.inputTextDelta),\n });\n break;\n }\n\n case \"actions.requested\": {\n const actions = Array.isArray(data.actions) ? data.actions : [];\n for (const raw of actions) {\n const action = asRecord(raw);\n const callId = str(action.callId);\n if (!callId) continue;\n startedTools.add(callId);\n out.push({\n type: \"tool-input-available\",\n toolCallId: callId,\n toolName: str(\n action.toolName ?? action.name ?? action.kind,\n \"tool\"\n ),\n input: action.input ?? {},\n dynamic: true,\n providerMetadata: { eve: { kind: str(action.kind, \"tool-call\") } },\n });\n }\n break;\n }\n\n case \"action.partial\": {\n const result = asRecord(data.result);\n const callId = str(result.callId);\n if (!callId) break;\n out.push({\n type: \"tool-output-available\",\n toolCallId: callId,\n output: result.output ?? null,\n dynamic: true,\n preliminary: true,\n });\n break;\n }\n\n case \"action.result\": {\n const result = asRecord(data.result);\n const callId = str(result.callId);\n if (!callId) break;\n const status = str(data.status, \"completed\");\n if (status === \"rejected\") {\n out.push({ type: \"tool-output-denied\", toolCallId: callId });\n } else if (status === \"failed\" || result.isError === true) {\n const error = asRecord(data.error);\n out.push({\n type: \"tool-output-error\",\n toolCallId: callId,\n errorText: str(\n error.message,\n typeof result.output === \"string\" ? result.output : \"Tool failed\"\n ),\n dynamic: true,\n });\n } else {\n out.push({\n type: \"tool-output-available\",\n toolCallId: callId,\n output: result.output ?? null,\n dynamic: true,\n });\n }\n break;\n }\n\n case \"input.requested\": {\n const requests = Array.isArray(data.requests) ? data.requests : [];\n for (const raw of requests) {\n const request = asRecord(raw) as unknown as EveInputRequest;\n if (!request.requestId) continue;\n if (request.kind === \"tool-approval\" && request.action?.callId) {\n const callId = request.action.callId;\n // Re-state the call with the request on it, so the answer\n // on the next turn can be turned back into eve's option id.\n out.push({\n type: \"tool-input-available\",\n toolCallId: callId,\n toolName: request.action.toolName || \"tool\",\n input: request.action.input ?? {},\n dynamic: true,\n providerMetadata: {\n eve: {\n inputRequest: {\n requestId: request.requestId,\n options: (request.options ?? []) as never,\n },\n },\n },\n });\n out.push({\n type: \"tool-approval-request\",\n approvalId: request.requestId,\n toolCallId: callId,\n });\n } else {\n state.pendingQuestion = {\n requestId: request.requestId,\n options: request.options,\n };\n out.push(\n dataChunk({\n type: \"data-chat-question\",\n id: request.requestId,\n data: {\n id: request.requestId,\n prompt: request.prompt,\n ...(request.options\n ? {\n options: request.options.map(\n ({ id, label, description }) => ({\n id,\n label,\n ...(description ? { description } : {}),\n })\n ),\n }\n : {}),\n ...(request.allowFreeform !== undefined\n ? { allowFreeform: request.allowFreeform }\n : {}),\n },\n })\n );\n }\n }\n break;\n }\n\n case \"subagent.called\": {\n const callId = str(data.callId);\n out.push(\n dataChunk({\n type: \"data-chat-agent\",\n id: callId || undefined,\n data: {\n id: callId,\n name: str(data.subagentName ?? data.agentId, \"subagent\"),\n status: \"started\",\n },\n })\n );\n break;\n }\n case \"subagent.completed\": {\n const callId = str(data.callId);\n const output = str(data.output);\n out.push(\n dataChunk({\n type: \"data-chat-agent\",\n id: callId || undefined,\n data: {\n id: callId,\n name: str(data.subagentName, \"subagent\"),\n status: \"completed\",\n ...(output\n ? {\n summary:\n output.length > 280 ? `${output.slice(0, 279)}…` : output,\n }\n : {}),\n },\n })\n );\n break;\n }\n\n case \"authorization.required\": {\n const challenge = asRecord(data.authorization);\n const id = str(\n data.attemptId ?? data.candidateId ?? data.name,\n \"authorization\"\n );\n out.push(\n dataChunk({\n type: \"data-chat-authorization\",\n id,\n data: {\n id,\n name: str(data.name),\n status: \"required\",\n ...(typeof data.description === \"string\"\n ? { description: data.description }\n : {}),\n ...(typeof challenge.url === \"string\"\n ? { url: challenge.url }\n : {}),\n ...(typeof challenge.instructions === \"string\"\n ? { instructions: challenge.instructions }\n : {}),\n },\n })\n );\n break;\n }\n case \"authorization.completed\": {\n const id = str(\n data.attemptId ?? data.candidateId ?? data.name,\n \"authorization\"\n );\n out.push(\n dataChunk({\n type: \"data-chat-authorization\",\n id,\n data: { id, name: str(data.name), status: \"completed\" },\n })\n );\n break;\n }\n\n case \"compaction.requested\":\n case \"compaction.completed\":\n out.push(\n dataChunk({\n type: \"data-chat-compaction\",\n data: {\n status:\n event.type === \"compaction.requested\"\n ? \"requested\"\n : \"completed\",\n },\n transient: true,\n })\n );\n break;\n\n case \"result.completed\":\n out.push(\n dataChunk({ type: \"data-chat-result\", data: data.result ?? null })\n );\n break;\n\n case \"step.failed\":\n case \"turn.failed\":\n case \"session.failed\": {\n const message = str(data.message, \"The agent failed.\");\n state.failed = message;\n state.done = true;\n out.push({ type: \"error\", errorText: message });\n break;\n }\n\n case \"turn.cancelled\":\n state.cancelled = true;\n state.done = true;\n out.push({ type: \"abort\" });\n break;\n\n case \"turn.completed\":\n case \"session.waiting\":\n case \"session.completed\":\n state.done = true;\n break;\n\n default:\n // session.started, turn.started, message.received, approval.*,\n // subagent.started / subagent.event, context.cleared: nothing to draw.\n break;\n }\n return out;\n }\n\n return { map, state };\n}\n\nfunction dataChunk(chunk: ChatDataChunk): UIMessageChunk {\n return chunk as unknown as UIMessageChunk;\n}\n\n// ---------------------------------------------------------------------------\n// The other direction: what the client answered → eve's input responses\n// ---------------------------------------------------------------------------\n\n/** Approval answers on a continuation, as eve input responses. */\nexport function approvalResponsesFrom(\n messages: ReadonlyArray<UIMessage>\n): EveInputResponse[] {\n const last = messages[messages.length - 1];\n if (last?.role !== \"assistant\") return [];\n const responses: EveInputResponse[] = [];\n for (const raw of last.parts) {\n const part = raw as unknown as Record<string, unknown>;\n if (part.state !== \"approval-responded\") continue;\n const approval = asRecord(part.approval);\n const requestId = str(approval.id);\n if (!requestId) continue;\n const meta = asRecord(\n asRecord(asRecord(part.callProviderMetadata).eve).inputRequest\n );\n const options = Array.isArray(meta.options)\n ? (meta.options as EveInputRequest[\"options\"])\n : undefined;\n const approved = approval.approved === true;\n const optionId = optionFor(options, approved);\n responses.push(\n optionId\n ? { requestId, optionId }\n : {\n requestId,\n text: approved\n ? \"approve\"\n : `deny${typeof approval.reason === \"string\" ? `: ${approval.reason}` : \"\"}`,\n }\n );\n }\n return responses;\n}\n\n/** The user's message as eve's `message` — a string, or parts when files ride along. */\nexport function userContentFrom(message: UIMessage): unknown {\n const text = message.parts\n .filter(\n (part): part is { type: \"text\"; text: string } => part.type === \"text\"\n )\n .map((part) => part.text)\n .join(\"\\n\\n\");\n const files = message.parts.filter(\n (part): part is FileUIPart => part.type === \"file\"\n );\n if (files.length === 0) return text;\n return [\n ...(text ? [{ type: \"text\", text }] : []),\n ...files.map((file) => ({\n type: \"file\",\n data: file.url,\n mediaType: file.mediaType,\n ...(file.filename ? { filename: file.filename } : {}),\n })),\n ];\n}\n\n// ---------------------------------------------------------------------------\n// The streamTurn binding\n// ---------------------------------------------------------------------------\n\nexport type EveStreamTurnOptions = {\n /** Where eve is mounted, e.g. `https://agent.example.com` or the app's own origin. */\n baseUrl: string;\n /** A named agent (`/eve/agents/<name>/eve/v1`); the root agent when omitted. */\n agent?: string;\n /** Credentials for eve — a bearer token, a bypass header. */\n headers?: (turn: ChatTurn) => HeadersInit | Promise<HeadersInit>;\n fetch?: typeof fetch;\n};\n\nfunction routes(options: EveStreamTurnOptions) {\n const base = `${options.baseUrl.replace(/\\/$/, \"\")}${options.agent ? `/eve/agents/${options.agent}` : \"\"}/eve/v1`;\n return {\n create: `${base}/session`,\n session: (id: string) => `${base}/session/${id}`,\n stream: (id: string, from: number) =>\n `${base}/session/${id}/stream?startIndex=${from}`,\n cancel: (id: string) => `${base}/session/${id}/cancel`,\n };\n}\n\nasync function* ndjson(\n body: ReadableStream<Uint8Array>\n): AsyncGenerator<EveEvent> {\n const reader = body.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let newline = buffer.indexOf(\"\\n\");\n while (newline !== -1) {\n const line = buffer.slice(0, newline).trim();\n buffer = buffer.slice(newline + 1);\n if (line) {\n try {\n yield JSON.parse(line) as EveEvent;\n } catch {\n // A torn line is not an event.\n }\n }\n newline = buffer.indexOf(\"\\n\");\n }\n }\n const tail = buffer.trim();\n if (tail) {\n try {\n yield JSON.parse(tail) as EveEvent;\n } catch {\n // A torn tail is not an event.\n }\n }\n}\n\nexport function eveStreamTurn(options: EveStreamTurnOptions): StreamTurn {\n const doFetch = options.fetch ?? fetch;\n const url = routes(options);\n\n return async (turn: ChatTurn, prepared: PreparedTurn, context) => {\n const metadata = asRecord(turn.conversation?.metadata);\n const eve = asRecord(metadata.eve) as EveMetadata;\n const headers = {\n \"content-type\": \"application/json\",\n ...(options.headers ? await options.headers(turn) : {}),\n };\n\n // What this turn sends: an approval answer, a question's answer, or the message.\n const last = prepared.messages[prepared.messages.length - 1];\n let payload: Record<string, unknown>;\n let clearQuestion = false;\n if (last?.role === \"assistant\") {\n payload = { inputResponses: approvalResponsesFrom(prepared.messages) };\n } else if (last && eve.pendingQuestion) {\n const text = String(userContentFrom(last));\n const option = eve.pendingQuestion.options?.find(\n (o) => o.label === text || o.id === text\n );\n payload = {\n inputResponses: [\n option\n ? { requestId: eve.pendingQuestion.requestId, optionId: option.id }\n : { requestId: eve.pendingQuestion.requestId, text },\n ],\n };\n clearQuestion = true;\n } else if (last) {\n payload = { message: userContentFrom(last) };\n } else {\n payload = { message: \"\" };\n }\n\n // Open or continue the session.\n let sessionId = eve.sessionId;\n let streamIndex = eve.streamIndex ?? 0;\n const send = async (target: string) =>\n doFetch(target, {\n method: \"POST\",\n headers,\n body: JSON.stringify(payload),\n signal: context.abortSignal,\n });\n\n let response = sessionId ? await send(url.session(sessionId)) : null;\n if (\n !response ||\n response.status === 404 ||\n response.status === 409 ||\n response.status === 410\n ) {\n response = await send(url.create);\n sessionId = undefined;\n streamIndex = 0;\n }\n if (!response.ok) {\n throw new Error(`eve answered ${response.status} to the turn`);\n }\n const accepted = asRecord(await response.json().catch(() => ({})));\n // `str` answers \"\" for a missing field, which `??` would keep.\n sessionId =\n sessionId ||\n str(accepted.sessionId) ||\n response.headers.get(\"x-eve-session-id\") ||\n undefined;\n if (!sessionId) throw new Error(\"eve did not return a session id\");\n const fixedSessionId = sessionId;\n\n const mapper = createEveEventMapper();\n let settle: (\n usage: TokenUsage & { modelId?: string; finishReason?: string }\n ) => void;\n let fail: (error: unknown) => void;\n const usage = new Promise<\n TokenUsage & { modelId?: string; finishReason?: string }\n >((resolve, reject) => {\n settle = resolve;\n fail = reject;\n });\n\n const onAbort = () => {\n void doFetch(url.cancel(fixedSessionId), {\n method: \"POST\",\n headers,\n }).catch(() => {});\n };\n context.abortSignal.addEventListener(\"abort\", onAbort, { once: true });\n\n const stream = new ReadableStream<UIMessageChunk>({\n async start(controller) {\n let count = 0;\n try {\n const live = await doFetch(url.stream(fixedSessionId, streamIndex), {\n headers,\n signal: context.abortSignal,\n });\n if (!live.ok || !live.body) {\n throw new Error(`eve answered ${live.status} to the stream`);\n }\n for await (const event of ndjson(live.body)) {\n count += 1;\n for (const chunk of mapper.map(event)) controller.enqueue(chunk);\n if (mapper.state.done) break;\n }\n await turn\n .updateMetadata({\n eve: {\n sessionId: fixedSessionId,\n streamIndex: streamIndex + count,\n ...(mapper.state.pendingQuestion\n ? { pendingQuestion: mapper.state.pendingQuestion }\n : clearQuestion\n ? { pendingQuestion: null }\n : {}),\n },\n })\n .catch(() => {});\n if (mapper.state.failed) {\n fail(new Error(mapper.state.failed));\n } else {\n settle({\n ...mapper.state.usage,\n ...(mapper.state.modelId\n ? { modelId: mapper.state.modelId }\n : {}),\n ...(mapper.state.finishReason\n ? { finishReason: mapper.state.finishReason }\n : {}),\n });\n }\n controller.close();\n } catch (error) {\n if (context.abortSignal.aborted) {\n settle(mapper.state.usage);\n controller.close();\n } else {\n fail(error);\n controller.error(error);\n }\n } finally {\n context.abortSignal.removeEventListener(\"abort\", onAbort);\n }\n },\n });\n\n return { stream, usage };\n };\n}\n",
14
+ "type": "registry:lib",
15
+ "target": "lib/chat-eve.ts"
16
+ }
17
+ ],
18
+ "type": "registry:lib"
19
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "chat-panel",
4
+ "title": "Chat Panel",
5
+ "description": "The chat as a side panel on any page: a sheet that opens the same thread the chat page runs, with the page's context on every turn. Requires the chat item.",
6
+ "dependencies": [
7
+ "ai@^7.0.103",
8
+ "lucide-react",
9
+ "next-intl"
10
+ ],
11
+ "registryDependencies": [
12
+ "@intelligo/button",
13
+ "@intelligo/sheet"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "base/chat-panel/components/chat-panel.tsx",
18
+ "content": "\"use client\";\n\n/**\n * The chat as a side panel on any page — a sheet with the same thread\n * the chat page runs, opened from wherever the product puts the\n * trigger: a header button, a \"help\" affordance, a row's context menu.\n *\n * Each open conversation is a real one: a fresh id is minted when the\n * panel first opens (the row appears on the first message, like the\n * page) and kept for the page's lifetime, so closing and reopening the\n * panel continues the same thread. Pass `conversationId` to pin it to\n * an existing conversation instead, and `body` to give the agent the\n * page's context on every turn (`resolveAgent` reads it).\n *\n * Installed from the `chat-panel` item; requires the `chat` item.\n */\n\nimport { Suspense, useEffect, useState, type ReactNode } from \"react\";\nimport type { UIMessage } from \"ai\";\nimport { useTranslations } from \"next-intl\";\nimport { MessageSquareIcon } from \"lucide-react\";\n\nimport { loadConversationForChat } from \"@/actions/chat\";\nimport { ChatThread } from \"@/components/chat/chat-thread\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Sheet,\n SheetContent,\n SheetDescription,\n SheetHeader,\n SheetTitle,\n SheetTrigger,\n} from \"@/components/ui/sheet\";\nimport { cn } from \"@/lib/utils\";\n\ninterface ChatPanelProps {\n /** The trigger; a button with the item's own label when omitted. */\n trigger?: ReactNode;\n /** Continue an existing conversation rather than starting one. */\n conversationId?: string;\n /** Overrides the agent the chat surface is configured with. */\n agentId?: string;\n /** Sent with every turn — the page the reader is on, the record they are looking at. */\n body?: Record<string, unknown>;\n title?: string;\n description?: string;\n side?: \"right\" | \"left\";\n className?: string;\n}\n\nexport function ChatPanel({\n trigger,\n conversationId,\n agentId,\n body,\n title,\n description,\n side = \"right\",\n className,\n}: ChatPanelProps) {\n const t = useTranslations(\"chat-panel\");\n const [open, setOpen] = useState(false);\n const [minted] = useState(() => crypto.randomUUID());\n const id = conversationId ?? minted;\n // A pinned conversation opens on its history, loaded once per id; a\n // minted one has none. `null` while it is still loading.\n const [history, setHistory] = useState<{\n id: string;\n messages: UIMessage[];\n } | null>(null);\n\n useEffect(() => {\n if (!open || !conversationId || history?.id === conversationId) return;\n let current = true;\n void loadConversationForChat(conversationId).then((result) => {\n if (!current) return;\n setHistory({\n id: conversationId,\n messages: result.success ? result.data.messages : [],\n });\n });\n return () => {\n current = false;\n };\n }, [open, conversationId, history?.id]);\n\n const initialMessages = conversationId\n ? history?.id === conversationId\n ? history.messages\n : null\n : [];\n\n return (\n <Sheet open={open} onOpenChange={setOpen}>\n <SheetTrigger\n render={\n trigger ? (\n <span className=\"contents\" />\n ) : (\n <Button variant=\"outline\" size=\"sm\" />\n )\n }\n nativeButton={!trigger}\n >\n {trigger ?? (\n <>\n <MessageSquareIcon data-icon=\"inline-start\" />\n {t(\"trigger\")}\n </>\n )}\n </SheetTrigger>\n <SheetContent\n side={side}\n className={cn(\"flex w-full flex-col gap-0 p-0 sm:max-w-md\", className)}\n >\n <SheetHeader className=\"border-b px-4 py-3\">\n <SheetTitle>{title ?? t(\"title\")}</SheetTitle>\n <SheetDescription className={description ? undefined : \"sr-only\"}>\n {description ?? t(\"description\")}\n </SheetDescription>\n </SheetHeader>\n {open && initialMessages ? (\n <Suspense fallback={null}>\n <ChatThread\n key={id}\n conversationId={id}\n initialMessages={initialMessages}\n variant=\"panel\"\n agentId={agentId}\n body={body}\n autoFocus\n />\n </Suspense>\n ) : null}\n </SheetContent>\n </Sheet>\n );\n}\n",
19
+ "type": "registry:component",
20
+ "target": "components/chat/chat-panel.tsx"
21
+ },
22
+ {
23
+ "path": "base/chat-panel/messages/en.json",
24
+ "content": "{\n \"trigger\": \"Ask the assistant\",\n \"title\": \"Assistant\",\n \"description\": \"Chat with the assistant about this page.\"\n}\n",
25
+ "type": "registry:file",
26
+ "target": "messages/en/chat-panel.json"
27
+ }
28
+ ],
29
+ "type": "registry:block"
30
+ }
@@ -0,0 +1,42 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "chat-share",
4
+ "title": "Chat Share",
5
+ "description": "A conversation its owner published, readable by anyone with the link at /share/[id]: the same transcript, read-only, minus reasoning, tool details and attachments. Requires the chat item.",
6
+ "dependencies": [
7
+ "ai@^7.0.103",
8
+ "next-intl",
9
+ "@intelligo-dev/chat",
10
+ "@intelligo-dev/core"
11
+ ],
12
+ "registryDependencies": [
13
+ "@intelligo/button"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "base/chat-share/page.tsx",
18
+ "content": "import type { Metadata } from \"next\";\nimport { notFound } from \"next/navigation\";\nimport { getTranslations } from \"next-intl/server\";\n\nimport { SharedConversation } from \"@/components/chat/shared-conversation\";\nimport { loadSharedConversation } from \"@/actions/chat-share\";\n\n/**\n * `/share/[id]` — a conversation its owner published, readable by\n * anyone with the link. Outside the `(app)` group on purpose: no\n * session, no workspace. `noindex`, so a link that leaks does not also\n * get crawled. Per-request, since sharing can be switched off.\n */\nexport const dynamic = \"force-dynamic\";\n\ninterface SharePageProps {\n params: Promise<{ id: string; locale: string }>;\n}\n\nexport async function generateMetadata({\n params,\n}: SharePageProps): Promise<Metadata> {\n const { id } = await params;\n const t = await getTranslations(\"chat-share\");\n const shared = await loadSharedConversation(id);\n return {\n title: shared?.title ?? t(\"title\"),\n robots: { index: false, follow: false },\n };\n}\n\nexport default async function SharePage({ params }: SharePageProps) {\n const { id } = await params;\n const shared = await loadSharedConversation(id);\n if (!shared) notFound();\n\n return (\n <SharedConversation\n conversationId={shared.id}\n title={shared.title}\n messages={shared.messages}\n />\n );\n}\n",
19
+ "type": "registry:page",
20
+ "target": "app/[locale]/share/[id]/page.tsx"
21
+ },
22
+ {
23
+ "path": "base/chat-share/actions.ts",
24
+ "content": "\"use server\";\n\n/**\n * The public read of a shared conversation. No actor: the reader is\n * anyone with the link. What they get is decided by `sanitizeForShare`\n * (`@intelligo-dev/chat`) — the transcript minus reasoning, tool\n * details, provider metadata, transient parts and file URLs. A product\n * that wants a named tool's output on the page passes a policy here.\n */\n\nimport { sanitizeForShare, toUIMessages } from \"@intelligo-dev/chat\";\nimport type { UIMessage } from \"ai\";\nimport {\n getPublicConversation,\n getPublicMessages,\n isConversationServiceError,\n} from \"@intelligo-dev/core/conversations\";\n\nexport type SharedConversation = {\n id: string;\n title: string | null;\n updatedAt: string;\n messages: UIMessage[];\n};\n\nexport async function loadSharedConversation(\n id: string\n): Promise<SharedConversation | null> {\n try {\n const conversation = await getPublicConversation(id);\n const rows = await getPublicMessages(id);\n return {\n id: conversation.id,\n title: conversation.title,\n updatedAt: conversation.updatedAt.toISOString(),\n messages: sanitizeForShare(toUIMessages(rows)),\n };\n } catch (error) {\n if (isConversationServiceError(error) && error.code === \"not_found\") {\n return null;\n }\n throw error;\n }\n}\n",
25
+ "type": "registry:file",
26
+ "target": "actions/chat-share.ts"
27
+ },
28
+ {
29
+ "path": "base/chat-share/components/shared-conversation.tsx",
30
+ "content": "\"use client\";\n\n/**\n * A shared conversation, read-only: the same message component the\n * chat page renders, with no composer, no actions and no edit. The\n * banner says what this is and offers the product.\n */\n\nimport { useTranslations } from \"next-intl\";\nimport type { UIMessage } from \"ai\";\n\nimport { MessageList } from \"@/components/chat/message-list\";\nimport { Button } from \"@/components/ui/button\";\nimport { Link } from \"@/i18n/navigation\";\n\ninterface SharedConversationProps {\n conversationId: string;\n title: string | null;\n messages: UIMessage[];\n}\n\nexport function SharedConversation({\n conversationId,\n title,\n messages,\n}: SharedConversationProps) {\n const t = useTranslations(\"chat-share\");\n\n return (\n <div className=\"flex h-dvh flex-col\">\n <header className=\"flex items-center justify-between gap-3 border-b px-4 py-3\">\n <div className=\"min-w-0\">\n <p className=\"text-xs text-muted-foreground\">{t(\"readOnly\")}</p>\n <h1 className=\"truncate text-sm font-medium\">\n {title ?? t(\"untitled\")}\n </h1>\n </div>\n <Button size=\"sm\" render={<Link href=\"/chat\" />} nativeButton={false}>\n {t(\"openApp\")}\n </Button>\n </header>\n <MessageList\n conversationId={conversationId}\n messages={messages}\n isStreaming={false}\n readOnly\n />\n </div>\n );\n}\n",
31
+ "type": "registry:component",
32
+ "target": "components/chat/shared-conversation.tsx"
33
+ },
34
+ {
35
+ "path": "base/chat-share/messages/en.json",
36
+ "content": "{\n \"title\": \"Shared conversation\",\n \"readOnly\": \"Shared conversation · read-only\",\n \"untitled\": \"Untitled conversation\",\n \"openApp\": \"Start your own\"\n}\n",
37
+ "type": "registry:file",
38
+ "target": "messages/en/chat-share.json"
39
+ }
40
+ ],
41
+ "type": "registry:block"
42
+ }