@intelligo-dev/cli 1.0.0-beta.13 → 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 (171) hide show
  1. package/README.md +41 -2
  2. package/dist/bin.js +37 -0
  3. package/dist/bin.js.map +1 -1
  4. package/dist/commands/add.d.ts +4 -0
  5. package/dist/commands/add.d.ts.map +1 -1
  6. package/dist/commands/add.js +9 -0
  7. package/dist/commands/add.js.map +1 -1
  8. package/dist/commands/create-flow.d.ts +4 -1
  9. package/dist/commands/create-flow.d.ts.map +1 -1
  10. package/dist/commands/create-flow.js +63 -31
  11. package/dist/commands/create-flow.js.map +1 -1
  12. package/dist/commands/create.d.ts +8 -4
  13. package/dist/commands/create.d.ts.map +1 -1
  14. package/dist/commands/create.js +15 -7
  15. package/dist/commands/create.js.map +1 -1
  16. package/dist/commands/doctor.d.ts.map +1 -1
  17. package/dist/commands/doctor.js +21 -19
  18. package/dist/commands/doctor.js.map +1 -1
  19. package/dist/commands/migrate-check.d.ts +20 -1
  20. package/dist/commands/migrate-check.d.ts.map +1 -1
  21. package/dist/commands/migrate-check.js +35 -6
  22. package/dist/commands/migrate-check.js.map +1 -1
  23. package/dist/commands/migrate.d.ts.map +1 -1
  24. package/dist/commands/migrate.js +2 -8
  25. package/dist/commands/migrate.js.map +1 -1
  26. package/dist/commands/sync-messages.d.ts +37 -0
  27. package/dist/commands/sync-messages.d.ts.map +1 -0
  28. package/dist/commands/sync-messages.js +88 -0
  29. package/dist/commands/sync-messages.js.map +1 -0
  30. package/dist/commands/sync-scaffold.d.ts +36 -0
  31. package/dist/commands/sync-scaffold.d.ts.map +1 -0
  32. package/dist/commands/sync-scaffold.js +53 -0
  33. package/dist/commands/sync-scaffold.js.map +1 -0
  34. package/dist/commands/sync.d.ts +103 -0
  35. package/dist/commands/sync.d.ts.map +1 -0
  36. package/dist/commands/sync.js +361 -0
  37. package/dist/commands/sync.js.map +1 -0
  38. package/dist/commands/upgrade-check.d.ts.map +1 -1
  39. package/dist/commands/upgrade-check.js +4 -1
  40. package/dist/commands/upgrade-check.js.map +1 -1
  41. package/dist/manifest.d.ts +27 -0
  42. package/dist/manifest.d.ts.map +1 -1
  43. package/dist/manifest.js +30 -3
  44. package/dist/manifest.js.map +1 -1
  45. package/dist/module-exports.d.ts +63 -0
  46. package/dist/module-exports.d.ts.map +1 -0
  47. package/dist/module-exports.js +231 -0
  48. package/dist/module-exports.js.map +1 -0
  49. package/dist/registry-bundle.d.ts +43 -0
  50. package/dist/registry-bundle.d.ts.map +1 -0
  51. package/dist/registry-bundle.js +74 -0
  52. package/dist/registry-bundle.js.map +1 -0
  53. package/dist/registry-items.d.ts +41 -13
  54. package/dist/registry-items.d.ts.map +1 -1
  55. package/dist/registry-items.js +117 -33
  56. package/dist/registry-items.js.map +1 -1
  57. package/package.json +1 -1
  58. package/src/bin.ts +52 -0
  59. package/src/commands/add.ts +11 -0
  60. package/src/commands/create-flow.ts +82 -26
  61. package/src/commands/create.ts +17 -7
  62. package/src/commands/doctor.ts +27 -27
  63. package/src/commands/migrate-check.ts +51 -10
  64. package/src/commands/migrate.ts +2 -8
  65. package/src/commands/sync-messages.ts +114 -0
  66. package/src/commands/sync-scaffold.ts +83 -0
  67. package/src/commands/sync.ts +534 -0
  68. package/src/commands/upgrade-check.ts +4 -1
  69. package/src/manifest.ts +57 -3
  70. package/src/module-exports.ts +266 -0
  71. package/src/registry-bundle.ts +103 -0
  72. package/src/registry-items.ts +141 -36
  73. package/templates/app-scaffold/intelligo.ts.tpl +6 -47
  74. package/templates/manifest.json +19 -0
  75. package/templates/registry/ai-agent-activity.json +23 -0
  76. package/templates/registry/ai-agent-progress.json +21 -0
  77. package/templates/registry/ai-approval-card.json +27 -0
  78. package/templates/registry/ai-artifact.json +22 -0
  79. package/templates/registry/ai-branch.json +21 -0
  80. package/templates/registry/ai-citations.json +24 -0
  81. package/templates/registry/ai-code-block.json +23 -0
  82. package/templates/registry/ai-composer-menu.json +19 -0
  83. package/templates/registry/ai-file-diff.json +25 -0
  84. package/templates/registry/ai-image-generation.json +23 -0
  85. package/templates/registry/ai-markdown.json +23 -0
  86. package/templates/registry/ai-message-bubble.json +23 -0
  87. package/templates/registry/ai-message-scroller.json +22 -0
  88. package/templates/registry/ai-message.json +21 -0
  89. package/templates/registry/ai-motion.json +19 -0
  90. package/templates/registry/ai-prompt-input.json +28 -0
  91. package/templates/registry/ai-reasoning-text.json +22 -0
  92. package/templates/registry/ai-reasoning.json +22 -0
  93. package/templates/registry/ai-shimmer-text.json +19 -0
  94. package/templates/registry/ai-sidebar.json +25 -0
  95. package/templates/registry/ai-speech-input.json +21 -0
  96. package/templates/registry/ai-streaming-response.json +24 -0
  97. package/templates/registry/ai-suggestion.json +19 -0
  98. package/templates/registry/ai-todo-list.json +22 -0
  99. package/templates/registry/ai-tool-approval.json +26 -0
  100. package/templates/registry/ai-tool-result.json +25 -0
  101. package/templates/registry/alert-dialog.json +23 -0
  102. package/templates/registry/animated-list.json +21 -0
  103. package/templates/registry/app-shell.json +93 -0
  104. package/templates/registry/artifacts.json +82 -0
  105. package/templates/registry/attachment.json +22 -0
  106. package/templates/registry/auth-email-verification.json +39 -0
  107. package/templates/registry/auth-login.json +72 -0
  108. package/templates/registry/auth-password-reset.json +53 -0
  109. package/templates/registry/auth-signup.json +41 -0
  110. package/templates/registry/billing-settings.json +60 -0
  111. package/templates/registry/button.json +22 -0
  112. package/templates/registry/chat-eve.json +19 -0
  113. package/templates/registry/chat-panel.json +30 -0
  114. package/templates/registry/chat-share.json +42 -0
  115. package/templates/registry/chat-widget.json +34 -0
  116. package/templates/registry/chat.json +326 -0
  117. package/templates/registry/checkbox.json +22 -0
  118. package/templates/registry/checkout.json +40 -0
  119. package/templates/registry/collapsible.json +19 -0
  120. package/templates/registry/command.json +23 -0
  121. package/templates/registry/copy-button.json +21 -0
  122. package/templates/registry/dashboard.json +69 -0
  123. package/templates/registry/dialog.json +24 -0
  124. package/templates/registry/document-viewer.json +22 -0
  125. package/templates/registry/dropdown-menu.json +23 -0
  126. package/templates/registry/expandable-tabs.json +22 -0
  127. package/templates/registry/feature-gating.json +73 -0
  128. package/templates/registry/file-upload.json +24 -0
  129. package/templates/registry/hold-action-button.json +22 -0
  130. package/templates/registry/input-group.json +23 -0
  131. package/templates/registry/input.json +19 -0
  132. package/templates/registry/intelligo.json +187 -0
  133. package/templates/registry/invitation-accept.json +64 -0
  134. package/templates/registry/language-switcher.json +29 -0
  135. package/templates/registry/morphing-modal.json +24 -0
  136. package/templates/registry/notification-stack.json +24 -0
  137. package/templates/registry/notifications.json +87 -0
  138. package/templates/registry/onboarding.json +83 -0
  139. package/templates/registry/otp-input.json +22 -0
  140. package/templates/registry/page-header.json +15 -0
  141. package/templates/registry/payment-poll.json +52 -0
  142. package/templates/registry/popover-morph.json +22 -0
  143. package/templates/registry/popover.json +22 -0
  144. package/templates/registry/pricing.json +111 -0
  145. package/templates/registry/privacy-settings.json +65 -0
  146. package/templates/registry/profile-settings.json +68 -0
  147. package/templates/registry/progress.json +19 -0
  148. package/templates/registry/radio-group.json +22 -0
  149. package/templates/registry/registry.json +2945 -0
  150. package/templates/registry/route-error.json +65 -0
  151. package/templates/registry/select-morph.json +23 -0
  152. package/templates/registry/select.json +23 -0
  153. package/templates/registry/settings-shell.json +47 -0
  154. package/templates/registry/sheet.json +24 -0
  155. package/templates/registry/sidebar.json +28 -0
  156. package/templates/registry/smoke.json +34 -0
  157. package/templates/registry/spinner.json +16 -0
  158. package/templates/registry/stat-card.json +18 -0
  159. package/templates/registry/status-badge.json +18 -0
  160. package/templates/registry/switch.json +20 -0
  161. package/templates/registry/tabs.json +23 -0
  162. package/templates/registry/team-settings.json +97 -0
  163. package/templates/registry/textarea.json +16 -0
  164. package/templates/registry/tooltip.json +22 -0
  165. package/templates/registry/trial-banner.json +43 -0
  166. package/templates/registry/usage.json +84 -0
  167. package/templates/registry/workspace-settings.json +71 -0
  168. package/templates/registry-items.json +1 -1
  169. package/templates/registry-requires.json +26 -2
  170. package/templates/vitest/server-only.ts.tpl +7 -0
  171. package/templates/vitest/vitest.config.ts.tpl +37 -0
@@ -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
+ }
@@ -0,0 +1,34 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "chat-widget",
4
+ "title": "Chat Widget",
5
+ "description": "The floating assistant: a launcher in a corner of every page and a compact chat over it, configured in lib/chat-widget-config.tsx. Requires the chat item.",
6
+ "dependencies": [
7
+ "lucide-react",
8
+ "next-intl"
9
+ ],
10
+ "registryDependencies": [
11
+ "@intelligo/button"
12
+ ],
13
+ "files": [
14
+ {
15
+ "path": "base/chat-widget/components/chat-widget.tsx",
16
+ "content": "\"use client\";\n\n/**\n * The floating assistant: a launcher in a corner of every page and a\n * compact chat that opens over the page — the same thread the chat\n * page runs, in the widget variant. Mounted once in the app layout;\n * `lib/chat-widget-config.tsx` says where it sits, which agent it\n * runs as, and which routes hide it.\n *\n * Each open conversation is a real one, minted when the widget first\n * opens and kept for the page's lifetime. `body` on the component adds\n * per-page context to every turn; `chatWidgetConfig.body` adds the\n * product's.\n *\n * Installed from the `chat-widget` item; requires the `chat` item.\n */\n\nimport { Suspense, useState } from \"react\";\nimport { useTranslations } from \"next-intl\";\nimport { MessageSquareIcon, XIcon } from \"lucide-react\";\n\nimport { ChatThread } from \"@/components/chat/chat-thread\";\nimport { Button } from \"@/components/ui/button\";\nimport { usePathname } from \"@/i18n/navigation\";\nimport { chatWidgetConfig } from \"@/lib/chat-widget-config\";\nimport { cn } from \"@/lib/utils\";\n\ninterface ChatWidgetProps {\n /** Per-page context, merged over `chatWidgetConfig.body`. */\n body?: Record<string, unknown>;\n className?: string;\n}\n\nexport function ChatWidget({ body, className }: ChatWidgetProps) {\n const t = useTranslations(\"chat-widget\");\n const pathname = usePathname();\n const [open, setOpen] = useState(false);\n const [id] = useState(() => crypto.randomUUID());\n\n // Segment-aware: hiding on `/chat` must not hide it on `/chatbots`.\n const hidden = (chatWidgetConfig.hideOn ?? []).some(\n (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)\n );\n if (hidden) return null;\n\n const corner =\n chatWidgetConfig.position === \"bottom-left\"\n ? \"sm:right-auto sm:left-4\"\n : \"sm:left-auto sm:right-4\";\n const name = chatWidgetConfig.agent?.name ?? t(\"title\");\n\n return (\n <div\n className={cn(\n \"fixed inset-x-4 bottom-4 z-50 flex flex-col items-end gap-3\",\n corner,\n className\n )}\n >\n {open ? (\n <section\n role=\"dialog\"\n aria-label={name}\n className=\"flex h-128 max-h-dvh w-full flex-col overflow-hidden rounded-xl border bg-background shadow-lg sm:w-96\"\n >\n <header className=\"flex items-center justify-between gap-2 border-b px-3 py-2\">\n <span className=\"flex min-w-0 items-center gap-2 text-sm font-medium\">\n {chatWidgetConfig.agent?.icon ? (\n <span aria-hidden>{chatWidgetConfig.agent.icon}</span>\n ) : null}\n <span className=\"truncate\">{name}</span>\n </span>\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={t(\"close\")}\n onClick={() => setOpen(false)}\n >\n <XIcon />\n </Button>\n </header>\n <Suspense fallback={null}>\n <ChatThread\n conversationId={id}\n initialMessages={[]}\n variant=\"widget\"\n agentId={chatWidgetConfig.agent?.id}\n body={{ ...chatWidgetConfig.body, ...body }}\n autoFocus\n />\n </Suspense>\n </section>\n ) : null}\n <Button\n size=\"lg\"\n className=\"rounded-full shadow-lg\"\n aria-expanded={open}\n aria-label={open ? t(\"close\") : t(\"trigger\")}\n onClick={() => setOpen((value) => !value)}\n >\n {open ? <XIcon /> : <MessageSquareIcon />}\n <span className={open ? \"sr-only\" : undefined}>{t(\"trigger\")}</span>\n </Button>\n </div>\n );\n}\n",
17
+ "type": "registry:component",
18
+ "target": "components/chat/chat-widget.tsx"
19
+ },
20
+ {
21
+ "path": "base/chat-widget/lib/chat-widget-config.tsx",
22
+ "content": "/**\n * Chat widget config — the consumer-owned seam for the floating\n * assistant (composition through a config you own, never a\n * component edit).\n *\n * - `position`: which corner the launcher sits in.\n * - `agent`: which agent the widget's conversations run as, when it\n * differs from the chat page's (`chatConfig.agent`).\n * - `body`: sent with every turn — a static product context. For\n * per-page context pass `body` to `<ChatWidget>` where it mounts.\n * - `hideOn`: pathname prefixes where the launcher is not shown (the\n * chat page itself, auth pages).\n *\n * Mount the widget once, in your app layout:\n *\n * import { ChatWidget } from \"@/components/chat/chat-widget\";\n * …\n * <ChatWidget />\n */\n\nexport interface ChatWidgetConfig {\n position?: \"bottom-right\" | \"bottom-left\";\n agent?: { id?: string; name?: string; icon?: string };\n body?: Record<string, unknown>;\n hideOn?: string[];\n}\n\nexport const chatWidgetConfig: ChatWidgetConfig = {\n position: \"bottom-right\",\n hideOn: [\"/chat\", \"/login\", \"/signup\"],\n};\n",
23
+ "type": "registry:file",
24
+ "target": "lib/chat-widget-config.tsx"
25
+ },
26
+ {
27
+ "path": "base/chat-widget/messages/en.json",
28
+ "content": "{\n \"trigger\": \"Chat\",\n \"title\": \"Assistant\",\n \"close\": \"Close chat\"\n}\n",
29
+ "type": "registry:file",
30
+ "target": "messages/en/chat-widget.json"
31
+ }
32
+ ],
33
+ "type": "registry:block"
34
+ }