@intelligo-dev/cli 1.0.0-beta.13 → 1.0.0-beta.15

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 (188) hide show
  1. package/README.md +65 -2
  2. package/dist/args.d.ts +10 -0
  3. package/dist/args.d.ts.map +1 -0
  4. package/dist/args.js +20 -0
  5. package/dist/args.js.map +1 -0
  6. package/dist/bin.js +103 -22
  7. package/dist/bin.js.map +1 -1
  8. package/dist/commands/add.d.ts +4 -0
  9. package/dist/commands/add.d.ts.map +1 -1
  10. package/dist/commands/add.js +28 -2
  11. package/dist/commands/add.js.map +1 -1
  12. package/dist/commands/create-flow.d.ts +8 -1
  13. package/dist/commands/create-flow.d.ts.map +1 -1
  14. package/dist/commands/create-flow.js +101 -34
  15. package/dist/commands/create-flow.js.map +1 -1
  16. package/dist/commands/create.d.ts +24 -4
  17. package/dist/commands/create.d.ts.map +1 -1
  18. package/dist/commands/create.js +57 -14
  19. package/dist/commands/create.js.map +1 -1
  20. package/dist/commands/doctor.d.ts +2 -0
  21. package/dist/commands/doctor.d.ts.map +1 -1
  22. package/dist/commands/doctor.js +123 -35
  23. package/dist/commands/doctor.js.map +1 -1
  24. package/dist/commands/migrate-check.d.ts +20 -1
  25. package/dist/commands/migrate-check.d.ts.map +1 -1
  26. package/dist/commands/migrate-check.js +35 -6
  27. package/dist/commands/migrate-check.js.map +1 -1
  28. package/dist/commands/migrate.d.ts.map +1 -1
  29. package/dist/commands/migrate.js +2 -8
  30. package/dist/commands/migrate.js.map +1 -1
  31. package/dist/commands/sync-messages.d.ts +37 -0
  32. package/dist/commands/sync-messages.d.ts.map +1 -0
  33. package/dist/commands/sync-messages.js +88 -0
  34. package/dist/commands/sync-messages.js.map +1 -0
  35. package/dist/commands/sync-scaffold.d.ts +36 -0
  36. package/dist/commands/sync-scaffold.d.ts.map +1 -0
  37. package/dist/commands/sync-scaffold.js +53 -0
  38. package/dist/commands/sync-scaffold.js.map +1 -0
  39. package/dist/commands/sync.d.ts +109 -0
  40. package/dist/commands/sync.d.ts.map +1 -0
  41. package/dist/commands/sync.js +392 -0
  42. package/dist/commands/sync.js.map +1 -0
  43. package/dist/commands/upgrade-check.d.ts.map +1 -1
  44. package/dist/commands/upgrade-check.js +4 -1
  45. package/dist/commands/upgrade-check.js.map +1 -1
  46. package/dist/env-files.d.ts +11 -0
  47. package/dist/env-files.d.ts.map +1 -1
  48. package/dist/env-files.js +28 -9
  49. package/dist/env-files.js.map +1 -1
  50. package/dist/manifest.d.ts +27 -0
  51. package/dist/manifest.d.ts.map +1 -1
  52. package/dist/manifest.js +30 -3
  53. package/dist/manifest.js.map +1 -1
  54. package/dist/module-exports.d.ts +63 -0
  55. package/dist/module-exports.d.ts.map +1 -0
  56. package/dist/module-exports.js +231 -0
  57. package/dist/module-exports.js.map +1 -0
  58. package/dist/registry-bundle.d.ts +43 -0
  59. package/dist/registry-bundle.d.ts.map +1 -0
  60. package/dist/registry-bundle.js +74 -0
  61. package/dist/registry-bundle.js.map +1 -0
  62. package/dist/registry-items.d.ts +41 -13
  63. package/dist/registry-items.d.ts.map +1 -1
  64. package/dist/registry-items.js +117 -33
  65. package/dist/registry-items.js.map +1 -1
  66. package/package.json +1 -1
  67. package/src/args.ts +25 -0
  68. package/src/bin.ts +124 -22
  69. package/src/commands/add.ts +39 -12
  70. package/src/commands/create-flow.ts +119 -29
  71. package/src/commands/create.ts +82 -14
  72. package/src/commands/doctor.ts +156 -47
  73. package/src/commands/migrate-check.ts +51 -10
  74. package/src/commands/migrate.ts +2 -8
  75. package/src/commands/sync-messages.ts +114 -0
  76. package/src/commands/sync-scaffold.ts +83 -0
  77. package/src/commands/sync.ts +574 -0
  78. package/src/commands/upgrade-check.ts +4 -1
  79. package/src/env-files.ts +28 -7
  80. package/src/manifest.ts +57 -3
  81. package/src/module-exports.ts +266 -0
  82. package/src/registry-bundle.ts +103 -0
  83. package/src/registry-items.ts +141 -36
  84. package/templates/admin-page/admin-page.tsx.tpl +96 -24
  85. package/templates/app-scaffold/gitignore.tpl +25 -0
  86. package/templates/app-scaffold/intelligo.ts.tpl +6 -47
  87. package/templates/app-scaffold/next.config.mjs.tpl +31 -2
  88. package/templates/app-scaffold/package.json.tpl +2 -2
  89. package/templates/manifest.json +40 -3
  90. package/templates/pnpm-standalone/npmrc.tpl +6 -0
  91. package/templates/pnpm-standalone/pnpm-workspace.yaml.tpl +10 -0
  92. package/templates/registry/ai-agent-activity.json +23 -0
  93. package/templates/registry/ai-agent-progress.json +21 -0
  94. package/templates/registry/ai-approval-card.json +27 -0
  95. package/templates/registry/ai-artifact.json +22 -0
  96. package/templates/registry/ai-branch.json +21 -0
  97. package/templates/registry/ai-citations.json +24 -0
  98. package/templates/registry/ai-code-block.json +23 -0
  99. package/templates/registry/ai-composer-menu.json +19 -0
  100. package/templates/registry/ai-file-diff.json +25 -0
  101. package/templates/registry/ai-image-generation.json +23 -0
  102. package/templates/registry/ai-markdown.json +23 -0
  103. package/templates/registry/ai-message-bubble.json +23 -0
  104. package/templates/registry/ai-message-scroller.json +22 -0
  105. package/templates/registry/ai-message.json +21 -0
  106. package/templates/registry/ai-motion.json +19 -0
  107. package/templates/registry/ai-prompt-input.json +28 -0
  108. package/templates/registry/ai-reasoning-text.json +22 -0
  109. package/templates/registry/ai-reasoning.json +22 -0
  110. package/templates/registry/ai-shimmer-text.json +19 -0
  111. package/templates/registry/ai-sidebar.json +25 -0
  112. package/templates/registry/ai-speech-input.json +21 -0
  113. package/templates/registry/ai-streaming-response.json +24 -0
  114. package/templates/registry/ai-suggestion.json +19 -0
  115. package/templates/registry/ai-todo-list.json +22 -0
  116. package/templates/registry/ai-tool-approval.json +26 -0
  117. package/templates/registry/ai-tool-result.json +25 -0
  118. package/templates/registry/alert-dialog.json +23 -0
  119. package/templates/registry/animated-list.json +21 -0
  120. package/templates/registry/app-shell.json +93 -0
  121. package/templates/registry/artifacts.json +82 -0
  122. package/templates/registry/attachment.json +22 -0
  123. package/templates/registry/auth-email-verification.json +39 -0
  124. package/templates/registry/auth-login.json +72 -0
  125. package/templates/registry/auth-password-reset.json +53 -0
  126. package/templates/registry/auth-signup.json +41 -0
  127. package/templates/registry/billing-settings.json +66 -0
  128. package/templates/registry/button.json +22 -0
  129. package/templates/registry/chat-eve.json +19 -0
  130. package/templates/registry/chat-panel.json +30 -0
  131. package/templates/registry/chat-share.json +42 -0
  132. package/templates/registry/chat-widget.json +34 -0
  133. package/templates/registry/chat.json +326 -0
  134. package/templates/registry/checkbox.json +22 -0
  135. package/templates/registry/checkout.json +40 -0
  136. package/templates/registry/collapsible.json +19 -0
  137. package/templates/registry/command.json +23 -0
  138. package/templates/registry/copy-button.json +21 -0
  139. package/templates/registry/dashboard.json +69 -0
  140. package/templates/registry/dialog.json +24 -0
  141. package/templates/registry/document-viewer.json +22 -0
  142. package/templates/registry/dropdown-menu.json +23 -0
  143. package/templates/registry/expandable-tabs.json +22 -0
  144. package/templates/registry/feature-gating.json +73 -0
  145. package/templates/registry/file-upload.json +24 -0
  146. package/templates/registry/hold-action-button.json +22 -0
  147. package/templates/registry/input-group.json +23 -0
  148. package/templates/registry/input.json +19 -0
  149. package/templates/registry/intelligo.json +187 -0
  150. package/templates/registry/invitation-accept.json +64 -0
  151. package/templates/registry/language-switcher.json +29 -0
  152. package/templates/registry/morphing-modal.json +24 -0
  153. package/templates/registry/notification-stack.json +24 -0
  154. package/templates/registry/notifications.json +87 -0
  155. package/templates/registry/onboarding.json +83 -0
  156. package/templates/registry/otp-input.json +22 -0
  157. package/templates/registry/page-header.json +15 -0
  158. package/templates/registry/payment-poll.json +58 -0
  159. package/templates/registry/popover-morph.json +22 -0
  160. package/templates/registry/popover.json +22 -0
  161. package/templates/registry/pricing.json +117 -0
  162. package/templates/registry/privacy-settings.json +65 -0
  163. package/templates/registry/profile-settings.json +68 -0
  164. package/templates/registry/progress.json +19 -0
  165. package/templates/registry/radio-group.json +22 -0
  166. package/templates/registry/registry.json +2960 -0
  167. package/templates/registry/route-error.json +65 -0
  168. package/templates/registry/select-morph.json +23 -0
  169. package/templates/registry/select.json +23 -0
  170. package/templates/registry/settings-shell.json +47 -0
  171. package/templates/registry/sheet.json +24 -0
  172. package/templates/registry/sidebar.json +28 -0
  173. package/templates/registry/smoke.json +34 -0
  174. package/templates/registry/spinner.json +16 -0
  175. package/templates/registry/stat-card.json +18 -0
  176. package/templates/registry/status-badge.json +18 -0
  177. package/templates/registry/switch.json +20 -0
  178. package/templates/registry/tabs.json +23 -0
  179. package/templates/registry/team-settings.json +97 -0
  180. package/templates/registry/textarea.json +16 -0
  181. package/templates/registry/tooltip.json +22 -0
  182. package/templates/registry/trial-banner.json +43 -0
  183. package/templates/registry/usage.json +84 -0
  184. package/templates/registry/workspace-settings.json +71 -0
  185. package/templates/registry-items.json +3 -3
  186. package/templates/registry-requires.json +30 -4
  187. package/templates/vitest/server-only.ts.tpl +7 -0
  188. package/templates/vitest/vitest.config.ts.tpl +37 -0
@@ -0,0 +1,97 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "team-settings",
4
+ "title": "Team Settings",
5
+ "description": "Workspace team management: invite members, change roles, remove members, and manage pending invitations, backed by the framework's team service.",
6
+ "dependencies": [
7
+ "@intelligo-dev/auth",
8
+ "@intelligo-dev/core",
9
+ "@intelligo-dev/billing",
10
+ "@intelligo-dev/next",
11
+ "sonner",
12
+ "server-only",
13
+ "next-intl"
14
+ ],
15
+ "registryDependencies": [
16
+ "@intelligo/alert-dialog",
17
+ "badge",
18
+ "@intelligo/button",
19
+ "card",
20
+ "@intelligo/dialog",
21
+ "@intelligo/input",
22
+ "label",
23
+ "@intelligo/select",
24
+ "skeleton",
25
+ "table",
26
+ "@intelligo/page-header"
27
+ ],
28
+ "files": [
29
+ {
30
+ "path": "base/team-settings/page.tsx",
31
+ "content": "/**\n * Loads the caller's workspace membership, then renders an invite\n * form (owner/admin only), the member list, and pending invitations.\n * A \"member\" role gets a read-only view: no invite form, no pending\n * invitations, no role/remove controls. Only an owner can make another\n * member an owner; every role can leave.\n */\n\nimport { getTranslations } from \"next-intl/server\";\n\nimport { requireWorkspace } from \"@intelligo-dev/auth\";\n\nimport { team } from \"@/lib/team\";\nimport { InviteMemberForm } from \"@/components/team/invite-member-form\";\nimport { LeaveWorkspace } from \"@/components/team/leave-workspace\";\nimport { MemberList } from \"@/components/team/member-list\";\nimport { PendingInvitations } from \"@/components/team/pending-invitations\";\nimport {\n PageHeader,\n PageHeaderContent,\n PageHeaderDescription,\n PageHeaderTitle,\n} from \"@/components/ui/page-header\";\n\nexport default async function TeamSettingsPage() {\n const t = await getTranslations(\"team-settings\");\n const { user, workspace, membership } = await requireWorkspace();\n const canManage = [\"owner\", \"admin\"].includes(membership.role);\n\n const [members, allInvitations] = await Promise.all([\n team.listMembers(),\n canManage ? team.listInvitations() : Promise.resolve([]),\n ]);\n // The organization keeps accepted, rejected and canceled invitations too.\n const invitations = allInvitations.filter(\n (invitation) => invitation.status === \"pending\"\n );\n\n return (\n <div className=\"space-y-8\">\n <PageHeader>\n <PageHeaderContent>\n <PageHeaderTitle level={2}>{t(\"page.title\")}</PageHeaderTitle>\n <PageHeaderDescription>\n {canManage\n ? t(\"page.descriptionManage\", { workspace: workspace.name })\n : t(\"page.descriptionReadOnly\", { workspace: workspace.name })}\n </PageHeaderDescription>\n </PageHeaderContent>\n </PageHeader>\n\n {canManage && <InviteMemberForm />}\n\n <MemberList\n members={members}\n currentUserId={user.id}\n canManage={canManage}\n canTransfer={membership.role === \"owner\"}\n />\n\n {canManage && <PendingInvitations invitations={invitations} />}\n\n <section className=\"border-t pt-8\">\n <LeaveWorkspace workspaceName={workspace.name} />\n </section>\n </div>\n );\n}\n",
32
+ "type": "registry:page",
33
+ "target": "app/[locale]/(app)/settings/team/page.tsx"
34
+ },
35
+ {
36
+ "path": "base/team-settings/loading.tsx",
37
+ "content": "import { Card } from \"@/components/ui/card\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\n\nexport default function TeamSettingsLoading() {\n return (\n <div className=\"space-y-8\">\n <div>\n <Skeleton className=\"h-7 w-40\" />\n <Skeleton className=\"mt-2 h-4 w-64\" />\n </div>\n\n <Card className=\"p-6\">\n <div className=\"space-y-4\">\n <Skeleton className=\"h-6 w-48\" />\n <div className=\"flex gap-2\">\n <Skeleton className=\"h-10 flex-1\" />\n <Skeleton className=\"h-10 w-32\" />\n </div>\n </div>\n </Card>\n\n <div className=\"space-y-4\">\n <Skeleton className=\"h-5 w-32\" />\n <div className=\"rounded-md border p-4\">\n {[1, 2, 3].map((row) => (\n <div key={row} className=\"flex items-center gap-4 py-2\">\n <div className=\"flex-1 space-y-2\">\n <Skeleton className=\"h-4 w-48\" />\n <Skeleton className=\"h-3 w-64\" />\n </div>\n <Skeleton className=\"h-6 w-16\" />\n <Skeleton className=\"size-8\" />\n </div>\n ))}\n </div>\n </div>\n </div>\n );\n}\n",
38
+ "type": "registry:page",
39
+ "target": "app/[locale]/(app)/settings/team/loading.tsx"
40
+ },
41
+ {
42
+ "path": "base/team-settings/components/member-list.tsx",
43
+ "content": "\"use client\";\n\n/**\n * Member list — table of workspace members with role changes and\n * removal. Owner/admin only for the management controls; anyone else\n * sees a read-only table. An owner can also make another member an\n * owner, behind a confirmation.\n */\n\nimport { useState, useTransition } from \"react\";\nimport { useFormatter, useTranslations } from \"next-intl\";\nimport { toast } from \"sonner\";\n\nimport type { OrgMember } from \"@intelligo-dev/auth\";\n\nimport {\n removeMember,\n transferOwnership,\n updateMemberRole,\n} from \"@/actions/team\";\nimport { RoleBadge } from \"./role-badge\";\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from \"@/components/ui/table\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n DialogTrigger,\n} from \"@/components/ui/dialog\";\n\n/**\n * `getFullOrganization()` returns a richer member record than the\n * `OrgMember` type declared in `@intelligo-dev/auth` (documented there as\n * the \"minimal\" shape: `id`, `userId`, `role`) — Better-Auth also\n * joins the `user` record and a `createdAt` timestamp onto each\n * member. Both are declared optional here so the table still renders\n * (falling back to the member id) if a future version omits them.\n */\nexport type TeamMember = OrgMember & {\n user?: { name?: string | null; email?: string | null } | null;\n createdAt?: string | Date;\n};\n\ninterface MemberListProps {\n members: TeamMember[];\n currentUserId: string;\n canManage: boolean;\n /** Owners only: shows \"Make owner\" on every other non-owner member. */\n canTransfer?: boolean;\n}\n\nexport function MemberList({\n members,\n currentUserId,\n canManage,\n canTransfer = false,\n}: MemberListProps) {\n const t = useTranslations(\"team-settings\");\n // The labels SelectValue shows; Base UI renders the raw value without them.\n const roleItems = [\n { value: \"member\", label: t(\"roles.member\") },\n { value: \"admin\", label: t(\"roles.admin\") },\n ];\n const format = useFormatter();\n const [isPending, startTransition] = useTransition();\n const [busyId, setBusyId] = useState<string | null>(null);\n // The target outlives `transferOpen` so the name stays while the dialog exits.\n const [transferTarget, setTransferTarget] = useState<{\n id: string;\n name: string;\n } | null>(null);\n const [transferOpen, setTransferOpen] = useState(false);\n const isTransferring =\n isPending && transferTarget !== null && busyId === transferTarget.id;\n\n function handleRoleChange(memberId: string, role: string) {\n setBusyId(memberId);\n startTransition(async () => {\n const result = await updateMemberRole({\n memberId,\n role: role as \"admin\" | \"member\",\n });\n if (!result.success) {\n toast.error(result.error);\n } else {\n toast.success(t(\"memberList.roleUpdated\"));\n }\n setBusyId(null);\n });\n }\n\n function handleRemove(memberId: string, name: string) {\n setBusyId(memberId);\n startTransition(async () => {\n const result = await removeMember(memberId);\n if (!result.success) {\n toast.error(result.error);\n } else {\n toast.success(t(\"memberList.memberRemoved\", { name }));\n }\n setBusyId(null);\n });\n }\n\n function handleTransfer() {\n if (!transferTarget) return;\n const { id, name } = transferTarget;\n setBusyId(id);\n startTransition(async () => {\n const result = await transferOwnership(id);\n if (!result.success) {\n toast.error(result.error);\n } else {\n toast.success(t(\"memberList.ownershipTransferred\", { name }));\n }\n setBusyId(null);\n setTransferOpen(false);\n });\n }\n\n return (\n <div>\n <h2 className=\"mb-3 text-sm font-semibold\">\n {t(\"memberList.heading\", { count: members.length })}\n </h2>\n\n {members.length === 0 ? (\n <div className=\"rounded-md border border-dashed px-6 py-10 text-center\">\n <p className=\"text-sm font-medium\">{t(\"memberList.empty\")}</p>\n <p className=\"mt-1 text-sm text-muted-foreground\">\n {t(\"memberList.emptyHint\")}\n </p>\n </div>\n ) : (\n <div className=\"rounded-md border\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>{t(\"memberList.columns.name\")}</TableHead>\n <TableHead className=\"hidden md:table-cell\">\n {t(\"memberList.columns.email\")}\n </TableHead>\n <TableHead>{t(\"memberList.columns.role\")}</TableHead>\n <TableHead className=\"hidden md:table-cell\">\n {t(\"memberList.columns.joined\")}\n </TableHead>\n {canManage && (\n <TableHead className=\"text-right\">\n {t(\"memberList.columns.actions\")}\n </TableHead>\n )}\n </TableRow>\n </TableHeader>\n <TableBody>\n {members.map((member) => {\n const memberId = member.id ?? member.userId;\n const isCurrentUser = member.userId === currentUserId;\n const isOwner = member.role === \"owner\";\n const isBusy = isPending && busyId === memberId;\n const name = member.user?.name || member.userId;\n\n return (\n <TableRow key={memberId}>\n <TableCell className=\"font-medium\">\n {name}\n {isCurrentUser && (\n <span className=\"ml-2 text-xs text-muted-foreground\">\n {t(\"memberList.you\")}\n </span>\n )}\n {/* The email column is hidden on small screens,\n so the primary cell carries it there. */}\n <span className=\"block truncate text-xs font-normal text-muted-foreground md:hidden\">\n {member.user?.email ?? \"—\"}\n </span>\n </TableCell>\n <TableCell className=\"hidden md:table-cell\">\n {member.user?.email ?? \"—\"}\n </TableCell>\n <TableCell>\n {canManage && !isOwner && !isCurrentUser ? (\n <Select\n value={member.role}\n items={roleItems}\n onValueChange={(value) =>\n value && handleRoleChange(memberId, value)\n }\n disabled={isBusy}\n >\n <SelectTrigger className=\"w-32\">\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n <SelectItem value=\"member\">\n {t(\"roles.member\")}\n </SelectItem>\n <SelectItem value=\"admin\">\n {t(\"roles.admin\")}\n </SelectItem>\n </SelectContent>\n </Select>\n ) : (\n <RoleBadge role={member.role} />\n )}\n </TableCell>\n <TableCell className=\"hidden md:table-cell\">\n {member.createdAt\n ? format.dateTime(new Date(member.createdAt), {\n dateStyle: \"medium\",\n })\n : \"—\"}\n </TableCell>\n {canManage && (\n <TableCell className=\"text-right\">\n {canTransfer && !isOwner && !isCurrentUser && (\n <Button\n variant=\"ghost\"\n size=\"sm\"\n disabled={isBusy}\n onClick={() => {\n setTransferTarget({ id: memberId, name });\n setTransferOpen(true);\n }}\n >\n {t(\"memberList.transferDialog.trigger\")}\n </Button>\n )}\n {!isCurrentUser && !isOwner && (\n <Dialog>\n <DialogTrigger\n render={\n <Button\n variant=\"ghost\"\n size=\"sm\"\n disabled={isBusy}\n />\n }\n >\n {t(\"memberList.removeDialog.trigger\")}\n </DialogTrigger>\n <DialogContent>\n <DialogHeader>\n <DialogTitle>\n {t(\"memberList.removeDialog.title\")}\n </DialogTitle>\n <DialogDescription>\n {t(\"memberList.removeDialog.description\", {\n name,\n })}\n </DialogDescription>\n </DialogHeader>\n <DialogFooter>\n <Button\n variant=\"destructive\"\n onClick={() => handleRemove(memberId, name)}\n disabled={isBusy}\n >\n {isBusy\n ? t(\n \"memberList.removeDialog.confirmPending\"\n )\n : t(\"memberList.removeDialog.confirm\")}\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n )}\n </TableCell>\n )}\n </TableRow>\n );\n })}\n </TableBody>\n </Table>\n </div>\n )}\n\n {/* A request in flight keeps the dialog open until it settles. */}\n <AlertDialog\n open={transferOpen}\n onOpenChange={(next) => {\n if (!isTransferring) setTransferOpen(next);\n }}\n >\n <AlertDialogContent>\n <AlertDialogHeader>\n <AlertDialogTitle>\n {t(\"memberList.transferDialog.title\", {\n name: transferTarget?.name ?? \"\",\n })}\n </AlertDialogTitle>\n <AlertDialogDescription>\n {t(\"memberList.transferDialog.description\", {\n name: transferTarget?.name ?? \"\",\n })}\n </AlertDialogDescription>\n </AlertDialogHeader>\n <AlertDialogFooter>\n <AlertDialogCancel disabled={isTransferring}>\n {t(\"memberList.transferDialog.cancel\")}\n </AlertDialogCancel>\n <AlertDialogAction\n onClick={handleTransfer}\n disabled={isTransferring}\n aria-busy={isTransferring}\n >\n {isTransferring\n ? t(\"memberList.transferDialog.confirmPending\")\n : t(\"memberList.transferDialog.confirm\")}\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n </div>\n );\n}\n",
44
+ "type": "registry:component",
45
+ "target": "components/team/member-list.tsx"
46
+ },
47
+ {
48
+ "path": "base/team-settings/components/invite-member-form.tsx",
49
+ "content": "\"use client\";\n\n/**\n * Invite member form — owner/admin only. Client-side required-field\n * checks are handled by the `email` input; server-side validation\n * errors from the action are surfaced inline.\n */\n\nimport { useState, useTransition, type FormEvent } from \"react\";\nimport { useTranslations } from \"next-intl\";\nimport { toast } from \"sonner\";\n\nimport { inviteMember } from \"@/actions/team\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\";\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from \"@/components/ui/card\";\n\nexport function InviteMemberForm() {\n const t = useTranslations(\"team-settings\");\n // The labels SelectValue shows; Base UI renders the raw value without them.\n const roleItems = [\n { value: \"member\", label: t(\"roles.member\") },\n { value: \"admin\", label: t(\"roles.admin\") },\n ];\n const [isPending, startTransition] = useTransition();\n const [email, setEmail] = useState(\"\");\n const [role, setRole] = useState<\"admin\" | \"member\">(\"member\");\n const [error, setError] = useState<string | null>(null);\n\n function handleSubmit(event: FormEvent<HTMLFormElement>) {\n event.preventDefault();\n setError(null);\n\n startTransition(async () => {\n const result = await inviteMember({ email, role });\n if (!result.success) {\n setError(result.error);\n return;\n }\n toast.success(t(\"inviteForm.inviteSent\", { email }));\n setEmail(\"\");\n setRole(\"member\");\n });\n }\n\n return (\n <Card>\n <CardHeader>\n <CardTitle>{t(\"inviteForm.title\")}</CardTitle>\n <CardDescription>{t(\"inviteForm.description\")}</CardDescription>\n </CardHeader>\n <CardContent>\n <form onSubmit={handleSubmit} className=\"space-y-4\">\n <div className=\"space-y-2\">\n <Label htmlFor=\"email\">{t(\"inviteForm.emailLabel\")}</Label>\n <Input\n id=\"email\"\n type=\"email\"\n placeholder={t(\"inviteForm.emailPlaceholder\")}\n value={email}\n onChange={(event) => setEmail(event.target.value)}\n disabled={isPending}\n required\n />\n {error && <p className=\"text-sm text-destructive\">{error}</p>}\n </div>\n\n <div className=\"space-y-2\">\n <Label htmlFor=\"role\">{t(\"inviteForm.roleLabel\")}</Label>\n <Select\n value={role}\n items={roleItems}\n onValueChange={(value) =>\n value && setRole(value as \"admin\" | \"member\")\n }\n disabled={isPending}\n >\n <SelectTrigger id=\"role\">\n <SelectValue placeholder={t(\"inviteForm.rolePlaceholder\")} />\n </SelectTrigger>\n <SelectContent>\n <SelectItem value=\"member\">{t(\"roles.member\")}</SelectItem>\n <SelectItem value=\"admin\">{t(\"roles.admin\")}</SelectItem>\n </SelectContent>\n </Select>\n </div>\n\n <Button type=\"submit\" disabled={isPending}>\n {isPending ? t(\"inviteForm.submitPending\") : t(\"inviteForm.submit\")}\n </Button>\n </form>\n </CardContent>\n </Card>\n );\n}\n",
50
+ "type": "registry:component",
51
+ "target": "components/team/invite-member-form.tsx"
52
+ },
53
+ {
54
+ "path": "base/team-settings/components/pending-invitations.tsx",
55
+ "content": "\"use client\";\n\n/**\n * Pending invitations — table of outstanding workspace invitations\n * with a confirmed cancel action. Rendered for owner/admin only.\n */\n\nimport { useState, useTransition } from \"react\";\nimport { useFormatter, useTranslations } from \"next-intl\";\nimport { toast } from \"sonner\";\n\nimport type { OrgInvitation } from \"@intelligo-dev/auth\";\n\nimport { cancelInvitation } from \"@/actions/team\";\nimport { RoleBadge } from \"./role-badge\";\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from \"@/components/ui/table\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\n\ninterface PendingInvitationsProps {\n invitations: OrgInvitation[];\n}\n\nexport function PendingInvitations({ invitations }: PendingInvitationsProps) {\n const t = useTranslations(\"team-settings\");\n const format = useFormatter();\n const [isPending, startTransition] = useTransition();\n const [busyId, setBusyId] = useState<string | null>(null);\n // The target outlives `cancelOpen` so the email stays while the dialog exits.\n const [cancelTarget, setCancelTarget] = useState<{\n id: string;\n email: string;\n } | null>(null);\n const [cancelOpen, setCancelOpen] = useState(false);\n const isCanceling =\n isPending && cancelTarget !== null && busyId === cancelTarget.id;\n\n function handleCancel() {\n if (!cancelTarget) return;\n const { id } = cancelTarget;\n setBusyId(id);\n startTransition(async () => {\n const result = await cancelInvitation(id);\n if (!result.success) {\n toast.error(result.error);\n } else {\n toast.success(t(\"pendingInvitations.canceled\"));\n }\n setBusyId(null);\n setCancelOpen(false);\n });\n }\n\n return (\n <div>\n <h2 className=\"mb-3 text-sm font-semibold\">\n {t(\"pendingInvitations.heading\", { count: invitations.length })}\n </h2>\n\n {invitations.length === 0 ? (\n <div className=\"rounded-md border border-dashed px-6 py-10 text-center\">\n <p className=\"text-sm font-medium\">{t(\"pendingInvitations.empty\")}</p>\n <p className=\"mt-1 text-sm text-muted-foreground\">\n {t(\"pendingInvitations.emptyHint\")}\n </p>\n </div>\n ) : (\n <div className=\"rounded-md border\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>{t(\"pendingInvitations.columns.email\")}</TableHead>\n <TableHead>{t(\"pendingInvitations.columns.role\")}</TableHead>\n <TableHead className=\"hidden sm:table-cell\">\n {t(\"pendingInvitations.columns.status\")}\n </TableHead>\n <TableHead className=\"hidden md:table-cell\">\n {t(\"pendingInvitations.columns.expires\")}\n </TableHead>\n <TableHead className=\"text-right\">\n {t(\"pendingInvitations.columns.actions\")}\n </TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {invitations.map((invitation) => {\n const isBusy = isPending && busyId === invitation.id;\n const isExpired = new Date(invitation.expiresAt) < new Date();\n\n return (\n <TableRow key={invitation.id}>\n <TableCell className=\"font-medium\">\n {invitation.email}\n </TableCell>\n <TableCell>\n <RoleBadge role={invitation.role} />\n </TableCell>\n <TableCell className=\"hidden sm:table-cell\">\n {isExpired ? (\n <span className=\"text-sm text-destructive\">\n {t(\"pendingInvitations.expired\")}\n </span>\n ) : (\n <span className=\"text-sm text-muted-foreground\">\n {t(\"pendingInvitations.pending\")}\n </span>\n )}\n </TableCell>\n <TableCell className=\"hidden md:table-cell\">\n {format.dateTime(new Date(invitation.expiresAt), {\n dateStyle: \"medium\",\n })}\n </TableCell>\n <TableCell className=\"text-right\">\n <Button\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => {\n setCancelTarget({\n id: invitation.id,\n email: invitation.email,\n });\n setCancelOpen(true);\n }}\n disabled={isBusy}\n >\n {isBusy\n ? t(\"pendingInvitations.cancelPending\")\n : t(\"pendingInvitations.cancel\")}\n </Button>\n </TableCell>\n </TableRow>\n );\n })}\n </TableBody>\n </Table>\n </div>\n )}\n\n {/* A request in flight keeps the dialog open until it settles. */}\n <AlertDialog\n open={cancelOpen}\n onOpenChange={(next) => {\n if (!isCanceling) setCancelOpen(next);\n }}\n >\n <AlertDialogContent>\n <AlertDialogHeader>\n <AlertDialogTitle>\n {t(\"pendingInvitations.cancelDialog.title\")}\n </AlertDialogTitle>\n <AlertDialogDescription>\n {t(\"pendingInvitations.cancelDialog.description\", {\n email: cancelTarget?.email ?? \"\",\n })}\n </AlertDialogDescription>\n </AlertDialogHeader>\n <AlertDialogFooter>\n <AlertDialogCancel disabled={isCanceling}>\n {t(\"pendingInvitations.cancelDialog.keep\")}\n </AlertDialogCancel>\n <AlertDialogAction\n variant=\"destructive\"\n onClick={handleCancel}\n disabled={isCanceling}\n aria-busy={isCanceling}\n >\n {isCanceling\n ? t(\"pendingInvitations.cancelPending\")\n : t(\"pendingInvitations.cancelDialog.confirm\")}\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n </div>\n );\n}\n",
56
+ "type": "registry:component",
57
+ "target": "components/team/pending-invitations.tsx"
58
+ },
59
+ {
60
+ "path": "base/team-settings/components/role-badge.tsx",
61
+ "content": "\"use client\";\n\nimport { useTranslations } from \"next-intl\";\n\nimport { Badge } from \"@/components/ui/badge\";\n\n/**\n * A role is a label, not a status: the badges stay quiet and differ by\n * their text rather than by colour.\n */\nconst VARIANT_BY_ROLE: Record<string, \"default\" | \"secondary\" | \"outline\"> = {\n owner: \"secondary\",\n admin: \"outline\",\n member: \"outline\",\n};\n\ninterface RoleBadgeProps {\n role: string;\n}\n\nexport function RoleBadge({ role }: RoleBadgeProps) {\n const t = useTranslations(\"team-settings\");\n\n return (\n <Badge variant={VARIANT_BY_ROLE[role] ?? \"outline\"} className=\"capitalize\">\n {t.has(`roles.${role}`) ? t(`roles.${role}`) : role}\n </Badge>\n );\n}\n",
62
+ "type": "registry:component",
63
+ "target": "components/team/role-badge.tsx"
64
+ },
65
+ {
66
+ "path": "base/team-settings/components/leave-workspace.tsx",
67
+ "content": "\"use client\";\n\n/**\n * Leave workspace — every role can leave. The sole owner cannot: the\n * service refuses and the message says to make someone else an owner\n * first.\n */\n\nimport { useState, useTransition } from \"react\";\nimport { useTranslations } from \"next-intl\";\nimport { toast } from \"sonner\";\n\nimport { useRouter } from \"@/i18n/navigation\";\nimport { leaveWorkspace } from \"@/actions/team\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from \"@/components/ui/card\";\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n AlertDialogTrigger,\n} from \"@/components/ui/alert-dialog\";\n\ninterface LeaveWorkspaceProps {\n workspaceName: string;\n}\n\nexport function LeaveWorkspace({ workspaceName }: LeaveWorkspaceProps) {\n const t = useTranslations(\"team-settings\");\n const router = useRouter();\n const [isPending, startTransition] = useTransition();\n const [open, setOpen] = useState(false);\n\n function handleLeave() {\n startTransition(async () => {\n const result = await leaveWorkspace();\n if (!result.success) {\n toast.error(result.error);\n setOpen(false);\n return;\n }\n toast.success(t(\"leave.left\", { workspace: workspaceName }));\n router.push(\"/dashboard\");\n router.refresh();\n });\n }\n\n return (\n <Card className=\"border-destructive/30\">\n <CardHeader>\n <CardTitle>{t(\"leave.title\")}</CardTitle>\n <CardDescription>\n {t(\"leave.description\", { workspace: workspaceName })}\n </CardDescription>\n </CardHeader>\n <CardContent>\n {/* A request in flight keeps the dialog open until it settles. */}\n <AlertDialog\n open={open}\n onOpenChange={(next) => {\n if (!isPending) setOpen(next);\n }}\n >\n <AlertDialogTrigger render={<Button variant=\"destructive\" />}>\n {t(\"leave.trigger\")}\n </AlertDialogTrigger>\n <AlertDialogContent>\n <AlertDialogHeader>\n <AlertDialogTitle>\n {t(\"leave.dialogTitle\", { workspace: workspaceName })}\n </AlertDialogTitle>\n <AlertDialogDescription>\n {t(\"leave.dialogDescription\")}\n </AlertDialogDescription>\n </AlertDialogHeader>\n <AlertDialogFooter>\n <AlertDialogCancel disabled={isPending}>\n {t(\"leave.cancel\")}\n </AlertDialogCancel>\n <AlertDialogAction\n variant=\"destructive\"\n onClick={handleLeave}\n disabled={isPending}\n aria-busy={isPending}\n >\n {isPending ? t(\"leave.confirmPending\") : t(\"leave.confirm\")}\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n </CardContent>\n </Card>\n );\n}\n",
68
+ "type": "registry:component",
69
+ "target": "components/team/leave-workspace.tsx"
70
+ },
71
+ {
72
+ "path": "base/team-settings/actions.ts",
73
+ "content": "\"use server\";\n\n/**\n * Team settings server actions — thin transport over the bound team\n * service (`@/lib/team`): parse input with the schemas from\n * `@intelligo-dev/auth`, call the service, map any `TeamServiceError` to a\n * friendly message, and revalidate the page. No business rules here —\n * those live in the service.\n */\n\nimport { revalidatePath } from \"next/cache\";\nimport { getTranslations } from \"next-intl/server\";\n\nimport {\n inviteMemberSchema,\n isTeamServiceError,\n updateRoleSchema,\n type InviteMemberInput,\n type UpdateRoleInput,\n} from \"@intelligo-dev/auth\";\nimport type { ActionResult } from \"@intelligo-dev/next\";\n\nimport { team } from \"@/lib/team\";\n\nexport type TeamActionResult<T = undefined> = ActionResult<T>;\n\ntype Translator = Awaited<ReturnType<typeof getTranslations<\"team-settings\">>>;\n\nfunction friendlyMessage(\n t: Translator,\n limit: number | undefined\n): Partial<Record<string, string>> {\n return {\n member_limit_reached:\n limit === undefined\n ? t(\"errors.memberLimitReached\")\n : t(\"errors.memberLimitReachedCount\", { limit }),\n invitation_not_found: t(\"errors.invitationNotFound\"),\n sole_owner: t(\"errors.soleOwner\"),\n forbidden: t(\"errors.forbidden\"),\n invalid_input: t(\"errors.invalidInput\"),\n accept_verification_failed: t(\"errors.acceptVerificationFailed\"),\n provider_error: t(\"errors.providerError\"),\n };\n}\n\nfunction friendlyError(error: unknown, t: Translator): string {\n // Unknown errors deliberately map to the generic key — a raw\n // `Error#message` can carry internals (SQL, hostnames) to the UI.\n if (isTeamServiceError(error)) {\n return (\n friendlyMessage(t, error.meta?.limit)[error.code] ??\n t(\"errors.somethingWentWrong\")\n );\n }\n return t(\"errors.somethingWentWrong\");\n}\n\nexport async function inviteMember(\n input: InviteMemberInput\n): Promise<TeamActionResult> {\n const t = await getTranslations(\"team-settings\");\n const parsed = inviteMemberSchema.safeParse(input);\n if (!parsed.success) {\n return {\n success: false,\n error: parsed.error.issues[0]?.message ?? t(\"errors.invalidInput\"),\n };\n }\n\n try {\n await team.inviteMember(parsed.data);\n revalidatePath(\"/settings/team\");\n return { success: true, data: undefined };\n } catch (error) {\n return { success: false, error: friendlyError(error, t) };\n }\n}\n\nexport async function cancelInvitation(\n invitationId: string\n): Promise<TeamActionResult> {\n const t = await getTranslations(\"team-settings\");\n try {\n await team.cancelInvitation(invitationId);\n revalidatePath(\"/settings/team\");\n return { success: true, data: undefined };\n } catch (error) {\n return { success: false, error: friendlyError(error, t) };\n }\n}\n\nexport async function removeMember(\n memberId: string\n): Promise<TeamActionResult> {\n const t = await getTranslations(\"team-settings\");\n try {\n await team.removeMember(memberId);\n revalidatePath(\"/settings/team\");\n return { success: true, data: undefined };\n } catch (error) {\n return { success: false, error: friendlyError(error, t) };\n }\n}\n\nexport async function updateMemberRole(\n input: UpdateRoleInput\n): Promise<TeamActionResult> {\n const t = await getTranslations(\"team-settings\");\n const parsed = updateRoleSchema.safeParse(input);\n if (!parsed.success) {\n return {\n success: false,\n error: parsed.error.issues[0]?.message ?? t(\"errors.invalidInput\"),\n };\n }\n\n try {\n await team.updateMemberRole(parsed.data);\n revalidatePath(\"/settings/team\");\n return { success: true, data: undefined };\n } catch (error) {\n return { success: false, error: friendlyError(error, t) };\n }\n}\n\nexport async function transferOwnership(\n memberId: string\n): Promise<TeamActionResult> {\n const t = await getTranslations(\"team-settings\");\n if (typeof memberId !== \"string\" || memberId.length === 0) {\n return { success: false, error: t(\"errors.invalidInput\") };\n }\n\n try {\n await team.transferOwnership(memberId);\n revalidatePath(\"/settings/team\");\n return { success: true, data: undefined };\n } catch (error) {\n return { success: false, error: friendlyError(error, t) };\n }\n}\n\nexport async function leaveWorkspace(): Promise<TeamActionResult> {\n const t = await getTranslations(\"team-settings\");\n try {\n await team.leaveWorkspace();\n // The active workspace changed, and every layout reads it.\n revalidatePath(\"/\", \"layout\");\n return { success: true, data: undefined };\n } catch (error) {\n return { success: false, error: friendlyError(error, t) };\n }\n}\n",
74
+ "type": "registry:file",
75
+ "target": "actions/team.ts"
76
+ },
77
+ {
78
+ "path": "base/team-settings/lib/team.ts",
79
+ "content": "import \"server-only\";\n\n/**\n * Team service binding — the composition-root wiring for the\n * team-settings item. Binds the plan-defined member limit\n * (`@intelligo-dev/billing`) and the \"member joined\" in-app notification\n * (`@intelligo-dev/core/notifications`) into the framework-owned team\n * service (`@intelligo-dev/auth`).\n *\n * `sendInvitationEmail` is intentionally left unbound: the\n * organization plugin's `sendInvitationEmail` hook (wired in\n * `@intelligo-dev/auth`'s Better-Auth server config) already sends the\n * invitation email for every `inviteMember()` call. Binding this port\n * too would send a duplicate email — see the doc comment on\n * `createTeamService` for the full trace.\n */\n\nimport { createTeamService } from \"@intelligo-dev/auth\";\nimport { checkTeamMemberLimit } from \"@intelligo-dev/billing\";\nimport { triggerTeamMemberJoinedNotification } from \"@intelligo-dev/core/notifications\";\n\nexport const team = createTeamService({\n checkMemberLimit: checkTeamMemberLimit,\n notifyMemberJoined: ({ workspaceId, memberName, memberEmail, ownerId }) =>\n triggerTeamMemberJoinedNotification({\n userId: ownerId,\n workspaceId,\n memberName,\n memberEmail,\n }),\n});\n",
80
+ "type": "registry:file",
81
+ "target": "lib/team.ts"
82
+ },
83
+ {
84
+ "path": "base/team-settings/messages/en.json",
85
+ "content": "{\n \"page\": {\n \"title\": \"Team\",\n \"descriptionManage\": \"Manage who has access to {workspace}.\",\n \"descriptionReadOnly\": \"People with access to {workspace}.\"\n },\n \"inviteForm\": {\n \"title\": \"Invite a team member\",\n \"description\": \"Send an invitation to join this workspace.\",\n \"emailLabel\": \"Email\",\n \"emailPlaceholder\": \"colleague@example.com\",\n \"roleLabel\": \"Role\",\n \"rolePlaceholder\": \"Select a role\",\n \"submit\": \"Send invitation\",\n \"submitPending\": \"Sending…\",\n \"inviteSent\": \"Invitation sent to {email}\"\n },\n \"roles\": {\n \"owner\": \"Owner\",\n \"admin\": \"Admin\",\n \"member\": \"Member\"\n },\n \"memberList\": {\n \"heading\": \"Members ({count})\",\n \"empty\": \"No members yet.\",\n \"columns\": {\n \"name\": \"Name\",\n \"email\": \"Email\",\n \"role\": \"Role\",\n \"joined\": \"Joined\",\n \"actions\": \"Actions\"\n },\n \"you\": \"(You)\",\n \"roleUpdated\": \"Role updated\",\n \"memberRemoved\": \"{name} removed from the workspace\",\n \"removeDialog\": {\n \"trigger\": \"Remove\",\n \"title\": \"Remove member\",\n \"description\": \"Are you sure you want to remove {name} from the workspace? They will lose access to everything in it.\",\n \"confirm\": \"Remove\",\n \"confirmPending\": \"Removing…\"\n },\n \"ownershipTransferred\": \"{name} is now an owner\",\n \"transferDialog\": {\n \"trigger\": \"Make owner\",\n \"title\": \"Make {name} an owner?\",\n \"description\": \"{name} becomes an owner, with full control of this workspace, its billing and its members. You stay an owner until you leave. An owner can't be demoted or removed from this page.\",\n \"cancel\": \"Cancel\",\n \"confirm\": \"Make owner\",\n \"confirmPending\": \"Making owner…\"\n },\n \"emptyHint\": \"Invite someone above to share this workspace.\"\n },\n \"pendingInvitations\": {\n \"heading\": \"Pending invitations ({count})\",\n \"empty\": \"No pending invitations.\",\n \"columns\": {\n \"email\": \"Email\",\n \"role\": \"Role\",\n \"status\": \"Status\",\n \"expires\": \"Expires\",\n \"actions\": \"Actions\"\n },\n \"pending\": \"Pending\",\n \"expired\": \"Expired\",\n \"cancel\": \"Cancel\",\n \"cancelPending\": \"Canceling…\",\n \"canceled\": \"Invitation canceled\",\n \"cancelDialog\": {\n \"title\": \"Cancel this invitation?\",\n \"description\": \"The invitation link sent to {email} stops working. You can invite them again later.\",\n \"keep\": \"Keep invitation\",\n \"confirm\": \"Cancel invitation\"\n },\n \"emptyHint\": \"Invitations you send appear here until they're accepted.\"\n },\n \"leave\": {\n \"title\": \"Leave workspace\",\n \"description\": \"Give up your access to {workspace}. Someone there has to invite you again for you to come back.\",\n \"trigger\": \"Leave workspace\",\n \"dialogTitle\": \"Leave {workspace}?\",\n \"dialogDescription\": \"You lose access to everything in this workspace right away. If you're its only owner, make another member an owner first.\",\n \"cancel\": \"Cancel\",\n \"confirm\": \"Leave workspace\",\n \"confirmPending\": \"Leaving…\",\n \"left\": \"You left {workspace}\"\n },\n \"errors\": {\n \"memberLimitReached\": \"Your plan's seats are all taken. Upgrade to invite more people.\",\n \"memberLimitReachedCount\": \"Your plan includes {limit, plural, one {# seat} other {# seats}}, and they're all taken. Upgrade to invite more people.\",\n \"invitationNotFound\": \"That invitation no longer exists.\",\n \"soleOwner\": \"You're the only owner of this workspace. Make another member an owner before leaving.\",\n \"forbidden\": \"You don't have permission to do that.\",\n \"acceptVerificationFailed\": \"We couldn't confirm the invitation. Please try again.\",\n \"providerError\": \"Something went wrong. Please try again.\",\n \"somethingWentWrong\": \"Something went wrong.\",\n \"invalidInput\": \"Invalid input\"\n }\n}\n",
86
+ "type": "registry:file",
87
+ "target": "messages/en/team-settings.json"
88
+ },
89
+ {
90
+ "path": "base/team-settings/error.tsx",
91
+ "content": "\"use client\";\n\n/**\n * Renders the shared `RouteError` from the `route-error` item — install\n * 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-team\" />;\n}\n",
92
+ "type": "registry:page",
93
+ "target": "app/[locale]/(app)/settings/team/error.tsx"
94
+ }
95
+ ],
96
+ "type": "registry:block"
97
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "textarea",
4
+ "title": "Textarea",
5
+ "description": "A multi-line text field that grows with its content.",
6
+ "registryDependencies": [],
7
+ "files": [
8
+ {
9
+ "path": "base/ui/textarea/textarea.tsx",
10
+ "content": "/*\n * The textarea: the input's field, grown to its content.\n */\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction Textarea({ className, ...props }: React.ComponentProps<\"textarea\">) {\n return (\n <textarea\n data-slot=\"textarea\"\n className={cn(\n \"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-card px-2.5 py-2 text-base transition-[border-color,box-shadow,background-color] duration-normal ease-standard outline-none placeholder:text-muted-foreground hover:border-ring/50 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/20 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm\",\n className\n )}\n {...props}\n />\n );\n}\n\nexport { Textarea };\n",
11
+ "type": "registry:ui",
12
+ "target": "components/ui/textarea.tsx"
13
+ }
14
+ ],
15
+ "type": "registry:ui"
16
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "tooltip",
4
+ "title": "Tooltip",
5
+ "description": "A short hint on hover or focus that grows out of its trigger; neighbours open instantly.",
6
+ "dependencies": [
7
+ "@base-ui/react",
8
+ "motion"
9
+ ],
10
+ "registryDependencies": [
11
+ "@intelligo/ai-motion"
12
+ ],
13
+ "files": [
14
+ {
15
+ "path": "base/ui/tooltip/tooltip.tsx",
16
+ "content": "\"use client\";\n\n/*\n * The tooltip: a light surface that grows out of its trigger and blurs in;\n * once one has shown, the provider opens neighbouring tooltips instantly.\n * It springs open with motion; the root is kept controlled so the exit\n * plays.\n */\n\nimport * as React from \"react\";\nimport { Tooltip as TooltipPrimitive } from \"@base-ui/react/tooltip\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nimport { popupMotion, useOpenState } from \"@/components/ui/ai-motion\";\nimport { cn } from \"@/lib/utils\";\n\nconst TooltipOpenContext = React.createContext<boolean | null>(null);\n\nfunction TooltipProvider({\n delay = 120,\n ...props\n}: TooltipPrimitive.Provider.Props) {\n return (\n <TooltipPrimitive.Provider\n data-slot=\"tooltip-provider\"\n delay={delay}\n {...props}\n />\n );\n}\n\nfunction Tooltip({\n open: openProp,\n defaultOpen,\n onOpenChange,\n ...props\n}: TooltipPrimitive.Root.Props) {\n const [open, setOpen] = useOpenState(openProp, defaultOpen, onOpenChange);\n return (\n <TooltipOpenContext.Provider value={open}>\n <TooltipPrimitive.Root\n data-slot=\"tooltip\"\n open={open}\n onOpenChange={setOpen}\n {...props}\n />\n </TooltipOpenContext.Provider>\n );\n}\n\nfunction TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {\n return <TooltipPrimitive.Trigger data-slot=\"tooltip-trigger\" {...props} />;\n}\n\nfunction TooltipContent({\n className,\n side = \"top\",\n sideOffset = 6,\n align = \"center\",\n alignOffset = 0,\n children,\n ...props\n}: TooltipPrimitive.Popup.Props &\n Pick<\n TooltipPrimitive.Positioner.Props,\n \"align\" | \"alignOffset\" | \"side\" | \"sideOffset\"\n >) {\n const open = React.useContext(TooltipOpenContext) ?? true;\n const reduced = useReducedMotion() ?? false;\n\n return (\n <AnimatePresence>\n {open && (\n <TooltipPrimitive.Portal keepMounted>\n <TooltipPrimitive.Positioner\n align={align}\n alignOffset={alignOffset}\n side={side}\n sideOffset={sideOffset}\n className=\"isolate z-popover\"\n >\n <TooltipPrimitive.Popup\n data-slot=\"tooltip-content\"\n render={<motion.div {...popupMotion(reduced, 0.88)} />}\n className={cn(\n \"inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-lg border border-border bg-popover px-2.5 py-1 text-xs font-medium text-popover-foreground shadow-lg has-data-[slot=kbd]:pr-1\",\n className\n )}\n {...props}\n >\n {children}\n </TooltipPrimitive.Popup>\n </TooltipPrimitive.Positioner>\n </TooltipPrimitive.Portal>\n )}\n </AnimatePresence>\n );\n}\n\nexport { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };\n",
17
+ "type": "registry:ui",
18
+ "target": "components/ui/tooltip.tsx"
19
+ }
20
+ ],
21
+ "type": "registry:ui"
22
+ }
@@ -0,0 +1,43 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "trial-banner",
4
+ "title": "Trial Banner",
5
+ "description": "Dismissable trial-status strip for the app shell's bannerTop seam: days and credits remaining with an upgrade CTA, urgency-ramped visuals, dismiss-for-today, and a consumer config (lib/trial-banner-config.ts) for the CTA target and route suppression (hidden on /chat by default). Bind TrialBannerContainer as shellConfig.bannerTop.",
6
+ "dependencies": [
7
+ "@intelligo-dev/auth",
8
+ "@intelligo-dev/billing",
9
+ "@intelligo-dev/core",
10
+ "lucide-react",
11
+ "next-intl"
12
+ ],
13
+ "registryDependencies": [
14
+ "@intelligo/button"
15
+ ],
16
+ "files": [
17
+ {
18
+ "path": "base/trial-banner/components/trial-banner-container.tsx",
19
+ "content": "/**\n * Trial banner container — the intended `bannerTop` binding for the\n * app-shell's `ShellConfig` seam (`@/lib/shell-config`).\n *\n * `shellConfig.bannerTop` is rendered with no props (inside\n * `SidebarInset`, above `main`), so this wrapper resolves the active\n * workspace, fetches its trial status, and decides whether the client\n * `TrialBanner` renders at all. An async Server Component is a valid\n * `bannerTop` binding — React 19's `FunctionComponent` allows\n * `Promise<ReactNode>` — so the fetch happens server-side rather than\n * through a client round trip.\n *\n * A trial that isn't active (none, converted, depleted, expired)\n * renders nothing; so does a fetch failure — a broken banner lookup\n * must never take the shell down with it.\n *\n * The credit counts are formatted here, on the server, and reach the\n * client as strings: compact notation (\"1.2K\") is spelled by the ICU\n * data of whichever runtime formats it, and Node's and a browser's\n * differ for many locales — formatting on both sides would render one\n * string on the server and another at hydration.\n *\n * Route suppression (e.g. keeping the banner out of the chat surface)\n * is client-side in `TrialBanner` via `trialBannerConfig.hideOnPaths` —\n * a Server Component outside the `[locale]` segment has no reliable\n * pathname to branch on.\n */\n\nimport { getWorkspaceContext } from \"@intelligo-dev/auth\";\nimport { getTrialStatus } from \"@intelligo-dev/billing\";\nimport { createLogger } from \"@intelligo-dev/core/logger\";\nimport { getFormatter } from \"next-intl/server\";\n\nimport { TrialBanner } from \"./trial-banner\";\n\nconst log = createLogger(\"TrialBannerContainer\");\n\nexport async function TrialBannerContainer() {\n const workspace = await getWorkspaceContext();\n if (!workspace?.workspace?.id) return null;\n\n try {\n const trial = await getTrialStatus(workspace.workspace.id);\n if (\n trial.status !== \"active\" ||\n trial.isExpired ||\n trial.daysRemaining <= 0\n ) {\n return null;\n }\n\n const format = await getFormatter();\n const compact = (value: number) =>\n format.number(value, { notation: \"compact\", maximumFractionDigits: 1 });\n\n return (\n <TrialBanner\n daysRemaining={trial.daysRemaining}\n creditsRemaining={compact(trial.creditsRemaining)}\n initialCredits={compact(trial.initialCredits)}\n />\n );\n } catch (error) {\n log.error(\"Failed to fetch trial status\", {\n error: error instanceof Error ? error.message : String(error),\n });\n return null;\n }\n}\n",
20
+ "type": "registry:component",
21
+ "target": "components/trial/trial-banner-container.tsx"
22
+ },
23
+ {
24
+ "path": "base/trial-banner/components/trial-banner.tsx",
25
+ "content": "\"use client\";\n\n/**\n * Trial banner — a dismissable strip above the app shell's content\n * showing days and credits remaining, with an upgrade CTA.\n *\n * Urgency ramps the visual weight with semantic tokens: normal (>3 days) sits on `primary`, warning (≤3 days) on\n * `foreground`/`muted`, urgent (≤1 day) on `destructive`. Dismissing\n * hides the banner for the rest of the day (localStorage; per-browser,\n * deliberately not server state — a nudge, not a notification).\n *\n * `trialBannerConfig` (consumer-owned `lib/trial-banner-config.ts`)\n * decides where the upgrade CTA points and which route prefixes\n * suppress the banner entirely — the default keeps it out of `/chat`,\n * where a persistent strip over a conversation costs the most.\n */\n\nimport { useEffect, useState } from \"react\";\nimport { Sparkles, X } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\n\nimport { Link, usePathname } from \"@/i18n/navigation\";\nimport { Button } from \"@/components/ui/button\";\nimport { trialBannerConfig } from \"@/lib/trial-banner-config\";\n\nconst DISMISSED_KEY = \"trial-banner-dismissed\";\n\n/** Local calendar day, as a stable `YYYY-MM-DD` storage key. */\nfunction today(): string {\n return new Date().toISOString().slice(0, 10);\n}\n\ntype TrialBannerProps = {\n daysRemaining: number;\n /**\n * Credit counts already formatted for display, by the server\n * (`TrialBannerContainer`). Formatting them here would run Intl twice —\n * Node's ICU during SSR, the browser's at hydration — and the two\n * disagree on compact notation for many locales.\n */\n creditsRemaining: string;\n initialCredits: string;\n};\n\nexport function TrialBanner({\n daysRemaining,\n creditsRemaining,\n initialCredits,\n}: TrialBannerProps) {\n const t = useTranslations(\"trial-banner\");\n const pathname = usePathname();\n const [isVisible, setIsVisible] = useState(true);\n\n // Dismissed-today check runs in an effect: localStorage doesn't exist\n // during SSR, and reading it during render would desync hydration.\n useEffect(() => {\n if (localStorage.getItem(DISMISSED_KEY) === today()) {\n setIsVisible(false);\n }\n }, []);\n\n const hidden = trialBannerConfig.hideOnPaths.some(\n (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)\n );\n if (hidden || !isVisible) return null;\n\n function handleDismiss() {\n localStorage.setItem(DISMISSED_KEY, today());\n setIsVisible(false);\n }\n\n const isUrgent = daysRemaining <= 1;\n const isWarning = daysRemaining > 1 && daysRemaining <= 3;\n\n const strip = isUrgent\n ? \"bg-destructive/10 border-destructive/20\"\n : isWarning\n ? \"bg-muted border-border\"\n : \"bg-primary/5 border-primary/20\";\n const accent = isUrgent\n ? \"text-destructive\"\n : isWarning\n ? \"text-foreground\"\n : \"text-primary\";\n const chip = isUrgent\n ? \"bg-destructive/10 text-destructive\"\n : isWarning\n ? \"bg-foreground/10 text-foreground\"\n : \"bg-primary/10 text-primary\";\n\n return (\n <div className={`relative w-full border-b transition-colors ${strip}`}>\n <div className=\"container mx-auto px-4 py-3\">\n <div className=\"flex items-center justify-between gap-4\">\n <div className=\"flex items-center gap-3\">\n <Sparkles className={`size-5 ${accent}`} aria-hidden />\n <div className=\"flex flex-col sm:flex-row sm:items-center sm:gap-2\">\n <div className=\"flex items-center gap-2\">\n <span\n className={`inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ${chip}`}\n >\n {t(\"label\")}\n </span>\n <span className={`text-sm font-semibold ${accent}`}>\n {daysRemaining === 0\n ? t(\"expiresToday\")\n : t(\"daysRemaining\", { days: daysRemaining })}\n </span>\n </div>\n <span className={`text-xs ${accent}`}>\n {t(\"creditsLeft\", {\n remaining: creditsRemaining,\n initial: initialCredits,\n })}\n </span>\n </div>\n </div>\n\n <div className=\"flex items-center gap-2\">\n <Button\n size=\"sm\"\n variant={isUrgent ? \"destructive\" : \"default\"}\n render={<Link href={trialBannerConfig.upgradeHref} />}\n nativeButton={false}\n >\n {t(\"upgrade\")}\n </Button>\n <button\n type=\"button\"\n onClick={handleDismiss}\n className=\"rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted\"\n aria-label={t(\"dismiss\")}\n >\n <X className=\"size-4\" />\n </button>\n </div>\n </div>\n </div>\n </div>\n );\n}\n",
26
+ "type": "registry:component",
27
+ "target": "components/trial/trial-banner.tsx"
28
+ },
29
+ {
30
+ "path": "base/trial-banner/lib/trial-banner-config.ts",
31
+ "content": "/**\n * Trial banner config — consumer-owned.\n *\n * `hideOnPaths`: locale-less route prefixes where the banner never\n * renders (matched against `@/i18n/navigation`'s `usePathname`, exact\n * or as a path-segment prefix). The default keeps it off the chat\n * surface — a persistent strip over a conversation is where a banner\n * costs the most. Empty the list to show it everywhere.\n *\n * `upgradeHref`: where the CTA sends the user.\n */\n\nexport interface TrialBannerConfig {\n hideOnPaths: string[];\n upgradeHref: string;\n}\n\nexport const trialBannerConfig: TrialBannerConfig = {\n hideOnPaths: [\"/chat\"],\n upgradeHref: \"/pricing\",\n};\n",
32
+ "type": "registry:file",
33
+ "target": "lib/trial-banner-config.ts"
34
+ },
35
+ {
36
+ "path": "base/trial-banner/messages/en.json",
37
+ "content": "{\n \"label\": \"Trial\",\n \"expiresToday\": \"Expires today!\",\n \"daysRemaining\": \"{days, plural, one {# day remaining} other {# days remaining}}\",\n \"creditsLeft\": \"{remaining} of {initial} trial credits left\",\n \"upgrade\": \"Upgrade\",\n \"dismiss\": \"Dismiss banner\"\n}\n",
38
+ "type": "registry:file",
39
+ "target": "messages/en/trial-banner.json"
40
+ }
41
+ ],
42
+ "type": "registry:block"
43
+ }
@@ -0,0 +1,84 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "usage",
4
+ "title": "Usage",
5
+ "description": "Workspace usage dashboard: current-period summary tiles, a period selector, and a table of recent AI execution records.",
6
+ "dependencies": [
7
+ "@intelligo-dev/auth",
8
+ "@intelligo-dev/billing",
9
+ "@intelligo-dev/core",
10
+ "@intelligo-dev/executions",
11
+ "@intelligo-dev/next",
12
+ "lucide-react",
13
+ "next-intl"
14
+ ],
15
+ "registryDependencies": [
16
+ "card",
17
+ "@intelligo/progress",
18
+ "skeleton",
19
+ "table",
20
+ "@intelligo/tabs",
21
+ "@intelligo/button",
22
+ "@intelligo/page-header",
23
+ "@intelligo/stat-card",
24
+ "@intelligo/status-badge",
25
+ "@intelligo/animated-list"
26
+ ],
27
+ "files": [
28
+ {
29
+ "path": "base/usage/page.tsx",
30
+ "content": "import type { Metadata } from \"next\";\nimport { Suspense } from \"react\";\nimport { getTranslations } from \"next-intl/server\";\n\nimport { getUsageOverview } from \"@/actions/usage\";\nimport { UsageEmptyState } from \"@/components/usage/usage-empty-state\";\nimport { UsageChart } from \"@/components/usage/usage-chart\";\nimport { UsageRecordsTable } from \"@/components/usage/usage-records-table\";\nimport { UsageSummaryCards } from \"@/components/usage/usage-summary-cards\";\nimport {\n PageHeader,\n PageHeaderContent,\n PageHeaderDescription,\n PageHeaderTitle,\n} from \"@/components/ui/page-header\";\n\nexport async function generateMetadata(): Promise<Metadata> {\n const t = await getTranslations(\"usage\");\n return { title: t(\"meta.title\") };\n}\n\nexport default async function UsagePage() {\n const t = await getTranslations(\"usage\");\n\n return (\n <div className=\"container mx-auto space-y-8 px-4 py-8\">\n <PageHeader>\n <PageHeaderContent>\n <PageHeaderTitle>{t(\"page.title\")}</PageHeaderTitle>\n <PageHeaderDescription>{t(\"page.description\")}</PageHeaderDescription>\n </PageHeaderContent>\n </PageHeader>\n\n <Suspense fallback={null}>\n <UsageOverviewSection />\n </Suspense>\n </div>\n );\n}\n\nasync function UsageOverviewSection() {\n const t = await getTranslations(\"usage\");\n const result = await getUsageOverview();\n\n if (!result.success) {\n return (\n <UsageEmptyState\n title={t(\"unavailable.title\")}\n description={result.error}\n />\n );\n }\n\n const { data } = result;\n\n return (\n <div className=\"space-y-8\">\n <UsageSummaryCards\n initialPeriodSummary={data.currentPeriod}\n plan={data.plan}\n billingMode={data.billingMode}\n quota={data.quota}\n trial={data.trial}\n />\n <UsageChart points={data.daily} />\n\n <UsageRecordsTable records={data.records} />\n </div>\n );\n}\n",
31
+ "type": "registry:page",
32
+ "target": "app/[locale]/(app)/usage/page.tsx"
33
+ },
34
+ {
35
+ "path": "base/usage/loading.tsx",
36
+ "content": "import { Skeleton } from \"@/components/ui/skeleton\";\n\nexport default function UsageLoading() {\n return (\n <div className=\"container mx-auto space-y-8 px-4 py-8\">\n <div className=\"space-y-2\">\n <Skeleton className=\"h-7 w-32\" />\n <Skeleton className=\"h-4 w-80\" />\n </div>\n\n <div className=\"space-y-4\">\n <Skeleton className=\"h-9 w-64\" />\n <div className=\"grid gap-4 sm:grid-cols-2 lg:grid-cols-4\">\n {Array.from({ length: 4 }).map((_, index) => (\n <Skeleton key={index} className=\"h-24 rounded-lg\" />\n ))}\n </div>\n </div>\n\n <Skeleton className=\"h-96 rounded-lg\" />\n </div>\n );\n}\n",
37
+ "type": "registry:page",
38
+ "target": "app/[locale]/(app)/usage/loading.tsx"
39
+ },
40
+ {
41
+ "path": "base/usage/actions.ts",
42
+ "content": "\"use server\";\n\n/**\n * Usage actions — thin reads over `@intelligo-dev/executions` (token and\n * charge aggregation) and `@intelligo-dev/billing` (quota, trial, plan).\n * They authenticate the caller and reshape the result for the page.\n *\n * Error fallbacks are translated and always generic — a thrown\n * `Error#message` can carry internals (SQL, hostnames).\n */\n\nimport { getTranslations } from \"next-intl/server\";\n\nimport { requireWorkspace } from \"@intelligo-dev/auth\";\nimport {\n getRequestHeaders,\n resolveTimeZone,\n} from \"@intelligo-dev/core/request-context\";\nimport {\n getQuotaThresholds,\n getTrialStatus,\n getWorkspaceBilling,\n} from \"@intelligo-dev/billing\";\nimport {\n listExecutions,\n summarizeExecutions,\n summarizeExecutionsByDay,\n} from \"@intelligo-dev/executions\";\nimport type { ActionResult as BaseActionResult } from \"@intelligo-dev/next\";\n\nimport type { MoneyLike } from \"@/lib/format-money\";\nimport { planName } from \"@/lib/plan-copy\";\n\nexport type UsagePeriod = \"7d\" | \"30d\" | \"current\";\n\nexport type UsagePeriodSummary = {\n tokensUsed: number;\n /**\n * What the period charged, in micros with its currency. Null when\n * nothing was charged, or when the rows carry no currency.\n */\n charged: MoneyLike | null;\n requestCount: number;\n};\n\nexport type UsageQuotaState = {\n percentage: number;\n warningThreshold: boolean;\n criticalThreshold: boolean;\n};\n\nexport type UsageTrialState = {\n hasTrialCredits: boolean;\n status: \"active\" | \"depleted\" | \"converted\" | \"expired\" | \"none\";\n creditsRemaining: number;\n initialCredits: number;\n percentageRemaining: number;\n};\n\nexport type UsagePlan = {\n name: string;\n slug: string;\n};\n\nexport type UsageRecord = {\n id: string;\n capability: string;\n status: \"running\" | \"settling\" | \"succeeded\" | \"failed\" | \"refused\";\n model: string | null;\n totalTokens: number | null;\n /** What this run charged, in micros with its currency. */\n charged: MoneyLike | null;\n startedAt: string; // ISO\n durationMs: number | null;\n};\n\nexport type UsageDailyPoint = {\n /** `YYYY-MM-DD`, UTC. */\n date: string;\n tokensUsed: number;\n requestCount: number;\n};\n\nexport type UsageOverview = {\n plan: UsagePlan;\n billingMode: \"subscription\" | \"credit\";\n currentPeriod: UsagePeriodSummary;\n quota: UsageQuotaState;\n trial: UsageTrialState;\n /** One point per day in the current period, gaps filled with zero. */\n daily: UsageDailyPoint[];\n records: UsageRecord[];\n};\n\nexport type ActionResult<T> = BaseActionResult<T>;\n\n/**\n * The reader's own time zone, from the cookie the app shell writes.\n * Falls back to UTC on the first request of a session, which has no\n * cookie yet, and anywhere no request context is bound.\n */\nasync function readerTimeZone(): Promise<string> {\n try {\n const cookie = (await getRequestHeaders()).get(\"cookie\") ?? \"\";\n const match = cookie.match(/(?:^|;\\s*)tz=([^;]*)/);\n return resolveTimeZone(match?.[1] ? decodeURIComponent(match[1]) : null);\n } catch {\n return \"UTC\";\n }\n}\n\ntype CalendarDay = { year: number; month: number; day: number };\n\n/** The calendar day an instant falls on, as it reads in `timeZone`. */\nfunction partsIn(date: Date, timeZone: string): CalendarDay {\n const parts = new Intl.DateTimeFormat(\"en-CA\", {\n timeZone,\n year: \"numeric\",\n month: \"2-digit\",\n day: \"2-digit\",\n }).formatToParts(date);\n const value = (type: string) =>\n Number(parts.find((part) => part.type === type)?.value ?? \"0\");\n return { year: value(\"year\"), month: value(\"month\"), day: value(\"day\") };\n}\n\n/** How far `timeZone` is from UTC at this instant, in milliseconds. */\nfunction offsetMs(date: Date, timeZone: string): number {\n const parts = new Intl.DateTimeFormat(\"en-US\", {\n timeZone,\n hour12: false,\n year: \"numeric\",\n month: \"2-digit\",\n day: \"2-digit\",\n hour: \"2-digit\",\n minute: \"2-digit\",\n second: \"2-digit\",\n }).formatToParts(date);\n const value = (type: string) =>\n Number(parts.find((part) => part.type === type)?.value ?? \"0\");\n // The wall clock there, read as if it were UTC, minus the real instant.\n const asUtc = Date.UTC(\n value(\"year\"),\n value(\"month\") - 1,\n value(\"day\"),\n value(\"hour\") % 24,\n value(\"minute\"),\n value(\"second\")\n );\n return asUtc - date.getTime();\n}\n\n/**\n * The instant a calendar day begins in `timeZone`.\n *\n * The offset is applied twice because the first correction can land on\n * the other side of a DST change, where the offset differs.\n */\nfunction startOfDay(day: CalendarDay, timeZone: string): Date {\n const naive = Date.UTC(day.year, day.month - 1, day.day);\n const guess = naive - offsetMs(new Date(naive), timeZone);\n return new Date(naive - offsetMs(new Date(guess), timeZone));\n}\n\nconst labelOf = (day: CalendarDay) =>\n `${day.year}-${String(day.month).padStart(2, \"0\")}-${String(day.day).padStart(2, \"0\")}`;\n\n/** The calendar day `count` days after this one. */\nfunction addDays(day: CalendarDay, count: number): CalendarDay {\n const moved = new Date(Date.UTC(day.year, day.month - 1, day.day + count));\n return {\n year: moved.getUTCFullYear(),\n month: moved.getUTCMonth() + 1,\n day: moved.getUTCDate(),\n };\n}\n\n/**\n * Windows are computed in the reader's zone, because the per-day read\n * model buckets there too (`summarizeExecutionsByDay`). Mixing the\n * two — a local month boundary against UTC buckets — silently produces\n * a leading or trailing day that belongs to the wrong period, and the\n * size of the error depends on where the reader is sitting.\n */\nfunction periodWindow(\n period: UsagePeriod,\n timeZone: string\n): { from: Date; to: Date } {\n const to = new Date();\n const today = partsIn(to, timeZone);\n\n const first =\n period === \"current\"\n ? { ...today, day: 1 }\n : addDays(today, period === \"7d\" ? -7 : -30);\n\n return { from: startOfDay(first, timeZone), to };\n}\n\nasync function summarizePeriod(\n workspaceId: string,\n period: UsagePeriod,\n timeZone: string\n): Promise<UsagePeriodSummary> {\n const summary = await summarizeExecutions(\n workspaceId,\n periodWindow(period, timeZone)\n );\n return {\n tokensUsed: summary.totals.totalTokens,\n // A workspace bills in one currency, so the summary has at most one\n // entry; the array is what keeps a platform-wide read honest.\n charged: summary.totals.charged[0] ?? null,\n requestCount: summary.totals.count,\n };\n}\n\n/**\n * Day-by-day series for the period, with absent days filled in as\n * zero. The read model omits days nothing ran on (see\n * `summarizeExecutionsByDay`); a chart needs the gaps, because a flat\n * stretch and a missing stretch look identical once they are drawn.\n */\nfunction fillDailyGaps(\n rows: Array<{ date: string; totalTokens: number; count: number }>,\n window: { from: Date; to: Date },\n timeZone: string\n): UsageDailyPoint[] {\n const byDate = new Map(rows.map((row) => [row.date, row]));\n const points: UsageDailyPoint[] = [];\n\n // Walk the reader's calendar days, which is what the query grouped\n // by. Counting days rather than adding 24 hours to an instant keeps\n // a DST change from skipping or repeating one.\n let cursor = partsIn(window.from, timeZone);\n const end = labelOf(partsIn(window.to, timeZone));\n\n for (let guard = 0; guard < 400; guard += 1) {\n const date = labelOf(cursor);\n const row = byDate.get(date);\n points.push({\n date,\n tokensUsed: row?.totalTokens ?? 0,\n requestCount: row?.count ?? 0,\n });\n if (date >= end) break;\n cursor = addDays(cursor, 1);\n }\n\n return points;\n}\n\n/**\n * Full initial load for the usage page: the current-month summary,\n * quota/trial/plan state, and the most recent execution records.\n */\nexport async function getUsageOverview(): Promise<ActionResult<UsageOverview>> {\n const t = await getTranslations(\"usage\");\n\n try {\n const { workspace } = await requireWorkspace();\n\n const timeZone = await readerTimeZone();\n const window = periodWindow(\"current\", timeZone);\n\n const [currentPeriod, quota, trial, billing, recent, daily] =\n await Promise.all([\n summarizePeriod(workspace.id, \"current\", timeZone),\n getQuotaThresholds(workspace.id),\n getTrialStatus(workspace.id),\n getWorkspaceBilling(workspace.id),\n listExecutions({ workspaceId: workspace.id, limit: 25 }),\n summarizeExecutionsByDay(workspace.id, window, { timeZone }),\n ]);\n\n // The plan's name in the reader's language, when the deployment's\n // `plans` messages translate it (lib/plan-copy.ts).\n const tPlans = await getTranslations(\"plans\");\n const planSlug = billing.plan?.slug ?? \"free\";\n\n return {\n success: true,\n data: {\n plan: {\n name: planName(\n tPlans,\n planSlug,\n billing.plan?.name ?? t(\"summaryCards.noPlan\")\n ),\n slug: planSlug,\n },\n billingMode: billing.billingMode,\n currentPeriod,\n quota,\n trial: {\n hasTrialCredits: trial.hasTrialCredits,\n status: trial.status,\n creditsRemaining: trial.creditsRemaining,\n initialCredits: trial.initialCredits,\n percentageRemaining: trial.percentageRemaining,\n },\n daily: fillDailyGaps(daily, window, timeZone),\n records: recent.map((execution) => ({\n id: execution.id,\n capability: execution.capability,\n status: execution.status as UsageRecord[\"status\"],\n model: execution.model,\n totalTokens: execution.totalTokens,\n charged:\n execution.chargedMicros !== null && execution.currency\n ? {\n amount: Number(execution.chargedMicros),\n currency: execution.currency,\n }\n : null,\n startedAt: execution.startedAt.toISOString(),\n durationMs: execution.durationMs,\n })),\n },\n };\n } catch {\n return {\n success: false,\n // Always the translated key — a raw Error#message can carry\n // internals (SQL, hostnames) to the UI.\n error: t(\"errors.loadOverview\"),\n };\n }\n}\n\n/**\n * Re-summarize just the current-period tile for a different window,\n * called from the client-side period selector. Quota/trial/plan state\n * and the records table are not period-dependent, so they are not\n * refetched here.\n */\nexport async function getUsagePeriodSummary(\n period: UsagePeriod\n): Promise<ActionResult<UsagePeriodSummary>> {\n const t = await getTranslations(\"usage\");\n\n try {\n const { workspace } = await requireWorkspace();\n const data = await summarizePeriod(\n workspace.id,\n period,\n await readerTimeZone()\n );\n return { success: true, data };\n } catch {\n return {\n success: false,\n error: t(\"errors.loadSummary\"),\n };\n }\n}\n",
43
+ "type": "registry:file",
44
+ "target": "actions/usage.ts"
45
+ },
46
+ {
47
+ "path": "base/usage/components/usage-summary-cards.tsx",
48
+ "content": "\"use client\";\n\nimport { useState, useTransition } from \"react\";\nimport { useFormatter, useTranslations } from \"next-intl\";\n\nimport { formatMoney } from \"@/lib/format-money\";\nimport { Card, CardContent, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport { Progress } from \"@/components/ui/progress\";\nimport { Tabs, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport {\n StatCard,\n StatCardAction,\n StatCardHeader,\n StatCardLabel,\n StatCardValue,\n} from \"@/components/ui/stat-card\";\nimport { StatusBadge } from \"@/components/ui/status-badge\";\nimport { AnimatedList, AnimatedListItem } from \"@/components/ui/animated-list\";\n\nimport {\n getUsagePeriodSummary,\n type UsagePeriod,\n type UsagePeriodSummary,\n type UsagePlan,\n type UsageQuotaState,\n type UsageTrialState,\n} from \"@/actions/usage\";\n\nconst PERIODS: UsagePeriod[] = [\"7d\", \"30d\", \"current\"];\n\nexport function UsageSummaryCards({\n initialPeriodSummary,\n plan,\n billingMode,\n quota,\n trial,\n}: {\n initialPeriodSummary: UsagePeriodSummary;\n plan: UsagePlan;\n billingMode: \"subscription\" | \"credit\";\n quota: UsageQuotaState;\n trial: UsageTrialState;\n}) {\n const t = useTranslations(\"usage\");\n const format = useFormatter();\n const [period, setPeriod] = useState<UsagePeriod>(\"current\");\n const [summary, setSummary] = useState(initialPeriodSummary);\n const [isPending, startTransition] = useTransition();\n const [failed, setFailed] = useState(false);\n\n // The tab moves only with its numbers: a failed load keeps the period\n // the cards are showing and says so, rather than labelling the old\n // numbers with the new period.\n function handlePeriodChange(next: string) {\n const nextPeriod = next as UsagePeriod;\n setFailed(false);\n\n startTransition(async () => {\n const result = await getUsagePeriodSummary(nextPeriod);\n if (result.success) {\n setSummary(result.data);\n setPeriod(nextPeriod);\n } else {\n setFailed(true);\n }\n });\n }\n\n const quotaBadge = quota.criticalThreshold\n ? {\n label: t(\"summaryCards.quotaBadge.limitReached\"),\n status: \"destructive\" as const,\n }\n : quota.warningThreshold\n ? {\n label: t(\"summaryCards.quotaBadge.nearingLimit\"),\n status: \"warning\" as const,\n }\n : {\n label: t(\"summaryCards.quotaBadge.withinLimit\"),\n status: \"neutral\" as const,\n };\n\n return (\n <div className=\"space-y-4\">\n {failed ? (\n <p role=\"alert\" className=\"text-sm text-destructive\">\n {t(\"summaryCards.periodFailed\")}\n </p>\n ) : null}\n <Tabs value={period} onValueChange={handlePeriodChange}>\n <TabsList>\n {PERIODS.map((value) => (\n <TabsTrigger key={value} value={value} disabled={isPending}>\n {t(`summaryCards.periods.${value}`)}\n </TabsTrigger>\n ))}\n </TabsList>\n </Tabs>\n\n <AnimatedList\n as=\"div\"\n className=\"grid gap-4 sm:grid-cols-2 lg:grid-cols-4\"\n >\n <AnimatedListItem as=\"div\" className=\"grid\">\n <StatCard aria-busy={isPending}>\n <StatCardHeader>\n <StatCardLabel>{t(\"summaryCards.tokensUsed\")}</StatCardLabel>\n <StatCardValue>{format.number(summary.tokensUsed)}</StatCardValue>\n </StatCardHeader>\n </StatCard>\n </AnimatedListItem>\n\n <AnimatedListItem as=\"div\" className=\"grid\">\n <StatCard aria-busy={isPending}>\n <StatCardHeader>\n <StatCardLabel>{t(\"summaryCards.chargedAmount\")}</StatCardLabel>\n <StatCardValue>\n {/* The amount names its own currency, and shows enough\n decimals to be worth reading: a month of cheap turns\n is a few cents, not \"$0\". */}\n {summary.charged ? formatMoney(format, summary.charged) : \"—\"}\n </StatCardValue>\n </StatCardHeader>\n </StatCard>\n </AnimatedListItem>\n\n <AnimatedListItem as=\"div\" className=\"grid\">\n <StatCard aria-busy={isPending}>\n <StatCardHeader>\n <StatCardLabel>{t(\"summaryCards.requests\")}</StatCardLabel>\n <StatCardValue>\n {format.number(summary.requestCount)}\n </StatCardValue>\n </StatCardHeader>\n </StatCard>\n </AnimatedListItem>\n\n <AnimatedListItem as=\"div\" className=\"grid\">\n <StatCard>\n <StatCardHeader>\n <StatCardLabel>{t(\"summaryCards.planQuota\")}</StatCardLabel>\n <StatCardAction>\n <StatusBadge status={quotaBadge.status}>\n {quotaBadge.label}\n </StatusBadge>\n </StatCardAction>\n </StatCardHeader>\n <CardContent className=\"space-y-2\">\n <Progress value={Math.min(quota.percentage, 100)} />\n <p className=\"text-xs text-muted-foreground\">\n {billingMode === \"credit\"\n ? t(\"summaryCards.quotaAllowanceCredit\", {\n percentage: quota.percentage,\n planName: plan.name,\n })\n : t(\"summaryCards.quotaAllowance\", {\n percentage: quota.percentage,\n planName: plan.name,\n })}\n </p>\n </CardContent>\n </StatCard>\n </AnimatedListItem>\n </AnimatedList>\n\n {trial.hasTrialCredits && trial.status === \"active\" ? (\n <Card>\n <CardHeader className=\"pb-2\">\n <CardTitle className=\"text-sm font-medium text-muted-foreground\">\n {t(\"summaryCards.trialCredits\")}\n </CardTitle>\n </CardHeader>\n <CardContent className=\"space-y-2\">\n <div className=\"flex items-baseline justify-between text-sm\">\n <span>\n {t(\"summaryCards.trialRemaining\", {\n count: format.number(trial.creditsRemaining),\n })}\n </span>\n <span className=\"text-muted-foreground\">\n {t(\"summaryCards.trialOf\", {\n count: format.number(trial.initialCredits),\n })}\n </span>\n </div>\n <Progress value={Math.min(trial.percentageRemaining, 100)} />\n </CardContent>\n </Card>\n ) : null}\n </div>\n );\n}\n",
49
+ "type": "registry:component",
50
+ "target": "components/usage/usage-summary-cards.tsx"
51
+ },
52
+ {
53
+ "path": "base/usage/components/usage-chart.tsx",
54
+ "content": "\"use client\";\n\n/**\n * Tokens used per day across the current period, as a single-series\n * area chart in hand-drawn SVG (no chart dependency). A product that\n * needs real analytics should replace this file with a library.\n *\n * The SVG stretches (`preserveAspectRatio=\"none\"`), so anything that\n * must keep its proportions — tick labels, point markers, hit targets —\n * is an HTML overlay positioned in percentages using the same scale as\n * the path, and every stroke carries `vectorEffect`.\n *\n * Every value is also in a visually hidden table for screen readers.\n */\n\nimport { useId, useState } from \"react\";\nimport { useFormatter, useTranslations } from \"next-intl\";\n\nimport type { UsageDailyPoint } from \"@/actions/usage\";\n\nconst VIEWBOX_WIDTH = 720;\nconst VIEWBOX_HEIGHT = 180;\nconst PADDING_TOP = 8;\nconst PADDING_BOTTOM = 20;\n\ninterface UsageChartProps {\n points: UsageDailyPoint[];\n}\n\n/**\n * `YYYY-MM-DD` as an instant that formats back to that same day.\n *\n * The series is already bucketed in the reader's zone, so it is parsed\n * and formatted as UTC (see `shortDate`): the label is a pure function\n * of the string, and server and browser in different zones render the\n * same day instead of a hydration mismatch.\n */\nfunction dateOf(iso: string): Date {\n const [year, month, day] = iso.split(\"-\").map(Number);\n return new Date(Date.UTC(year ?? 1970, (month ?? 1) - 1, day ?? 1));\n}\n\nexport function UsageChart({ points }: UsageChartProps) {\n const t = useTranslations(\"usage\");\n const format = useFormatter();\n const gradientId = useId();\n const [active, setActive] = useState<number | null>(null);\n\n // A period with a single day has no line to draw, and an all-zero\n // period would divide by zero below.\n if (points.length < 2) return null;\n\n const max = Math.max(...points.map((point) => point.tokensUsed), 1);\n const plotHeight = VIEWBOX_HEIGHT - PADDING_TOP - PADDING_BOTTOM;\n const step = VIEWBOX_WIDTH / (points.length - 1);\n\n const x = (index: number) => index * step;\n const y = (value: number) =>\n PADDING_TOP + plotHeight - (value / max) * plotHeight;\n\n /** The same mapping the path uses, as a fraction of the box. */\n const topPercent = (value: number) => (y(value) / VIEWBOX_HEIGHT) * 100;\n const leftPercent = (index: number) => (index / (points.length - 1)) * 100;\n\n const line = points\n .map((point, index) => `${x(index)},${y(point.tokensUsed)}`)\n .join(\" \");\n const area = `${line} ${VIEWBOX_WIDTH},${PADDING_TOP + plotHeight} 0,${PADDING_TOP + plotHeight}`;\n\n const hovered = active === null ? null : points[active];\n const compact = (value: number) =>\n format.number(value, { notation: \"compact\", maximumFractionDigits: 1 });\n // `timeZone: \"UTC\"` pairs with `dateOf`: the day is already the\n // reader's, so this formats the label, it does not convert it.\n const shortDate = (date: Date) =>\n format.dateTime(date, { month: \"short\", day: \"numeric\", timeZone: \"UTC\" });\n\n // Start, middle and end: enough to place any point, without the\n // crowding a label per day would bring at thirty days.\n const axisDates = [0, Math.floor((points.length - 1) / 2), points.length - 1];\n\n return (\n <figure className=\"space-y-2\">\n <figcaption className=\"text-sm font-medium text-muted-foreground\">\n {t(\"chart.title\")}\n </figcaption>\n\n <div className=\"flex gap-2\">\n {/* The scale, as HTML so it keeps its type size at any width. */}\n <div\n aria-hidden=\"true\"\n className=\"relative h-44 w-10 shrink-0 text-xs text-muted-foreground\"\n >\n {[max, max / 2, 0].map((value, index) => (\n <span\n key={value}\n className=\"absolute right-0 -translate-y-1/2 tabular-nums\"\n style={{ top: `${topPercent(index === 2 ? 0 : value)}%` }}\n >\n {compact(Math.round(value))}\n </span>\n ))}\n </div>\n\n <div className=\"relative min-w-0 flex-1\">\n <svg\n viewBox={`0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`}\n className=\"h-44 w-full\"\n role=\"img\"\n aria-label={t(\"chart.ariaLabel\")}\n preserveAspectRatio=\"none\"\n >\n <defs>\n <linearGradient id={gradientId} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop\n offset=\"0%\"\n stopColor=\"var(--primary)\"\n stopOpacity=\"0.28\"\n />\n <stop\n offset=\"100%\"\n stopColor=\"var(--primary)\"\n stopOpacity=\"0\"\n />\n </linearGradient>\n </defs>\n\n {/* Recessive grid, at the three values the scale names. */}\n {[0, 0.5, 1].map((fraction) => (\n <line\n key={fraction}\n x1=\"0\"\n x2={VIEWBOX_WIDTH}\n y1={PADDING_TOP + plotHeight * fraction}\n y2={PADDING_TOP + plotHeight * fraction}\n stroke=\"var(--border)\"\n strokeWidth=\"1\"\n vectorEffect=\"non-scaling-stroke\"\n />\n ))}\n\n <polygon points={area} fill={`url(#${gradientId})`} />\n <polyline\n points={line}\n fill=\"none\"\n stroke=\"var(--primary)\"\n strokeWidth=\"2\"\n strokeLinejoin=\"round\"\n strokeLinecap=\"round\"\n vectorEffect=\"non-scaling-stroke\"\n />\n\n {active !== null ? (\n <line\n x1={x(active)}\n x2={x(active)}\n y1={PADDING_TOP}\n y2={PADDING_TOP + plotHeight}\n stroke=\"var(--border)\"\n strokeWidth=\"1\"\n vectorEffect=\"non-scaling-stroke\"\n />\n ) : null}\n </svg>\n\n {/* A marker on every day that had usage, so a month with one\n busy day reads as a point rather than a slope. */}\n {points.map((point, index) =>\n point.tokensUsed > 0 ? (\n <span\n key={`dot-${point.date}`}\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute size-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary\"\n style={{\n left: `${leftPercent(index)}%`,\n top: `${topPercent(point.tokensUsed)}%`,\n }}\n />\n ) : null\n )}\n\n {active !== null ? (\n <span\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary ring-2 ring-background\"\n style={{\n left: `${leftPercent(active)}%`,\n top: `${topPercent(points[active]!.tokensUsed)}%`,\n }}\n />\n ) : null}\n\n {/* Hit targets: full-height slices, reachable by pointer and\n by keyboard, so the series is not pointer-only. */}\n <div\n className=\"absolute inset-0 flex\"\n onMouseLeave={() => setActive(null)}\n >\n {points.map((point, index) => (\n <button\n key={point.date}\n type=\"button\"\n tabIndex={0}\n className=\"h-full flex-1 cursor-default rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n aria-label={`${shortDate(dateOf(point.date))}: ${t(\"chart.tokensOnDay\", { tokens: point.tokensUsed })}`}\n onMouseEnter={() => setActive(index)}\n onFocus={() => setActive(index)}\n onBlur={() => setActive(null)}\n />\n ))}\n </div>\n\n {hovered ? (\n <div\n className=\"pointer-events-none absolute top-0 z-10 rounded-md border bg-popover px-2 py-1 text-xs shadow-sm\"\n style={{\n left: `${leftPercent(active!)}%`,\n // Clamped at the ends, where centring would hang the\n // card off the side of the chart.\n transform:\n active === 0\n ? \"translateX(0)\"\n : active === points.length - 1\n ? \"translateX(-100%)\"\n : \"translateX(-50%)\",\n }}\n >\n <p className=\"font-medium\">{shortDate(dateOf(hovered.date))}</p>\n <p className=\"text-muted-foreground\">\n {t(\"chart.tokensOnDay\", { tokens: hovered.tokensUsed })}\n </p>\n </div>\n ) : null}\n </div>\n </div>\n\n <div className=\"flex justify-between pl-12 text-xs text-muted-foreground\">\n {axisDates.map((index, position) => (\n <span\n key={points[index]!.date}\n className={position === 1 ? \"hidden sm:inline\" : undefined}\n >\n {shortDate(dateOf(points[index]!.date))}\n </span>\n ))}\n </div>\n\n {/* The same numbers, reachable without the chart. */}\n <table className=\"sr-only\">\n <caption>{t(\"chart.tableCaption\")}</caption>\n <thead>\n <tr>\n <th scope=\"col\">{t(\"chart.columns.date\")}</th>\n <th scope=\"col\">{t(\"chart.columns.tokens\")}</th>\n <th scope=\"col\">{t(\"chart.columns.requests\")}</th>\n </tr>\n </thead>\n <tbody>\n {points.map((point) => (\n <tr key={point.date}>\n <th scope=\"row\">{point.date}</th>\n <td>{format.number(point.tokensUsed)}</td>\n <td>{format.number(point.requestCount)}</td>\n </tr>\n ))}\n </tbody>\n </table>\n </figure>\n );\n}\n",
55
+ "type": "registry:component",
56
+ "target": "components/usage/usage-chart.tsx"
57
+ },
58
+ {
59
+ "path": "base/usage/components/usage-records-table.tsx",
60
+ "content": "import { getFormatter, getTranslations } from \"next-intl/server\";\n\nimport { Card, CardContent, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport { StatusBadge } from \"@/components/ui/status-badge\";\nimport { formatMoney } from \"@/lib/format-money\";\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from \"@/components/ui/table\";\n\nimport type { UsageRecord } from \"@/actions/usage\";\n\nimport { UsageEmptyState } from \"./usage-empty-state\";\n\n/** Status is a muted dot and a word; only a failure carries colour. */\nconst STATUS_VARIANT: Record<\n UsageRecord[\"status\"],\n \"success\" | \"warning\" | \"destructive\" | \"neutral\"\n> = {\n succeeded: \"success\",\n running: \"warning\",\n settling: \"warning\",\n failed: \"destructive\",\n refused: \"neutral\",\n};\n\nexport async function UsageRecordsTable({\n records,\n}: {\n records: UsageRecord[];\n}) {\n const t = await getTranslations(\"usage\");\n const format = await getFormatter();\n const empty = t(\"recordsTable.empty\");\n\n if (records.length === 0) {\n return (\n <UsageEmptyState\n title={t(\"recordsTable.emptyTitle\")}\n description={t(\"recordsTable.emptyDescription\")}\n />\n );\n }\n\n return (\n <Card>\n <CardHeader>\n <CardTitle>{t(\"recordsTable.title\")}</CardTitle>\n </CardHeader>\n <CardContent>\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>{t(\"recordsTable.columns.started\")}</TableHead>\n <TableHead>{t(\"recordsTable.columns.capability\")}</TableHead>\n <TableHead className=\"hidden md:table-cell\">\n {t(\"recordsTable.columns.model\")}\n </TableHead>\n <TableHead>{t(\"recordsTable.columns.status\")}</TableHead>\n <TableHead className=\"hidden text-right sm:table-cell\">\n {t(\"recordsTable.columns.tokens\")}\n </TableHead>\n <TableHead className=\"text-right\">\n {t(\"recordsTable.columns.chargedAmount\")}\n </TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {records.map((record) => (\n <TableRow key={record.id}>\n <TableCell className=\"whitespace-nowrap text-muted-foreground\">\n {format.dateTime(new Date(record.startedAt), {\n dateStyle: \"medium\",\n timeStyle: \"short\",\n })}\n </TableCell>\n <TableCell>\n {record.capability}\n {/* The model column is hidden on small screens, so\n the primary cell carries it there. */}\n <span className=\"block truncate text-xs text-muted-foreground md:hidden\">\n {record.model ?? empty}\n </span>\n </TableCell>\n <TableCell className=\"hidden md:table-cell\">\n {record.model ?? empty}\n </TableCell>\n <TableCell>\n <StatusBadge status={STATUS_VARIANT[record.status]} dot>\n {t(`recordsTable.status.${record.status}`)}\n </StatusBadge>\n </TableCell>\n <TableCell className=\"hidden text-right sm:table-cell\">\n {record.totalTokens !== null\n ? format.number(record.totalTokens)\n : empty}\n </TableCell>\n <TableCell className=\"text-right\">\n {record.charged ? formatMoney(format, record.charged) : empty}\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </Table>\n </CardContent>\n </Card>\n );\n}\n",
61
+ "type": "registry:component",
62
+ "target": "components/usage/usage-records-table.tsx"
63
+ },
64
+ {
65
+ "path": "base/usage/components/usage-empty-state.tsx",
66
+ "content": "import { BarChart3 } from \"lucide-react\";\nimport { getTranslations } from \"next-intl/server\";\n\nimport { Link } from \"@/i18n/navigation\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardContent } from \"@/components/ui/card\";\n\n/**\n * Empty state for the usage surface, linking to the chat surface. A\n * product without one points `ctaHref` where its own work starts.\n */\nexport async function UsageEmptyState({\n title,\n description,\n ctaHref = \"/chat\",\n}: {\n title?: string;\n description?: string;\n ctaHref?: string;\n}) {\n const t = await getTranslations(\"usage\");\n\n return (\n <Card>\n <CardContent className=\"flex flex-col items-center gap-3 px-6 py-12 text-center\">\n <span className=\"flex size-10 items-center justify-center rounded-full bg-muted\">\n <BarChart3 className=\"size-5 text-muted-foreground\" />\n </span>\n <div className=\"space-y-1\">\n <p className=\"font-medium\">{title ?? t(\"emptyState.defaultTitle\")}</p>\n <p className=\"max-w-sm text-sm text-muted-foreground\">\n {description ?? t(\"emptyState.defaultDescription\")}\n </p>\n </div>\n <Button\n size=\"sm\"\n className=\"mt-1\"\n render={<Link href={ctaHref} />}\n nativeButton={false}\n >\n {t(\"emptyState.cta\")}\n </Button>\n </CardContent>\n </Card>\n );\n}\n",
67
+ "type": "registry:component",
68
+ "target": "components/usage/usage-empty-state.tsx"
69
+ },
70
+ {
71
+ "path": "base/usage/messages/en.json",
72
+ "content": "{\n \"meta\": {\n \"title\": \"Usage\"\n },\n \"page\": {\n \"title\": \"Usage\",\n \"description\": \"Track this workspace's AI usage, charges, and plan quota.\"\n },\n \"emptyState\": {\n \"defaultTitle\": \"No usage yet\",\n \"defaultDescription\": \"Usage will show up here once this workspace starts making AI requests.\",\n \"cta\": \"Start a chat\"\n },\n \"recordsTable\": {\n \"title\": \"Recent requests\",\n \"emptyTitle\": \"No requests yet\",\n \"emptyDescription\": \"Recent AI requests for this workspace will appear here.\",\n \"empty\": \"—\",\n \"columns\": {\n \"started\": \"Started\",\n \"capability\": \"Capability\",\n \"model\": \"Model\",\n \"status\": \"Status\",\n \"tokens\": \"Tokens\",\n \"chargedAmount\": \"Charged amount\"\n },\n \"status\": {\n \"succeeded\": \"Succeeded\",\n \"running\": \"Running\",\n \"settling\": \"Settling\",\n \"failed\": \"Failed\",\n \"refused\": \"Refused\"\n }\n },\n \"summaryCards\": {\n \"periods\": {\n \"7d\": \"Last 7 days\",\n \"30d\": \"Last 30 days\",\n \"current\": \"This month\"\n },\n \"tokensUsed\": \"Tokens used\",\n \"chargedAmount\": \"Charged amount\",\n \"requests\": \"Requests\",\n \"planQuota\": \"Plan quota\",\n \"quotaBadge\": {\n \"limitReached\": \"Limit reached\",\n \"nearingLimit\": \"Nearing limit\",\n \"withinLimit\": \"Within limit\"\n },\n \"quotaAllowance\": \"{percentage}% of the {planName} plan's monthly allowance\",\n \"quotaAllowanceCredit\": \"{percentage}% of the {planName} plan's monthly allowance (credit balance)\",\n \"trialCredits\": \"Trial credits\",\n \"trialRemaining\": \"{count} remaining\",\n \"trialOf\": \"of {count}\",\n \"periodFailed\": \"Couldn't load that period. Showing the previous one.\",\n \"noPlan\": \"No plan\"\n },\n \"unavailable\": {\n \"title\": \"Usage unavailable\"\n },\n \"errors\": {\n \"loadOverview\": \"Failed to load usage\",\n \"loadSummary\": \"Failed to load usage summary\"\n },\n \"chart\": {\n \"title\": \"Tokens per day\",\n \"ariaLabel\": \"Tokens used per day over the current period\",\n \"tokensOnDay\": \"{tokens, number} tokens\",\n \"tableCaption\": \"Daily usage for the current period\",\n \"columns\": {\n \"date\": \"Date\",\n \"tokens\": \"Tokens\",\n \"requests\": \"Requests\"\n }\n }\n}\n",
73
+ "type": "registry:file",
74
+ "target": "messages/en/usage.json"
75
+ },
76
+ {
77
+ "path": "base/usage/error.tsx",
78
+ "content": "\"use client\";\n\n/**\n * Renders the shared `RouteError` from the `route-error` item — install\n * 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=\"usage\" />;\n}\n",
79
+ "type": "registry:page",
80
+ "target": "app/[locale]/(app)/usage/error.tsx"
81
+ }
82
+ ],
83
+ "type": "registry:block"
84
+ }
@@ -0,0 +1,71 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "workspace-settings",
4
+ "title": "Workspace Settings",
5
+ "description": "Workspace name/URL editing and owner-only deletion, backed by the framework's workspace service.",
6
+ "dependencies": [
7
+ "@intelligo-dev/auth",
8
+ "@intelligo-dev/core",
9
+ "@intelligo-dev/billing",
10
+ "@intelligo-dev/next",
11
+ "drizzle-orm",
12
+ "sonner",
13
+ "server-only",
14
+ "next-intl"
15
+ ],
16
+ "registryDependencies": [
17
+ "@intelligo/button",
18
+ "card",
19
+ "@intelligo/dialog",
20
+ "@intelligo/input",
21
+ "label",
22
+ "skeleton",
23
+ "@intelligo/page-header",
24
+ "@intelligo/hold-action-button"
25
+ ],
26
+ "files": [
27
+ {
28
+ "path": "base/workspace-settings/page.tsx",
29
+ "content": "/**\n * A `member` gets a read-only view of the same form; only an owner sees\n * the danger zone.\n */\n\nimport { getTranslations } from \"next-intl/server\";\n\nimport { requireWorkspace } from \"@intelligo-dev/auth\";\n\nimport { WorkspaceSettingsForm } from \"@/components/workspace/workspace-settings-form\";\nimport {\n PageHeader,\n PageHeaderContent,\n PageHeaderDescription,\n PageHeaderTitle,\n} from \"@/components/ui/page-header\";\n\nexport default async function WorkspaceSettingsPage() {\n const t = await getTranslations(\"workspace-settings\");\n const { workspace, membership } = await requireWorkspace();\n\n const canEdit = membership.role === \"owner\" || membership.role === \"admin\";\n const isOwner = membership.role === \"owner\";\n\n return (\n <div className=\"space-y-6\">\n <PageHeader>\n <PageHeaderContent>\n <PageHeaderTitle level={2}>{t(\"page.title\")}</PageHeaderTitle>\n <PageHeaderDescription>{t(\"page.description\")}</PageHeaderDescription>\n </PageHeaderContent>\n </PageHeader>\n\n <WorkspaceSettingsForm\n workspace={workspace}\n canEdit={canEdit}\n isOwner={isOwner}\n />\n </div>\n );\n}\n",
30
+ "type": "registry:page",
31
+ "target": "app/[locale]/(app)/settings/workspace/page.tsx"
32
+ },
33
+ {
34
+ "path": "base/workspace-settings/loading.tsx",
35
+ "content": "import { Card } from \"@/components/ui/card\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\n\nexport default function WorkspaceSettingsLoading() {\n return (\n <div className=\"space-y-6\">\n <div>\n <Skeleton className=\"h-7 w-56\" />\n <Skeleton className=\"mt-2 h-4 w-80\" />\n </div>\n\n <Card className=\"p-6\">\n <div className=\"space-y-6\">\n <div className=\"space-y-2\">\n <Skeleton className=\"h-4 w-32\" />\n <Skeleton className=\"h-10 w-full\" />\n </div>\n\n <div className=\"space-y-2\">\n <Skeleton className=\"h-4 w-32\" />\n <Skeleton className=\"h-10 w-full\" />\n <Skeleton className=\"h-3 w-48\" />\n </div>\n\n <div className=\"flex justify-end\">\n <Skeleton className=\"h-10 w-32\" />\n </div>\n </div>\n </Card>\n </div>\n );\n}\n",
36
+ "type": "registry:page",
37
+ "target": "app/[locale]/(app)/settings/workspace/loading.tsx"
38
+ },
39
+ {
40
+ "path": "base/workspace-settings/components/workspace-settings-form.tsx",
41
+ "content": "\"use client\";\n\n/**\n * Workspace settings form — name/slug editor plus an owner-only danger\n * zone. Owners and admins can edit; a plain member sees a read-only\n * form. Only an owner sees the delete-workspace control, gated behind\n * a confirmation dialog whose button is held, not clicked.\n */\n\nimport { useEffect, useState, useTransition, type FormEvent } from \"react\";\nimport { useTranslations } from \"next-intl\";\nimport { toast } from \"sonner\";\n\nimport { useRouter } from \"@/i18n/navigation\";\nimport { updateWorkspace, deleteWorkspace } from \"@/actions/workspace\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from \"@/components/ui/card\";\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { HoldActionButton } from \"@/components/ui/hold-action-button\";\n\ninterface WorkspaceSettingsFormProps {\n workspace: {\n id: string;\n name: string;\n slug: string;\n logo?: string | null;\n };\n canEdit: boolean;\n isOwner: boolean;\n}\n\nexport function WorkspaceSettingsForm({\n workspace,\n canEdit,\n isOwner,\n}: WorkspaceSettingsFormProps) {\n const t = useTranslations(\"workspace-settings\");\n const router = useRouter();\n const [isPending, startTransition] = useTransition();\n const [name, setName] = useState(workspace.name);\n const [slug, setSlug] = useState(workspace.slug);\n const [error, setError] = useState<string | null>(null);\n const [deleteOpen, setDeleteOpen] = useState(false);\n const [isDeleting, setIsDeleting] = useState(false);\n\n /**\n * Read after mount, never during render: the server has no window,\n * and an origin baked into the HTML would differ from the browser's\n * on any deployment reached by more than one hostname. Until it\n * arrives the helper shows the path alone, which is already true.\n */\n const [origin, setOrigin] = useState(\"\");\n useEffect(() => setOrigin(window.location.origin), []);\n\n function handleSubmit(event: FormEvent<HTMLFormElement>) {\n event.preventDefault();\n setError(null);\n\n startTransition(async () => {\n const result = await updateWorkspace({ name, slug });\n if (!result.success) {\n setError(result.error);\n return;\n }\n toast.success(t(\"form.updated\"));\n router.refresh();\n });\n }\n\n function handleDelete() {\n setIsDeleting(true);\n startTransition(async () => {\n const result = await deleteWorkspace();\n if (!result.success) {\n toast.error(result.error);\n setIsDeleting(false);\n setDeleteOpen(false);\n return;\n }\n toast.success(t(\"dangerZone.deleted\"));\n router.push(\"/dashboard\");\n router.refresh();\n });\n }\n\n return (\n <>\n <Card>\n <CardHeader>\n <CardTitle>{t(\"form.generalTitle\")}</CardTitle>\n <CardDescription>{t(\"form.generalDescription\")}</CardDescription>\n </CardHeader>\n <CardContent>\n <form onSubmit={handleSubmit} className=\"space-y-6\">\n {error && <p className=\"text-sm text-destructive\">{error}</p>}\n\n <div className=\"space-y-2\">\n <Label htmlFor=\"name\">{t(\"form.nameLabel\")}</Label>\n <Input\n id=\"name\"\n value={name}\n onChange={(event) => setName(event.target.value)}\n disabled={!canEdit || isPending}\n required\n minLength={2}\n maxLength={50}\n />\n </div>\n\n <div className=\"space-y-2\">\n <Label htmlFor=\"slug\">{t(\"form.urlLabel\")}</Label>\n <Input\n id=\"slug\"\n value={slug}\n onChange={(event) => setSlug(event.target.value)}\n disabled={!canEdit || isPending}\n required\n minLength={2}\n maxLength={50}\n pattern=\"[a-z0-9-]+\"\n />\n {/* The address the typed slug produces, not the saved one. */}\n <p className=\"text-xs text-muted-foreground\">\n {origin}/{slug}\n </p>\n </div>\n\n {canEdit ? (\n <div className=\"flex justify-end\">\n <Button type=\"submit\" disabled={isPending}>\n {isPending ? t(\"form.savePending\") : t(\"form.save\")}\n </Button>\n </div>\n ) : (\n <p className=\"text-sm text-muted-foreground\">\n {t(\"form.readOnlyNote\")}\n </p>\n )}\n </form>\n </CardContent>\n </Card>\n\n {isOwner && (\n <Card className=\"border-destructive/30\">\n <CardHeader>\n <CardTitle className=\"text-destructive\">\n {t(\"dangerZone.title\")}\n </CardTitle>\n <CardDescription>{t(\"dangerZone.description\")}</CardDescription>\n </CardHeader>\n <CardContent>\n <Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>\n <DialogTrigger render={<Button variant=\"destructive\" />}>\n {t(\"dangerZone.trigger\")}\n </DialogTrigger>\n <DialogContent>\n <DialogHeader>\n <DialogTitle>{t(\"dangerZone.dialogTitle\")}</DialogTitle>\n <DialogDescription>\n {t(\"dangerZone.dialogDescription\", {\n name: workspace.name,\n })}\n </DialogDescription>\n </DialogHeader>\n <DialogFooter>\n <Button\n variant=\"outline\"\n onClick={() => setDeleteOpen(false)}\n disabled={isDeleting}\n >\n {t(\"dangerZone.cancel\")}\n </Button>\n {/* Held, not clicked: a workspace goes with everything in it. */}\n <HoldActionButton\n size=\"default\"\n onConfirm={handleDelete}\n disabled={isDeleting}\n labels={{\n holding: t(\"dangerZone.holding\"),\n complete: t(\"dangerZone.confirmPending\"),\n description: t(\"dangerZone.holdDescription\"),\n confirmed: t(\"dangerZone.confirmPending\"),\n }}\n >\n {isDeleting\n ? t(\"dangerZone.confirmPending\")\n : t(\"dangerZone.confirm\")}\n </HoldActionButton>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n </CardContent>\n </Card>\n )}\n </>\n );\n}\n",
42
+ "type": "registry:component",
43
+ "target": "components/workspace/workspace-settings-form.tsx"
44
+ },
45
+ {
46
+ "path": "base/workspace-settings/actions.ts",
47
+ "content": "\"use server\";\n\n/**\n * Workspace settings server actions — thin transport over the bound\n * workspace service (`@/lib/workspace`): parse input with the schemas\n * from `@intelligo-dev/auth`, call the service, map any\n * `WorkspaceServiceError` to a friendly message, and revalidate the\n * page. No business rules here — those live in the service.\n */\n\nimport { revalidatePath } from \"next/cache\";\nimport { getTranslations } from \"next-intl/server\";\n\nimport {\n isWorkspaceServiceError,\n updateWorkspaceSchema,\n type UpdateWorkspaceInput,\n} from \"@intelligo-dev/auth\";\nimport type { ActionResult } from \"@intelligo-dev/next\";\n\nimport { workspace } from \"@/lib/workspace\";\n\nexport type WorkspaceActionResult<T = undefined> = ActionResult<T>;\n\ntype Translator = Awaited<\n ReturnType<typeof getTranslations<\"workspace-settings\">>\n>;\n\nfunction friendlyMessage(t: Translator): Partial<Record<string, string>> {\n return {\n forbidden: t(\"errors.forbidden\"),\n invalid_input: t(\"errors.invalidInput\"),\n not_found: t(\"errors.notFound\"),\n workspace_limit_reached: t(\"errors.workspaceLimitReached\"),\n provider_error: t(\"errors.providerError\"),\n };\n}\n\nfunction friendlyError(error: unknown, t: Translator): string {\n // Unknown errors deliberately map to the generic key — a raw\n // `Error#message` can carry internals (SQL, hostnames) to the UI.\n if (isWorkspaceServiceError(error)) {\n return friendlyMessage(t)[error.code] ?? t(\"errors.somethingWentWrong\");\n }\n return t(\"errors.somethingWentWrong\");\n}\n\nexport async function updateWorkspace(\n input: UpdateWorkspaceInput\n): Promise<WorkspaceActionResult> {\n const t = await getTranslations(\"workspace-settings\");\n const parsed = updateWorkspaceSchema.safeParse(input);\n if (!parsed.success) {\n return {\n success: false,\n error: parsed.error.issues[0]?.message ?? t(\"errors.invalidInput\"),\n };\n }\n\n try {\n await workspace.updateWorkspace(parsed.data);\n revalidatePath(\"/settings/workspace\");\n return { success: true, data: undefined };\n } catch (error) {\n return { success: false, error: friendlyError(error, t) };\n }\n}\n\nexport async function deleteWorkspace(): Promise<WorkspaceActionResult> {\n const t = await getTranslations(\"workspace-settings\");\n try {\n await workspace.deleteWorkspace();\n revalidatePath(\"/\", \"layout\");\n return { success: true, data: undefined };\n } catch (error) {\n return { success: false, error: friendlyError(error, t) };\n }\n}\n",
48
+ "type": "registry:file",
49
+ "target": "actions/workspace.ts"
50
+ },
51
+ {
52
+ "path": "base/workspace-settings/lib/workspace.ts",
53
+ "content": "import \"server-only\";\n\n/**\n * Binds billing into the workspace service (`@intelligo-dev/auth`): the\n * plan's workspace limit (`checkPlanLimit`), and ending the Stripe\n * subscription of a workspace before it is deleted\n * (`cancelWorkspaceSubscription`).\n *\n * `checkPlanLimit` is keyed by `workspaceId`, but the service's\n * `checkWorkspaceLimit` port is keyed by `userId`: a new workspace has\n * no id to check a plan against yet. This binding checks the plan of\n * the oldest workspace the user owns — not one they were invited into,\n * whose plan someone else pays for.\n */\n\nimport { and, eq } from \"drizzle-orm\";\n\nimport { db } from \"@intelligo-dev/core/db\";\nimport { member } from \"@intelligo-dev/core/db/schema\";\nimport { createWorkspaceService } from \"@intelligo-dev/auth\";\nimport {\n cancelWorkspaceSubscription,\n checkPlanLimit,\n} from \"@intelligo-dev/billing\";\n\nexport const workspace = createWorkspaceService({\n checkWorkspaceLimit: async (userId, currentCount) => {\n const [membership] = await db\n .select({ organizationId: member.organizationId })\n .from(member)\n .where(and(eq(member.userId, userId), eq(member.role, \"owner\")))\n .orderBy(member.createdAt)\n .limit(1);\n\n // No owned workspace to price a plan against yet — allow. The\n // service itself never calls this port for a caller's first\n // workspace, but a defensive default keeps this binding correct on\n // its own too.\n if (!membership) {\n return { allowed: true, limit: -1 };\n }\n\n const result = await checkPlanLimit(\n membership.organizationId,\n \"workspaces\",\n currentCount\n );\n return { allowed: result.allowed, limit: result.limit };\n },\n beforeDeleteWorkspace: cancelWorkspaceSubscription,\n});\n",
54
+ "type": "registry:file",
55
+ "target": "lib/workspace.ts"
56
+ },
57
+ {
58
+ "path": "base/workspace-settings/messages/en.json",
59
+ "content": "{\n \"page\": {\n \"title\": \"Workspace settings\",\n \"description\": \"Manage your workspace's name and URL.\"\n },\n \"form\": {\n \"generalTitle\": \"General\",\n \"generalDescription\": \"Update your workspace's name and URL.\",\n \"nameLabel\": \"Workspace name\",\n \"urlLabel\": \"Workspace URL\",\n \"save\": \"Save changes\",\n \"savePending\": \"Saving…\",\n \"readOnlyNote\": \"Only workspace owners and admins can edit these settings.\",\n \"updated\": \"Workspace updated\"\n },\n \"dangerZone\": {\n \"title\": \"Danger zone\",\n \"description\": \"Deleting this workspace permanently removes it and all associated data. This cannot be undone.\",\n \"trigger\": \"Delete workspace\",\n \"dialogTitle\": \"Delete workspace\",\n \"dialogDescription\": \"Are you sure you want to delete {name}? This action cannot be undone and all workspace data will be permanently deleted.\",\n \"cancel\": \"Cancel\",\n \"confirm\": \"Hold to delete\",\n \"holding\": \"Keep holding…\",\n \"holdDescription\": \"Press and hold to delete the workspace. Release to cancel.\",\n \"confirmPending\": \"Deleting…\",\n \"deleted\": \"Workspace deleted\"\n },\n \"errors\": {\n \"forbidden\": \"You don't have permission to do that.\",\n \"notFound\": \"This workspace no longer exists.\",\n \"workspaceLimitReached\": \"Your plan's workspace limit has been reached. Upgrade to create more workspaces.\",\n \"providerError\": \"Something went wrong. Please try again.\",\n \"somethingWentWrong\": \"Something went wrong.\",\n \"invalidInput\": \"Invalid input\"\n }\n}\n",
60
+ "type": "registry:file",
61
+ "target": "messages/en/workspace-settings.json"
62
+ },
63
+ {
64
+ "path": "base/workspace-settings/error.tsx",
65
+ "content": "\"use client\";\n\n/**\n * Error boundary for workspace 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-workspace\" />;\n}\n",
66
+ "type": "registry:page",
67
+ "target": "app/[locale]/(app)/settings/workspace/error.tsx"
68
+ }
69
+ ],
70
+ "type": "registry:block"
71
+ }