@intelligo-dev/cli 1.0.0-beta.13 → 1.0.0-beta.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -2
- package/dist/bin.js +37 -0
- package/dist/bin.js.map +1 -1
- package/dist/commands/add.d.ts +4 -0
- package/dist/commands/add.d.ts.map +1 -1
- package/dist/commands/add.js +9 -0
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/create-flow.d.ts +4 -1
- package/dist/commands/create-flow.d.ts.map +1 -1
- package/dist/commands/create-flow.js +63 -31
- package/dist/commands/create-flow.js.map +1 -1
- package/dist/commands/create.d.ts +8 -4
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +15 -7
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +21 -19
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/migrate-check.d.ts +20 -1
- package/dist/commands/migrate-check.d.ts.map +1 -1
- package/dist/commands/migrate-check.js +35 -6
- package/dist/commands/migrate-check.js.map +1 -1
- package/dist/commands/migrate.d.ts.map +1 -1
- package/dist/commands/migrate.js +2 -8
- package/dist/commands/migrate.js.map +1 -1
- package/dist/commands/sync-messages.d.ts +37 -0
- package/dist/commands/sync-messages.d.ts.map +1 -0
- package/dist/commands/sync-messages.js +88 -0
- package/dist/commands/sync-messages.js.map +1 -0
- package/dist/commands/sync-scaffold.d.ts +36 -0
- package/dist/commands/sync-scaffold.d.ts.map +1 -0
- package/dist/commands/sync-scaffold.js +53 -0
- package/dist/commands/sync-scaffold.js.map +1 -0
- package/dist/commands/sync.d.ts +103 -0
- package/dist/commands/sync.d.ts.map +1 -0
- package/dist/commands/sync.js +361 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/commands/upgrade-check.d.ts.map +1 -1
- package/dist/commands/upgrade-check.js +4 -1
- package/dist/commands/upgrade-check.js.map +1 -1
- package/dist/manifest.d.ts +27 -0
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +30 -3
- package/dist/manifest.js.map +1 -1
- package/dist/module-exports.d.ts +63 -0
- package/dist/module-exports.d.ts.map +1 -0
- package/dist/module-exports.js +231 -0
- package/dist/module-exports.js.map +1 -0
- package/dist/registry-bundle.d.ts +43 -0
- package/dist/registry-bundle.d.ts.map +1 -0
- package/dist/registry-bundle.js +74 -0
- package/dist/registry-bundle.js.map +1 -0
- package/dist/registry-items.d.ts +41 -13
- package/dist/registry-items.d.ts.map +1 -1
- package/dist/registry-items.js +117 -33
- package/dist/registry-items.js.map +1 -1
- package/package.json +1 -1
- package/src/bin.ts +52 -0
- package/src/commands/add.ts +11 -0
- package/src/commands/create-flow.ts +82 -26
- package/src/commands/create.ts +17 -7
- package/src/commands/doctor.ts +27 -27
- package/src/commands/migrate-check.ts +51 -10
- package/src/commands/migrate.ts +2 -8
- package/src/commands/sync-messages.ts +114 -0
- package/src/commands/sync-scaffold.ts +83 -0
- package/src/commands/sync.ts +534 -0
- package/src/commands/upgrade-check.ts +4 -1
- package/src/manifest.ts +57 -3
- package/src/module-exports.ts +266 -0
- package/src/registry-bundle.ts +103 -0
- package/src/registry-items.ts +141 -36
- package/templates/app-scaffold/intelligo.ts.tpl +6 -47
- package/templates/manifest.json +19 -0
- package/templates/registry/ai-agent-activity.json +23 -0
- package/templates/registry/ai-agent-progress.json +21 -0
- package/templates/registry/ai-approval-card.json +27 -0
- package/templates/registry/ai-artifact.json +22 -0
- package/templates/registry/ai-branch.json +21 -0
- package/templates/registry/ai-citations.json +24 -0
- package/templates/registry/ai-code-block.json +23 -0
- package/templates/registry/ai-composer-menu.json +19 -0
- package/templates/registry/ai-file-diff.json +25 -0
- package/templates/registry/ai-image-generation.json +23 -0
- package/templates/registry/ai-markdown.json +23 -0
- package/templates/registry/ai-message-bubble.json +23 -0
- package/templates/registry/ai-message-scroller.json +22 -0
- package/templates/registry/ai-message.json +21 -0
- package/templates/registry/ai-motion.json +19 -0
- package/templates/registry/ai-prompt-input.json +28 -0
- package/templates/registry/ai-reasoning-text.json +22 -0
- package/templates/registry/ai-reasoning.json +22 -0
- package/templates/registry/ai-shimmer-text.json +19 -0
- package/templates/registry/ai-sidebar.json +25 -0
- package/templates/registry/ai-speech-input.json +21 -0
- package/templates/registry/ai-streaming-response.json +24 -0
- package/templates/registry/ai-suggestion.json +19 -0
- package/templates/registry/ai-todo-list.json +22 -0
- package/templates/registry/ai-tool-approval.json +26 -0
- package/templates/registry/ai-tool-result.json +25 -0
- package/templates/registry/alert-dialog.json +23 -0
- package/templates/registry/animated-list.json +21 -0
- package/templates/registry/app-shell.json +93 -0
- package/templates/registry/artifacts.json +82 -0
- package/templates/registry/attachment.json +22 -0
- package/templates/registry/auth-email-verification.json +39 -0
- package/templates/registry/auth-login.json +72 -0
- package/templates/registry/auth-password-reset.json +53 -0
- package/templates/registry/auth-signup.json +41 -0
- package/templates/registry/billing-settings.json +60 -0
- package/templates/registry/button.json +22 -0
- package/templates/registry/chat-eve.json +19 -0
- package/templates/registry/chat-panel.json +30 -0
- package/templates/registry/chat-share.json +42 -0
- package/templates/registry/chat-widget.json +34 -0
- package/templates/registry/chat.json +326 -0
- package/templates/registry/checkbox.json +22 -0
- package/templates/registry/checkout.json +40 -0
- package/templates/registry/collapsible.json +19 -0
- package/templates/registry/command.json +23 -0
- package/templates/registry/copy-button.json +21 -0
- package/templates/registry/dashboard.json +69 -0
- package/templates/registry/dialog.json +24 -0
- package/templates/registry/document-viewer.json +22 -0
- package/templates/registry/dropdown-menu.json +23 -0
- package/templates/registry/expandable-tabs.json +22 -0
- package/templates/registry/feature-gating.json +73 -0
- package/templates/registry/file-upload.json +24 -0
- package/templates/registry/hold-action-button.json +22 -0
- package/templates/registry/input-group.json +23 -0
- package/templates/registry/input.json +19 -0
- package/templates/registry/intelligo.json +187 -0
- package/templates/registry/invitation-accept.json +64 -0
- package/templates/registry/language-switcher.json +29 -0
- package/templates/registry/morphing-modal.json +24 -0
- package/templates/registry/notification-stack.json +24 -0
- package/templates/registry/notifications.json +87 -0
- package/templates/registry/onboarding.json +83 -0
- package/templates/registry/otp-input.json +22 -0
- package/templates/registry/page-header.json +15 -0
- package/templates/registry/payment-poll.json +52 -0
- package/templates/registry/popover-morph.json +22 -0
- package/templates/registry/popover.json +22 -0
- package/templates/registry/pricing.json +111 -0
- package/templates/registry/privacy-settings.json +65 -0
- package/templates/registry/profile-settings.json +68 -0
- package/templates/registry/progress.json +19 -0
- package/templates/registry/radio-group.json +22 -0
- package/templates/registry/registry.json +2945 -0
- package/templates/registry/route-error.json +65 -0
- package/templates/registry/select-morph.json +23 -0
- package/templates/registry/select.json +23 -0
- package/templates/registry/settings-shell.json +47 -0
- package/templates/registry/sheet.json +24 -0
- package/templates/registry/sidebar.json +28 -0
- package/templates/registry/smoke.json +34 -0
- package/templates/registry/spinner.json +16 -0
- package/templates/registry/stat-card.json +18 -0
- package/templates/registry/status-badge.json +18 -0
- package/templates/registry/switch.json +20 -0
- package/templates/registry/tabs.json +23 -0
- package/templates/registry/team-settings.json +97 -0
- package/templates/registry/textarea.json +16 -0
- package/templates/registry/tooltip.json +22 -0
- package/templates/registry/trial-banner.json +43 -0
- package/templates/registry/usage.json +84 -0
- package/templates/registry/workspace-settings.json +71 -0
- package/templates/registry-items.json +1 -1
- package/templates/registry-requires.json +26 -2
- package/templates/vitest/server-only.ts.tpl +7 -0
- package/templates/vitest/vitest.config.ts.tpl +37 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "notifications",
|
|
4
|
+
"title": "Notifications",
|
|
5
|
+
"description": "Notification bell with an unread badge and dropdown, plus a full notifications page with mark-as-read and load-more, backed by the framework's notification records.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"@intelligo-dev/auth",
|
|
8
|
+
"@intelligo-dev/core",
|
|
9
|
+
"@intelligo-dev/next",
|
|
10
|
+
"lucide-react",
|
|
11
|
+
"next-intl"
|
|
12
|
+
],
|
|
13
|
+
"registryDependencies": [
|
|
14
|
+
"@intelligo/button",
|
|
15
|
+
"@intelligo/popover",
|
|
16
|
+
"scroll-area",
|
|
17
|
+
"separator",
|
|
18
|
+
"skeleton",
|
|
19
|
+
"@intelligo/page-header",
|
|
20
|
+
"@intelligo/ai-motion",
|
|
21
|
+
"@intelligo/animated-list",
|
|
22
|
+
"@intelligo/notification-stack"
|
|
23
|
+
],
|
|
24
|
+
"files": [
|
|
25
|
+
{
|
|
26
|
+
"path": "base/notifications/page.tsx",
|
|
27
|
+
"content": "/**\n * The user's notifications across every workspace, newest first (20 at\n * first), in an uncontrolled `NotificationList` that handles mark-read\n * and \"Load more\" itself.\n */\n\nimport type { Metadata } from \"next\";\nimport { Suspense } from \"react\";\nimport { getTranslations } from \"next-intl/server\";\n\nimport { getNotifications } from \"@/actions/notifications\";\nimport { NotificationList } from \"@/components/notifications/notification-list\";\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(\"notifications\");\n return { title: t(\"page.title\") };\n}\n\nexport default async function NotificationsPage() {\n const t = await getTranslations(\"notifications\");\n\n return (\n <div className=\"container mx-auto max-w-2xl space-y-8 px-4 py-8\">\n <PageHeader>\n <PageHeaderContent>\n <PageHeaderTitle>{t(\"page.title\")}</PageHeaderTitle>\n <PageHeaderDescription>{t(\"page.subtitle\")}</PageHeaderDescription>\n </PageHeaderContent>\n </PageHeader>\n\n <Suspense fallback={null}>\n <NotificationsSection />\n </Suspense>\n </div>\n );\n}\n\nasync function NotificationsSection() {\n const result = await getNotifications();\n\n if (!result.success) {\n return (\n <div className=\"rounded-lg border p-6 text-sm text-destructive\">\n {result.error}\n </div>\n );\n }\n\n return (\n <div className=\"rounded-lg border\">\n <NotificationList notifications={result.data} variant=\"full\" />\n </div>\n );\n}\n",
|
|
28
|
+
"type": "registry:page",
|
|
29
|
+
"target": "app/[locale]/(app)/notifications/page.tsx"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"path": "base/notifications/loading.tsx",
|
|
33
|
+
"content": "import { Skeleton } from \"@/components/ui/skeleton\";\n\nexport default function NotificationsLoading() {\n return (\n <div className=\"container mx-auto max-w-2xl space-y-8 px-4 py-8\">\n <div className=\"space-y-2\">\n <Skeleton className=\"h-7 w-40\" />\n <Skeleton className=\"h-4 w-80\" />\n </div>\n\n <div className=\"rounded-lg border\">\n <div className=\"flex items-center justify-between px-4 py-3\">\n <Skeleton className=\"h-5 w-28\" />\n </div>\n <div className=\"border-t\">\n {[1, 2, 3, 4, 5].map((row) => (\n <div\n key={row}\n className=\"flex items-center gap-3 border-b px-4 py-3 last:border-b-0\"\n >\n <Skeleton className=\"size-2 rounded-full\" />\n <Skeleton className=\"size-4 rounded-full\" />\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-3 w-12\" />\n </div>\n ))}\n </div>\n </div>\n </div>\n );\n}\n",
|
|
34
|
+
"type": "registry:page",
|
|
35
|
+
"target": "app/[locale]/(app)/notifications/loading.tsx"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"path": "base/notifications/components/notification-bell.tsx",
|
|
39
|
+
"content": "\"use client\";\n\n/**\n * Bell with an unread badge that opens a dropdown of recent\n * notifications. Nothing mounts it for you: put it in the shell, e.g.\n * through `shellConfig.headerRight`.\n *\n * With no props it fetches on mount. Pass `initialCount` and\n * `initialNotifications` from a server component for a faster first\n * paint; it then skips that fetch.\n */\n\nimport { useState } from \"react\";\nimport { useTranslations } from \"next-intl\";\nimport { Bell } from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { SwapText } from \"@/components/ui/ai-motion\";\nimport { Link } from \"@/i18n/navigation\";\n\nimport { useNotifications } from \"@/hooks/use-notifications\";\nimport type { NotificationData } from \"@/actions/notifications\";\nimport { NotificationList } from \"./notification-list\";\n\nexport interface NotificationBellProps {\n initialCount?: number;\n initialNotifications?: NotificationData[];\n /** Where \"View all\" links. Matches this item's own installed page. */\n notificationsHref?: string;\n}\n\nexport function NotificationBell({\n initialCount,\n initialNotifications,\n notificationsHref = \"/notifications\",\n}: NotificationBellProps) {\n const [isOpen, setIsOpen] = useState(false);\n const t = useTranslations(\"notifications\");\n\n const { notifications, unreadCount, markRead, markAllRead } =\n useNotifications({\n initialCount,\n initialNotifications,\n // Skip polling while the user is reading the dropdown.\n paused: isOpen,\n });\n\n return (\n <Popover open={isOpen} onOpenChange={setIsOpen}>\n <PopoverTrigger\n render={\n <Button\n variant=\"ghost\"\n size=\"icon\"\n className=\"relative\"\n aria-label={\n unreadCount > 0\n ? t(\"bell.ariaLabelUnread\", { count: unreadCount })\n : t(\"bell.ariaLabel\")\n }\n />\n }\n >\n <Bell className=\"size-5\" />\n {unreadCount > 0 && (\n <span className=\"absolute -right-0.5 -top-0.5 flex h-4.5 min-w-4.5 items-center justify-center rounded-full bg-destructive px-1 text-xs text-destructive-foreground\">\n {/* A new count rolls in rather than replacing the old one. */}\n <SwapText value={String(unreadCount)}>\n {unreadCount > 99 ? \"99+\" : unreadCount}\n </SwapText>\n </span>\n )}\n </PopoverTrigger>\n <PopoverContent align=\"end\" className=\"w-96 p-0\">\n <NotificationList\n notifications={notifications}\n variant=\"compact\"\n onMarkRead={markRead}\n onMarkAllRead={markAllRead}\n onNavigate={() => setIsOpen(false)}\n footer={\n <Link\n href={notificationsHref}\n className=\"block border-t px-4 py-2 text-center text-sm text-muted-foreground transition-colors hover:text-foreground\"\n onClick={() => setIsOpen(false)}\n >\n {t(\"bell.viewAll\")}\n </Link>\n }\n />\n </PopoverContent>\n </Popover>\n );\n}\n",
|
|
40
|
+
"type": "registry:component",
|
|
41
|
+
"target": "components/notifications/notification-bell.tsx"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"path": "base/notifications/components/notification-list.tsx",
|
|
45
|
+
"content": "\"use client\";\n\n/**\n * The notification rows, a header with \"Mark all read\", and an empty\n * state. Two modes:\n *\n * - Controlled (the bell, `variant=\"compact\"`): pass `onMarkRead` and\n * `onMarkAllRead` from `useNotifications` so clicks keep its unread\n * count in sync.\n * - Uncontrolled (the page, `variant=\"full\"`): omit them and the list\n * keeps its own optimistic state, calls the server actions itself and\n * owns \"Load more\", which re-fetches with a larger limit (up to 100).\n */\n\nimport { useState, useTransition, type ReactNode } from \"react\";\nimport { useTranslations } from \"next-intl\";\nimport { Bell } from \"lucide-react\";\n\nimport { useRouter } from \"@/i18n/navigation\";\nimport { Button } from \"@/components/ui/button\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { AnimatedList, AnimatedListItem } from \"@/components/ui/animated-list\";\nimport { NotificationStack } from \"@/components/ui/notification-stack\";\n\nimport {\n getNotifications,\n markAllNotificationsRead,\n markNotificationRead,\n type NotificationData,\n} from \"@/actions/notifications\";\nimport {\n NotificationItem,\n notificationIcon,\n RelativeTime,\n} from \"./notification-item\";\n\nconst PAGE_SIZE = 20;\nconst MAX_LIMIT = 100;\n\nexport interface NotificationListProps {\n notifications: NotificationData[];\n variant?: \"compact\" | \"full\";\n /** Rendered below the list, e.g. a \"View all\" link in the bell dropdown. */\n footer?: ReactNode;\n /** Pass both to run this list in controlled mode — see file doc comment. */\n onMarkRead?: (id: string) => void;\n onMarkAllRead?: () => void;\n /** Called after opening a notification's link — the bell closes its dropdown. */\n onNavigate?: () => void;\n}\n\nexport function NotificationList({\n notifications,\n variant = \"full\",\n footer,\n onMarkRead,\n onMarkAllRead,\n onNavigate,\n}: NotificationListProps) {\n const t = useTranslations(\"notifications\");\n const router = useRouter();\n const controlled = onMarkRead !== undefined;\n\n const [ownedItems, setOwnedItems] = useState(notifications);\n const [limit, setLimit] = useState(PAGE_SIZE);\n const [isLoadingMore, setIsLoadingMore] = useState(false);\n const [, startTransition] = useTransition();\n\n const items = controlled ? notifications : ownedItems;\n const hasUnread = items.some((n) => !n.isRead);\n const canLoadMore =\n variant === \"full\" &&\n !controlled &&\n items.length >= limit &&\n limit < MAX_LIMIT;\n\n function handleMarkRead(id: string) {\n if (controlled) {\n onMarkRead?.(id);\n return;\n }\n setOwnedItems((prev) =>\n prev.map((n) => (n.id === id ? { ...n, isRead: true } : n))\n );\n startTransition(async () => {\n await markNotificationRead(id);\n });\n }\n\n function handleMarkAllRead() {\n if (controlled) {\n onMarkAllRead?.();\n return;\n }\n setOwnedItems((prev) => prev.map((n) => ({ ...n, isRead: true })));\n startTransition(async () => {\n await markAllNotificationsRead();\n });\n }\n\n function handleLoadMore() {\n const nextLimit = Math.min(limit + PAGE_SIZE, MAX_LIMIT);\n setIsLoadingMore(true);\n startTransition(async () => {\n const result = await getNotifications(nextLimit);\n if (result.success) {\n setOwnedItems(result.data);\n setLimit(nextLimit);\n }\n setIsLoadingMore(false);\n });\n }\n\n // Rows rise in one after another; one that arrives later rises alone.\n const rows = items.map((notification) => (\n <AnimatedListItem as=\"div\" key={notification.id}>\n <NotificationItem\n notification={notification}\n onMarkRead={handleMarkRead}\n />\n </AnimatedListItem>\n ));\n\n return (\n <div className=\"flex flex-col\">\n {/* The title shows only in the bell dropdown; the page has its\n own. The row is dropped when it would hold neither the title\n nor the mark-all action. */}\n {(variant === \"compact\" || hasUnread) && (\n <>\n <div\n className={`flex items-center px-4 py-3 ${\n variant === \"compact\" ? \"justify-between\" : \"justify-end\"\n }`}\n >\n {variant === \"compact\" ? (\n <h3 className=\"text-sm font-semibold\">{t(\"list.title\")}</h3>\n ) : null}\n {hasUnread && (\n <button\n type=\"button\"\n className=\"text-sm text-muted-foreground transition-colors hover:text-foreground\"\n onClick={handleMarkAllRead}\n >\n {t(\"list.markAllRead\")}\n </button>\n )}\n </div>\n <Separator />\n </>\n )}\n\n {items.length === 0 ? (\n <div className=\"flex flex-col items-center justify-center gap-2 py-12 text-center text-muted-foreground\">\n <Bell className=\"size-8\" />\n <p className=\"text-sm\">{t(\"list.emptyTitle\")}</p>\n <p className=\"max-w-xs text-xs\">{t(\"list.emptyDescription\")}</p>\n </div>\n ) : variant === \"compact\" && hasUnread ? (\n // The dropdown is an inbox of what is new: unread notifications\n // fanned into a stack that springs apart on hover. Opening one\n // or dismissing it marks it read, and it leaves the stack.\n <div className=\"p-3\">\n <NotificationStack\n items={items\n .filter((n) => !n.isRead)\n .map((n) => {\n const { Icon, color } = notificationIcon(n.type);\n return {\n id: n.id,\n title: n.title,\n description: n.message,\n time: <RelativeTime iso={n.createdAt} />,\n icon: <Icon className={`size-4 ${color}`} />,\n unread: true,\n };\n })}\n onItemClick={(item) => {\n handleMarkRead(item.id);\n const href = items.find((n) => n.id === item.id)?.href;\n if (href) {\n router.push(href);\n onNavigate?.();\n }\n }}\n onDismiss={(item) => handleMarkRead(item.id)}\n labels={{\n expand: t(\"stack.expand\"),\n collapse: t(\"stack.collapse\"),\n empty: t(\"stack.empty\"),\n dismiss: t(\"stack.dismiss\"),\n list: (count) => t(\"stack.list\", { count }),\n unread: t(\"stack.unread\"),\n }}\n />\n </div>\n ) : variant === \"compact\" ? (\n // `flex flex-col`: the viewport's percentage height resolves to\n // `auto` against a capped root, and the rows would spill past\n // the cap instead of scrolling.\n <ScrollArea className=\"flex max-h-100 flex-col\">\n <AnimatedList as=\"div\" className=\"flex flex-col\">\n {rows}\n </AnimatedList>\n </ScrollArea>\n ) : (\n <AnimatedList as=\"div\" className=\"flex flex-col\">\n {rows}\n </AnimatedList>\n )}\n\n {footer}\n\n {canLoadMore && (\n <div className=\"border-t p-3 text-center\">\n <Button\n variant=\"ghost\"\n size=\"sm\"\n onClick={handleLoadMore}\n disabled={isLoadingMore}\n >\n {isLoadingMore ? t(\"list.loading\") : t(\"list.loadMore\")}\n </Button>\n </div>\n )}\n </div>\n );\n}\n",
|
|
46
|
+
"type": "registry:component",
|
|
47
|
+
"target": "components/notifications/notification-list.tsx"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"path": "base/notifications/components/notification-item.tsx",
|
|
51
|
+
"content": "\"use client\";\n\n/**\n * A single notification row: type icon, unread dot, title/message,\n * relative timestamp. Clicking an unread row marks it read; a row with\n * an `href` is also a link there.\n */\n\nimport { useEffect, useState } from \"react\";\nimport { useFormatter } from \"next-intl\";\nimport {\n AlertTriangle,\n Bell,\n CheckCircle,\n Clock,\n CreditCard,\n Mail,\n UserPlus,\n type LucideIcon,\n} from \"lucide-react\";\n\nimport { Link } from \"@/i18n/navigation\";\nimport type { NotificationData } from \"@/actions/notifications\";\nimport { notificationTypes } from \"@/lib/notification-types\";\n\nexport interface NotificationItemProps {\n notification: NotificationData;\n onMarkRead: (id: string) => void;\n}\n\n/**\n * Relative timestamp that can't cause a hydration mismatch: the server\n * (and the first client render) show a locale-formatted absolute date,\n * and an effect swaps in the relative form once mounted — `Date.now()`\n * during render would differ between server and client.\n */\nexport function RelativeTime({ iso }: { iso: string }) {\n const format = useFormatter();\n const [now, setNow] = useState<Date | null>(null);\n useEffect(() => setNow(new Date()), []);\n const date = new Date(iso);\n return (\n <>\n {now\n ? format.relativeTime(date, now)\n : format.dateTime(date, { dateStyle: \"medium\" })}\n </>\n );\n}\n\n/**\n * Icon and colour for a notification `type`: the product's own entry in\n * `lib/notification-types.tsx` first, then the built-in types; any other\n * type falls back to a plain bell.\n */\nexport function notificationIcon(type: string): {\n Icon: LucideIcon;\n color: string;\n} {\n if (Object.hasOwn(notificationTypes, type)) {\n const { icon, className } = notificationTypes[type]!;\n return { Icon: icon, color: className ?? \"text-muted-foreground\" };\n }\n switch (type) {\n case \"quota_warning_80\":\n return { Icon: AlertTriangle, color: \"text-warning\" };\n case \"quota_warning_100\":\n return { Icon: AlertTriangle, color: \"text-destructive\" };\n case \"trial_warning_20\":\n return { Icon: Clock, color: \"text-warning\" };\n case \"trial_depleted\":\n return { Icon: Clock, color: \"text-destructive\" };\n case \"payment_failed\":\n return { Icon: CreditCard, color: \"text-destructive\" };\n case \"team_member_joined\":\n return { Icon: UserPlus, color: \"text-success\" };\n case \"subscription_confirmed\":\n return { Icon: CheckCircle, color: \"text-success\" };\n case \"workspace_invitation\":\n return { Icon: Mail, color: \"text-primary\" };\n default:\n return { Icon: Bell, color: \"text-muted-foreground\" };\n }\n}\n\nexport function NotificationItem({\n notification,\n onMarkRead,\n}: NotificationItemProps) {\n const { id, type, title, message, isRead, createdAt, href } = notification;\n const { Icon, color } = notificationIcon(type);\n\n function handleClick() {\n if (!isRead) onMarkRead(id);\n }\n\n const rowClassName =\n \"flex w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/50\";\n\n const content = (\n <>\n <span className=\"mt-2 flex-shrink-0\" aria-hidden>\n <span\n className={`block size-2 rounded-full ${isRead ? \"bg-transparent\" : \"bg-primary\"}`}\n />\n </span>\n\n <span className={`mt-0.5 flex-shrink-0 ${color}`} aria-hidden>\n <Icon className=\"size-4\" />\n </span>\n\n <span className=\"min-w-0 flex-1\">\n <span className=\"block truncate text-sm font-medium\">{title}</span>\n <span className=\"block line-clamp-2 text-sm text-muted-foreground\">\n {message}\n </span>\n </span>\n\n <span className=\"flex-shrink-0 text-xs text-muted-foreground\">\n <RelativeTime iso={createdAt} />\n </span>\n </>\n );\n\n if (href) {\n return (\n <Link href={href} className={rowClassName} onClick={handleClick}>\n {content}\n </Link>\n );\n }\n\n return (\n <button type=\"button\" className={rowClassName} onClick={handleClick}>\n {content}\n </button>\n );\n}\n",
|
|
52
|
+
"type": "registry:component",
|
|
53
|
+
"target": "components/notifications/notification-item.tsx"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"path": "base/notifications/hooks/use-notifications.ts",
|
|
57
|
+
"content": "\"use client\";\n\n/**\n * The bell's unread count and recent list, polled every 60s and paused\n * while `paused` is true. Mutations are optimistic.\n *\n * Pass `initialCount`/`initialNotifications` when a server component\n * already fetched them; omit both and the hook fetches on mount.\n */\n\nimport { useCallback, useEffect, useRef, useState, useTransition } from \"react\";\n\nimport {\n getUnreadCount,\n getUnreadNotifications,\n markAllNotificationsRead,\n markNotificationRead,\n type NotificationData,\n} from \"@/actions/notifications\";\n\nconst POLL_INTERVAL_MS = 60_000;\nconst RECENT_LIMIT = 10;\n\nexport interface UseNotificationsOptions {\n initialCount?: number;\n initialNotifications?: NotificationData[];\n /** Skip the next poll tick while true (e.g. the bell dropdown is open). */\n paused?: boolean;\n}\n\nexport interface UseNotificationsResult {\n notifications: NotificationData[];\n unreadCount: number;\n isPending: boolean;\n markRead: (id: string) => void;\n markAllRead: () => void;\n refresh: () => void;\n}\n\nexport function useNotifications(\n options: UseNotificationsOptions = {}\n): UseNotificationsResult {\n const [notifications, setNotifications] = useState<NotificationData[]>(\n options.initialNotifications ?? []\n );\n const [unreadCount, setUnreadCount] = useState(options.initialCount ?? 0);\n const [isPending, startTransition] = useTransition();\n\n const pausedRef = useRef(options.paused ?? false);\n pausedRef.current = options.paused ?? false;\n\n // Only ever read once: whether the caller handed us a starting point\n // decides if we fetch on mount, independent of later prop changes.\n const hadInitialData = useRef(options.initialNotifications !== undefined);\n\n const refresh = useCallback(() => {\n startTransition(async () => {\n const [countResult, listResult] = await Promise.all([\n getUnreadCount(),\n getUnreadNotifications(RECENT_LIMIT),\n ]);\n if (countResult.success) setUnreadCount(countResult.data);\n if (listResult.success) setNotifications(listResult.data);\n });\n }, []);\n\n useEffect(() => {\n // Runs once on mount; `refresh` is a stable useCallback.\n if (!hadInitialData.current) refresh();\n }, [refresh]);\n\n useEffect(() => {\n const interval = setInterval(() => {\n if (!pausedRef.current) refresh();\n }, POLL_INTERVAL_MS);\n return () => clearInterval(interval);\n }, [refresh]);\n\n const markRead = useCallback((id: string) => {\n setNotifications((prev) =>\n prev.map((n) => (n.id === id ? { ...n, isRead: true } : n))\n );\n setUnreadCount((prev) => Math.max(0, prev - 1));\n startTransition(async () => {\n await markNotificationRead(id);\n });\n }, []);\n\n const markAllRead = useCallback(() => {\n setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })));\n setUnreadCount(0);\n startTransition(async () => {\n await markAllNotificationsRead();\n });\n }, []);\n\n return {\n notifications,\n unreadCount,\n isPending,\n markRead,\n markAllRead,\n refresh,\n };\n}\n",
|
|
58
|
+
"type": "registry:hook",
|
|
59
|
+
"target": "hooks/use-notifications.ts"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"path": "base/notifications/actions.ts",
|
|
63
|
+
"content": "\"use server\";\n\n/**\n * Notification actions over `@intelligo-dev/core/notifications`.\n *\n * User-scoped, not workspace-scoped: `requireAuth()`, not\n * `requireWorkspace()`, because notifications span every workspace a user\n * belongs to and are shown in one list.\n */\n\nimport { revalidatePath } from \"next/cache\";\nimport { getTranslations } from \"next-intl/server\";\n\nimport { requireAuth } from \"@intelligo-dev/auth\";\nimport {\n getNotifications as coreGetNotifications,\n getUnreadNotifications as coreGetUnreadNotifications,\n getNotificationCount,\n markAsRead,\n markAllAsRead,\n} from \"@intelligo-dev/core/notifications\";\nimport type { ActionResult as BaseActionResult } from \"@intelligo-dev/next\";\n\nexport type ActionResult<T> = BaseActionResult<T>;\n\nexport type NotificationData = {\n id: string;\n type: string;\n title: string;\n message: string;\n isRead: boolean;\n /** ISO string — Dates don't cross the server action boundary as-is. */\n createdAt: string;\n /**\n * Optional deep link, read from `metadata.href`: pass\n * `metadata: { href: \"/billing\" }` to `createNotification()`. The table\n * has no link column, and the built-in triggers set none. `null` when\n * absent.\n */\n href: string | null;\n};\n\n// The core reads take a `limit`, not an offset or cursor, so \"Load more\"\n// re-fetches with a larger limit, up to the ceiling.\nconst DEFAULT_LIMIT = 20;\nconst MAX_LIMIT = 100;\n\nconst NOTIFICATIONS_PATH = \"/notifications\";\n\ntype CoreNotification = Awaited<\n ReturnType<typeof coreGetNotifications>\n>[number];\n\nfunction clampLimit(limit: number): number {\n return Math.min(Math.max(Math.trunc(limit) || DEFAULT_LIMIT, 1), MAX_LIMIT);\n}\n\nfunction toNotificationData(row: CoreNotification): NotificationData {\n const href =\n typeof row.metadata?.href === \"string\" ? row.metadata.href : null;\n\n return {\n id: row.id,\n type: row.type,\n title: row.title,\n message: row.message,\n isRead: row.isRead,\n createdAt: row.createdAt.toISOString(),\n href,\n };\n}\n\n/**\n * The reader gets the translated fallback; the error itself goes to the\n * server log. `Error#message` can carry internals (SQL, hostnames).\n */\nfunction errorMessage(error: unknown, fallback: string): string {\n console.error(\"[notifications]\", error);\n return fallback;\n}\n\n/**\n * All notifications (read and unread) for the current user, newest first.\n * Powers the full notifications page; `limit` grows for \"Load more\".\n */\nexport async function getNotifications(\n limit: number = DEFAULT_LIMIT\n): Promise<ActionResult<NotificationData[]>> {\n try {\n const { user } = await requireAuth();\n const rows = await coreGetNotifications(user.id, clampLimit(limit));\n return { success: true, data: rows.map(toNotificationData) };\n } catch (error) {\n const t = await getTranslations(\"notifications\");\n return {\n success: false,\n error: errorMessage(error, t(\"errors.loadNotifications\")),\n };\n }\n}\n\n/**\n * Unread notifications for the current user, newest first. Powers the\n * bell dropdown's recent list.\n */\nexport async function getUnreadNotifications(\n limit: number = DEFAULT_LIMIT\n): Promise<ActionResult<NotificationData[]>> {\n try {\n const { user } = await requireAuth();\n const rows = await coreGetUnreadNotifications(user.id, clampLimit(limit));\n return { success: true, data: rows.map(toNotificationData) };\n } catch (error) {\n const t = await getTranslations(\"notifications\");\n return {\n success: false,\n error: errorMessage(error, t(\"errors.loadUnreadNotifications\")),\n };\n }\n}\n\n/** Unread count for the current user. Powers the bell badge. */\nexport async function getUnreadCount(): Promise<ActionResult<number>> {\n try {\n const { user } = await requireAuth();\n const count = await getNotificationCount(user.id);\n return { success: true, data: count };\n } catch (error) {\n const t = await getTranslations(\"notifications\");\n return {\n success: false,\n error: errorMessage(error, t(\"errors.loadUnreadCount\")),\n };\n }\n}\n\n/**\n * Marks one notification as read. The update is scoped to\n * `(notificationId, userId)`, so it cannot touch another user's.\n */\nexport async function markNotificationRead(\n notificationId: string\n): Promise<ActionResult<undefined>> {\n try {\n const { user } = await requireAuth();\n await markAsRead(notificationId, user.id);\n revalidatePath(NOTIFICATIONS_PATH);\n return { success: true, data: undefined };\n } catch (error) {\n const t = await getTranslations(\"notifications\");\n return {\n success: false,\n error: errorMessage(error, t(\"errors.markRead\")),\n };\n }\n}\n\n/** Mark every unread notification as read for the current user. */\nexport async function markAllNotificationsRead(): Promise<\n ActionResult<undefined>\n> {\n try {\n const { user } = await requireAuth();\n await markAllAsRead(user.id);\n revalidatePath(NOTIFICATIONS_PATH);\n return { success: true, data: undefined };\n } catch (error) {\n const t = await getTranslations(\"notifications\");\n return {\n success: false,\n error: errorMessage(error, t(\"errors.markAllRead\")),\n };\n }\n}\n",
|
|
64
|
+
"type": "registry:file",
|
|
65
|
+
"target": "actions/notifications.ts"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"path": "base/notifications/lib/notification-types.tsx",
|
|
69
|
+
"content": "/**\n * Icons for the product's own notification types. Consumer-owned:\n * `intelligo sync` never overwrites this file.\n *\n * `createNotification` accepts any `type` string; a type listed here is\n * drawn with its `icon` and `className` (a semantic text colour), and\n * this map is consulted before the built-in types, so an entry can also\n * restyle one of those. Any other type falls back to a plain bell.\n *\n * ```ts\n * import { FileText } from \"lucide-react\";\n *\n * export const notificationTypes: NotificationTypeMap = {\n * report_ready: { icon: FileText, className: \"text-success\" },\n * };\n * ```\n *\n * Imported only by client components: an icon is a component, which\n * cannot cross from a server component to a client one as a prop.\n */\n\nimport type { LucideIcon } from \"lucide-react\";\n\nexport interface NotificationTypeStyle {\n icon: LucideIcon;\n /** Colour class for the icon; defaults to `text-muted-foreground`. */\n className?: string;\n}\n\nexport type NotificationTypeMap = Record<string, NotificationTypeStyle>;\n\nexport const notificationTypes: NotificationTypeMap = {};\n",
|
|
70
|
+
"type": "registry:file",
|
|
71
|
+
"target": "lib/notification-types.tsx"
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"path": "base/notifications/messages/en.json",
|
|
75
|
+
"content": "{\n \"page\": {\n \"title\": \"Notifications\",\n \"subtitle\": \"Quota, billing, and team updates for your account, across every workspace.\"\n },\n \"list\": {\n \"title\": \"Notifications\",\n \"markAllRead\": \"Mark all read\",\n \"emptyTitle\": \"No notifications yet\",\n \"emptyDescription\": \"Quota, billing, and team updates will show up here.\",\n \"loading\": \"Loading…\",\n \"loadMore\": \"Load more\"\n },\n \"bell\": {\n \"ariaLabel\": \"Notifications\",\n \"ariaLabelUnread\": \"Notifications ({count} unread)\",\n \"viewAll\": \"View all\"\n },\n \"errors\": {\n \"loadNotifications\": \"Failed to load notifications\",\n \"loadUnreadNotifications\": \"Failed to load unread notifications\",\n \"loadUnreadCount\": \"Failed to load unread count\",\n \"markRead\": \"Failed to mark notification as read\",\n \"markAllRead\": \"Failed to mark all notifications as read\"\n },\n \"stack\": {\n \"expand\": \"Show all\",\n \"collapse\": \"Show less\",\n \"empty\": \"You're all caught up\",\n \"dismiss\": \"Mark as read\",\n \"list\": \"{count, plural, one {# new notification} other {# new notifications}}\",\n \"unread\": \"Unread\"\n }\n}\n",
|
|
76
|
+
"type": "registry:file",
|
|
77
|
+
"target": "messages/en/notifications.json"
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"path": "base/notifications/error.tsx",
|
|
81
|
+
"content": "\"use client\";\n\n/**\n * Error boundary for the notifications page. Renders `RouteError` from\n * the `route-error` item, which must be installed too.\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=\"notifications\" />;\n}\n",
|
|
82
|
+
"type": "registry:page",
|
|
83
|
+
"target": "app/[locale]/(app)/notifications/error.tsx"
|
|
84
|
+
}
|
|
85
|
+
],
|
|
86
|
+
"type": "registry:block"
|
|
87
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "onboarding",
|
|
4
|
+
"title": "Onboarding",
|
|
5
|
+
"description": "A generic multi-step onboarding wizard driven by a consumer-owned steps config: text and single-select-card fields, a completion screen, skip, and back/next - backed by the framework's onboarding service (durable step progress, no vertical-specific UI).",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"@intelligo-dev/auth",
|
|
8
|
+
"@intelligo-dev/next",
|
|
9
|
+
"lucide-react",
|
|
10
|
+
"server-only",
|
|
11
|
+
"next-intl",
|
|
12
|
+
"motion"
|
|
13
|
+
],
|
|
14
|
+
"registryDependencies": [
|
|
15
|
+
"@intelligo/button",
|
|
16
|
+
"@intelligo/input",
|
|
17
|
+
"label",
|
|
18
|
+
"@intelligo/ai-motion"
|
|
19
|
+
],
|
|
20
|
+
"files": [
|
|
21
|
+
{
|
|
22
|
+
"path": "base/onboarding/layout.tsx",
|
|
23
|
+
"content": "/**\n * Centred layout with no sidebar. It sits beside `(app)`, not inside it,\n * so the shell's redirect to onboarding never wraps this route. If you\n * move it under `(app)`, skip that redirect on this route.\n */\n\nimport { getTranslations } from \"next-intl/server\";\nimport type { ReactNode } from \"react\";\n\nconst SKIP_LINK_CLASS =\n \"sr-only focus:not-sr-only focus:fixed focus:top-3 focus:left-3 focus:z-50 focus:rounded-lg focus:border focus:border-ring focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:text-foreground focus:shadow-md focus:outline-none focus:ring-3 focus:ring-ring/40\";\n\nexport default async function OnboardingLayout({\n children,\n}: {\n children: ReactNode;\n}) {\n const t = await getTranslations(\"onboarding\");\n return (\n <>\n <a href=\"#main-content\" className={SKIP_LINK_CLASS}>\n {t(\"skipToContent\")}\n </a>\n <main\n id=\"main-content\"\n tabIndex={-1}\n className=\"min-h-screen flex flex-col items-center justify-center bg-background px-4 py-12 outline-none\"\n >\n {children}\n </main>\n </>\n );\n}\n",
|
|
24
|
+
"type": "registry:page",
|
|
25
|
+
"target": "app/[locale]/onboarding/layout.tsx"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"path": "base/onboarding/page.tsx",
|
|
29
|
+
"content": "/**\n * Renders the wizard at the caller's persisted step. A caller who has\n * completed onboarding is redirected to the completion screen's target;\n * one without a session, to `/login`.\n */\n\n// Session reads make this page inherently per-request; declaring it\n// dynamic keeps `next build` from attempting a static prerender.\nexport const dynamic = \"force-dynamic\";\n\nimport { getLocale } from \"next-intl/server\";\nimport { getAuthSession } from \"@intelligo-dev/auth\";\nimport { redirect } from \"@/i18n/navigation\";\nimport { onboarding } from \"@/lib/onboarding\";\nimport { onboardingConfig } from \"@/lib/onboarding-steps\";\nimport { OnboardingWizard } from \"@/components/onboarding/onboarding-wizard\";\n\nexport default async function OnboardingPage() {\n // Outside `(app)`, so the shell's session check does not cover it.\n if (!(await getAuthSession())) {\n redirect({ href: \"/login\", locale: await getLocale() });\n }\n\n const state = await onboarding.getState();\n\n if (state.completed) {\n redirect({\n href: onboardingConfig.complete.ctaHref,\n locale: await getLocale(),\n });\n }\n\n return <OnboardingWizard initialStepId={state.currentStep} />;\n}\n",
|
|
30
|
+
"type": "registry:page",
|
|
31
|
+
"target": "app/[locale]/onboarding/page.tsx"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"path": "base/onboarding/components/onboarding-wizard.tsx",
|
|
35
|
+
"content": "\"use client\";\n\n/**\n * Runs the steps from `@/lib/onboarding-steps`: progress, back/next/skip\n * and the completion screen.\n *\n * It resumes at `initialStepId`, the persisted step. The completion\n * screen persists no step id, so a caller who leaves there resumes at\n * the last answered step. Focus moves to the card on every step change\n * so screen readers announce the new step.\n */\n\nimport { useRef, useState, useTransition } from \"react\";\nimport { useTranslations } from \"next-intl\";\nimport { CheckCircle2 } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nimport { useRouter } from \"@/i18n/navigation\";\nimport { EASE_IN_OUT, EASE_OUT } from \"@/components/ui/ai-motion\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n advanceStep,\n completeOnboarding,\n skipOnboarding,\n} from \"@/actions/onboarding\";\nimport {\n onboardingConfig,\n type OnboardingAnswers,\n} from \"@/lib/onboarding-steps\";\nimport { OnboardingStep } from \"./onboarding-step\";\n\ninterface OnboardingWizardProps {\n initialStepId: string | null;\n}\n\nexport function OnboardingWizard({ initialStepId }: OnboardingWizardProps) {\n const t = useTranslations(\"onboarding\");\n // Namespace-less: completion-screen copy keys are fully qualified\n // (see `@/lib/onboarding-steps`).\n const tAny = useTranslations();\n const router = useRouter();\n const { steps, complete } = onboardingConfig;\n\n const initialIndex = initialStepId\n ? Math.max(\n steps.findIndex((step) => step.id === initialStepId),\n 0\n )\n : 0;\n\n const [stepIndex, setStepIndex] = useState(initialIndex);\n // Which way the last move went: a step slides in from the side it\n // came from, and the one it replaces leaves the other way.\n const [direction, setDirection] = useState<1 | -1>(1);\n const reduced = useReducedMotion() ?? false;\n const [answers, setAnswers] = useState<OnboardingAnswers>({});\n const [error, setError] = useState<string | null>(null);\n const [isSkipping, startSkip] = useTransition();\n const [isPending, startTransition] = useTransition();\n const cardRef = useRef<HTMLDivElement>(null);\n\n const isCompleteScreen = stepIndex >= steps.length;\n const currentStep = steps[stepIndex];\n const totalScreens = steps.length + 1; // + the completion screen\n const progressPosition = Math.min(stepIndex, steps.length);\n\n function focusCard() {\n cardRef.current?.focus();\n }\n\n function handleAnswerChange(fieldId: string, value: string) {\n setAnswers((prev) => ({ ...prev, [fieldId]: value }));\n }\n\n function handleNext() {\n if (!currentStep) return;\n setError(null);\n startTransition(async () => {\n const nextIndex = stepIndex + 1;\n // The step being left is submitted, the last one included; the\n // completion screen is not a step and persists no step id.\n const result = await advanceStep({\n from: currentStep.id,\n to: steps[nextIndex]?.id ?? null,\n answers,\n });\n if (!result.success) {\n setError(result.error);\n return;\n }\n setDirection(1);\n setStepIndex(nextIndex);\n focusCard();\n });\n }\n\n function handleBack() {\n if (stepIndex === 0) return;\n setError(null);\n startTransition(async () => {\n const prevIndex = stepIndex - 1;\n const prevStep = steps[prevIndex];\n if (prevStep) {\n const result = await advanceStep({\n from: currentStep?.id ?? prevStep.id,\n to: prevStep.id,\n });\n if (!result.success) {\n setError(result.error);\n return;\n }\n }\n setDirection(-1);\n setStepIndex(prevIndex);\n focusCard();\n });\n }\n\n function handleSkip() {\n setError(null);\n startSkip(async () => {\n const result = await skipOnboarding();\n if (!result.success) {\n setError(result.error);\n return;\n }\n router.push(complete.ctaHref);\n router.refresh();\n });\n }\n\n function handleComplete() {\n setError(null);\n startTransition(async () => {\n const result = await completeOnboarding();\n if (!result.success) {\n setError(result.error);\n return;\n }\n router.push(complete.ctaHref);\n router.refresh();\n });\n }\n\n return (\n <div className=\"w-full max-w-md\">\n <div className=\"flex justify-end mb-4\">\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n onClick={handleSkip}\n disabled={isSkipping}\n className=\"text-muted-foreground\"\n >\n {isSkipping ? t(\"wizard.skipping\") : t(\"wizard.skip\")}\n </Button>\n </div>\n\n <div\n className=\"flex items-center justify-center gap-2 mb-8\"\n role=\"progressbar\"\n aria-valuenow={progressPosition + 1}\n aria-valuemin={1}\n aria-valuemax={totalScreens}\n aria-label={t(\"wizard.progressLabel\")}\n >\n {Array.from({ length: totalScreens }).map((_, index) => (\n <div\n key={index}\n className={`h-1.5 rounded-full transition-[width,background-color] duration-slow ease-emphasized ${\n index === progressPosition\n ? \"w-8 bg-primary\"\n : index < progressPosition\n ? \"w-1.5 bg-primary/60\"\n : \"w-1.5 bg-border\"\n }`}\n />\n ))}\n </div>\n\n <div\n ref={cardRef}\n tabIndex={-1}\n className=\"rounded-xl border bg-card p-8 outline-none\"\n >\n {error && (\n <p role=\"alert\" className=\"text-sm text-destructive mb-4\">\n {error}\n </p>\n )}\n\n <AnimatePresence mode=\"wait\" initial={false} custom={direction}>\n <motion.div\n key={isCompleteScreen ? \"complete\" : (currentStep?.id ?? \"none\")}\n custom={direction}\n variants={{\n enter: (dir: number) =>\n reduced\n ? { opacity: 0 }\n : { opacity: 0, x: dir * 24, filter: \"blur(3px)\" },\n center: {\n opacity: 1,\n x: 0,\n filter: \"blur(0px)\",\n transition: { duration: reduced ? 0 : 0.32, ease: EASE_OUT },\n },\n leave: (dir: number) =>\n reduced\n ? { opacity: 0, transition: { duration: 0 } }\n : {\n opacity: 0,\n x: dir * -24,\n filter: \"blur(3px)\",\n transition: { duration: 0.16, ease: EASE_IN_OUT },\n },\n }}\n initial=\"enter\"\n animate=\"center\"\n exit=\"leave\"\n >\n {isCompleteScreen ? (\n <div className=\"text-center py-2\">\n <div className=\"inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-6\">\n <CheckCircle2\n className=\"w-8 h-8 text-primary\"\n aria-hidden=\"true\"\n />\n </div>\n <h2 className=\"text-xl font-semibold mb-2\">\n {tAny(complete.titleKey)}\n </h2>\n {complete.descriptionKey && (\n <p className=\"text-sm text-muted-foreground mb-8\">\n {tAny(complete.descriptionKey)}\n </p>\n )}\n <Button\n type=\"button\"\n className=\"w-full\"\n onClick={handleComplete}\n disabled={isPending}\n >\n {isPending\n ? t(\"wizard.finishing\")\n : tAny(complete.ctaLabelKey)}\n </Button>\n </div>\n ) : currentStep ? (\n <OnboardingStep\n step={currentStep}\n answers={answers}\n onAnswerChange={handleAnswerChange}\n onBack={stepIndex > 0 ? handleBack : undefined}\n onNext={handleNext}\n isSubmitting={isPending}\n />\n ) : null}\n </motion.div>\n </AnimatePresence>\n </div>\n </div>\n );\n}\n",
|
|
36
|
+
"type": "registry:component",
|
|
37
|
+
"target": "components/onboarding/onboarding-wizard.tsx"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"path": "base/onboarding/components/onboarding-step.tsx",
|
|
41
|
+
"content": "\"use client\";\n\n/**\n * Renders one step's fields from its config in `@/lib/onboarding-steps`:\n * a text input or a single-select card group. For other field types,\n * write your own step component and render it from the wizard.\n */\n\nimport { useId } from \"react\";\nimport { useTranslations } from \"next-intl\";\nimport { CheckCircle2 } from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport type {\n OnboardingAnswers,\n OnboardingStepConfig,\n} from \"@/lib/onboarding-steps\";\n\ninterface OnboardingStepProps {\n step: OnboardingStepConfig;\n answers: OnboardingAnswers;\n onAnswerChange: (fieldId: string, value: string) => void;\n onBack?: () => void;\n onNext: () => void;\n isSubmitting: boolean;\n}\n\nfunction isStepComplete(\n step: OnboardingStepConfig,\n answers: OnboardingAnswers\n): boolean {\n return (step.fields ?? []).every((field) => {\n if (!field.required) return true;\n return Boolean(answers[field.id]?.trim());\n });\n}\n\nexport function OnboardingStep({\n step,\n answers,\n onAnswerChange,\n onBack,\n onNext,\n isSubmitting,\n}: OnboardingStepProps) {\n const t = useTranslations(\"onboarding\");\n // Namespace-less: step and field keys are fully qualified, so a step\n // can point at any namespace.\n const tAny = useTranslations();\n const headingId = useId();\n const canProceed = isStepComplete(step, answers);\n\n return (\n <div role=\"group\" aria-labelledby={headingId}>\n <h2 id={headingId} className=\"text-xl font-semibold text-center mb-2\">\n {tAny(step.titleKey)}\n </h2>\n {step.descriptionKey && (\n <p className=\"text-sm text-muted-foreground text-center mb-6\">\n {tAny(step.descriptionKey)}\n </p>\n )}\n\n <div className=\"space-y-5\">\n {(step.fields ?? []).map((field) => (\n <div key={field.id} className=\"space-y-2\">\n <Label htmlFor={field.id}>{tAny(field.labelKey)}</Label>\n\n {field.type === \"text\" ? (\n <Input\n id={field.id}\n value={answers[field.id] ?? \"\"}\n onChange={(event) =>\n onAnswerChange(field.id, event.target.value)\n }\n placeholder={\n field.placeholderKey ? tAny(field.placeholderKey) : undefined\n }\n required={field.required}\n />\n ) : (\n <div\n className=\"space-y-2\"\n role=\"radiogroup\"\n aria-labelledby={field.id}\n >\n {(field.options ?? []).map((option) => {\n const selected = answers[field.id] === option.value;\n return (\n <button\n key={option.value}\n type=\"button\"\n role=\"radio\"\n aria-checked={selected}\n onClick={() => onAnswerChange(field.id, option.value)}\n className={`w-full flex items-center gap-3 rounded-lg border px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${\n selected\n ? \"border-primary bg-primary/5\"\n : \"border-border hover:bg-muted\"\n }`}\n >\n <div className=\"flex-1\">\n <p className=\"text-sm font-medium\">\n {tAny(option.labelKey)}\n </p>\n {option.descriptionKey && (\n <p className=\"text-xs text-muted-foreground\">\n {tAny(option.descriptionKey)}\n </p>\n )}\n </div>\n {selected && (\n <CheckCircle2\n className=\"size-5 shrink-0 text-primary\"\n aria-hidden=\"true\"\n />\n )}\n </button>\n );\n })}\n </div>\n )}\n </div>\n ))}\n </div>\n\n <div className=\"flex gap-3 mt-8\">\n {onBack && (\n <Button\n type=\"button\"\n variant=\"outline\"\n onClick={onBack}\n disabled={isSubmitting}\n >\n {t(\"step.back\")}\n </Button>\n )}\n <Button\n type=\"button\"\n className=\"flex-1\"\n onClick={onNext}\n disabled={!canProceed || isSubmitting}\n >\n {isSubmitting ? t(\"step.saving\") : t(\"step.continue\")}\n </Button>\n </div>\n </div>\n );\n}\n",
|
|
42
|
+
"type": "registry:component",
|
|
43
|
+
"target": "components/onboarding/onboarding-step.tsx"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"path": "base/onboarding/actions.ts",
|
|
47
|
+
"content": "\"use server\";\n\n/**\n * Onboarding actions over the service in `@/lib/onboarding`: call it, map\n * an `OnboardingServiceError` to a translated message, revalidate. The\n * page reads state from the service directly.\n */\n\nimport { revalidatePath } from \"next/cache\";\nimport { getTranslations } from \"next-intl/server\";\n\nimport { isOnboardingServiceError } from \"@intelligo-dev/auth\";\nimport type { ActionResult } from \"@intelligo-dev/next\";\n\nimport { profile } from \"@/lib/onboarding-profile\";\nimport { onboarding } from \"@/lib/onboarding\";\nimport {\n onboardingConfig,\n type OnboardingAnswers,\n} from \"@/lib/onboarding-steps\";\n\nexport type OnboardingActionResult<T = undefined> = ActionResult<T>;\n\nasync function friendlyError(error: unknown): Promise<string> {\n if (isOnboardingServiceError(error)) {\n if (error.code === \"invalid_input\") return error.message;\n const t = await getTranslations(\"onboarding\");\n if (error.code === \"forbidden\") return t(\"errors.forbidden\");\n return t(\"errors.unexpected\");\n }\n // `Error#message` can carry internals (SQL, hostnames) to the UI.\n console.error(\"[onboarding]\", error);\n const t = await getTranslations(\"onboarding\");\n return t(\"errors.generic\");\n}\n\n/**\n * Leaves the step `from` for `to`. Going forward (`answers` given), it\n * first runs `onStepSubmit` from `lib/onboarding-steps.ts` with `from`\n * and its answers; going back submits nothing. `to` null is the\n * completion screen, which persists no step id, so the caller resumes\n * at `from`.\n */\nexport async function advanceStep(move: {\n from: string;\n to: string | null;\n answers?: OnboardingAnswers;\n}): Promise<OnboardingActionResult> {\n try {\n if (move.answers && onboardingConfig.onStepSubmit) {\n await onboardingConfig.onStepSubmit(move.from, move.answers);\n }\n await onboarding.setStep(move.to ?? move.from);\n return { success: true, data: undefined };\n } catch (error) {\n return { success: false, error: await friendlyError(error) };\n }\n}\n\nexport async function completeOnboarding(): Promise<OnboardingActionResult> {\n try {\n await onboarding.complete();\n revalidatePath(\"/\");\n return { success: true, data: undefined };\n } catch (error) {\n return { success: false, error: await friendlyError(error) };\n }\n}\n\nexport async function skipOnboarding(): Promise<OnboardingActionResult> {\n try {\n await onboarding.skip();\n revalidatePath(\"/\");\n return { success: true, data: undefined };\n } catch (error) {\n return { success: false, error: await friendlyError(error) };\n }\n}\n\n/**\n * Saves a display name typed into the wizard.\n *\n * A server action because `lib/onboarding-steps.ts` is also imported by\n * the client wizard, and calling a server-only service from there would\n * pull `next/headers` into the client bundle. Any persistence you add in\n * `onStepSubmit` should take the same shape.\n */\nexport async function saveDisplayName(\n name: string\n): Promise<OnboardingActionResult> {\n const trimmed = name.trim();\n // The service validates length; anything shorter is the wizard's own\n // required-field problem, not a reason to fail the step.\n if (trimmed.length < 2) return { success: true, data: undefined };\n\n try {\n await profile.updateProfile({ name: trimmed });\n return { success: true, data: undefined };\n } catch (error) {\n return { success: false, error: await friendlyError(error) };\n }\n}\n",
|
|
48
|
+
"type": "registry:file",
|
|
49
|
+
"target": "actions/onboarding.ts"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"path": "base/onboarding/lib/onboarding.ts",
|
|
53
|
+
"content": "import \"server-only\";\n\n/**\n * The onboarding service; it takes no ports. `complete()` and `skip()`\n * provision nothing: first-workspace provisioning belongs in\n * `lib/workspace-bootstrap.ts`.\n */\n\nimport { createOnboardingService } from \"@intelligo-dev/auth\";\n\nexport const onboarding = createOnboardingService();\n",
|
|
54
|
+
"type": "registry:file",
|
|
55
|
+
"target": "lib/onboarding.ts"
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"path": "base/onboarding/lib/onboarding-steps.ts",
|
|
59
|
+
"content": "/**\n * The onboarding wizard: its steps, their fields, the completion screen\n * and `onStepSubmit`.\n *\n * Copy is referenced by fully-qualified message key (`titleKey`,\n * `labelKey`, `ctaLabelKey`…), resolved through a namespace-less\n * `useTranslations()`; a step you add can point at any namespace.\n *\n * The framework persists only which step the caller is on. Field\n * answers are kept only if `onStepSubmit` saves them; it runs\n * server-side each time the wizard advances past a step, and by default\n * writes `displayName` to the profile.\n *\n * The client wizard imports this file too, so it must stay client-safe:\n * persist through a server action, never a service imported here.\n */\n\nimport { saveDisplayName } from \"@/actions/onboarding\";\n\nexport type OnboardingFieldType = \"text\" | \"select-cards\";\n\nexport interface OnboardingSelectOption {\n value: string;\n labelKey: string;\n descriptionKey?: string;\n}\n\nexport interface OnboardingField {\n /** Unique within its step. Used as the answers-map key. */\n id: string;\n type: OnboardingFieldType;\n labelKey: string;\n /** `type: \"text\"` only. */\n placeholderKey?: string;\n required?: boolean;\n /** `type: \"select-cards\"` only. */\n options?: OnboardingSelectOption[];\n}\n\nexport interface OnboardingStepConfig {\n /** Persisted verbatim; at most 64 characters, unique across `steps`. */\n id: string;\n titleKey: string;\n descriptionKey?: string;\n /** Omit for a purely informational step with only a Continue button. */\n fields?: OnboardingField[];\n}\n\nexport interface OnboardingCompleteConfig {\n titleKey: string;\n descriptionKey?: string;\n ctaLabelKey: string;\n /** Where the completion screen's CTA (and Skip) send the caller. */\n ctaHref: string;\n}\n\nexport type OnboardingAnswers = Record<string, string>;\n\nexport interface OnboardingConfig {\n steps: OnboardingStepConfig[];\n complete: OnboardingCompleteConfig;\n /**\n * Runs server-side whenever the wizard advances past `stepId` — the\n * last step included — with the answers so far. The only place\n * answers are saved.\n */\n onStepSubmit?: (\n stepId: string,\n answers: OnboardingAnswers\n ) => Promise<void> | void;\n}\n\nexport const onboardingConfig: OnboardingConfig = {\n steps: [\n {\n id: \"purpose\",\n titleKey: \"onboarding.steps.purpose.title\",\n descriptionKey: \"onboarding.steps.purpose.description\",\n fields: [\n {\n id: \"purpose\",\n type: \"select-cards\",\n labelKey: \"onboarding.steps.purpose.label\",\n required: true,\n options: [\n {\n value: \"personal\",\n labelKey: \"onboarding.steps.purpose.options.personal.label\",\n descriptionKey:\n \"onboarding.steps.purpose.options.personal.description\",\n },\n {\n value: \"team\",\n labelKey: \"onboarding.steps.purpose.options.team.label\",\n descriptionKey:\n \"onboarding.steps.purpose.options.team.description\",\n },\n {\n value: \"clients\",\n labelKey: \"onboarding.steps.purpose.options.clients.label\",\n descriptionKey:\n \"onboarding.steps.purpose.options.clients.description\",\n },\n ],\n },\n ],\n },\n {\n id: \"profile\",\n titleKey: \"onboarding.steps.profile.title\",\n descriptionKey: \"onboarding.steps.profile.description\",\n fields: [\n {\n id: \"displayName\",\n type: \"text\",\n labelKey: \"onboarding.steps.profile.label\",\n placeholderKey: \"onboarding.steps.profile.placeholder\",\n required: true,\n },\n ],\n },\n ],\n complete: {\n titleKey: \"onboarding.complete.title\",\n descriptionKey: \"onboarding.complete.description\",\n ctaLabelKey: \"onboarding.complete.cta\",\n ctaHref: \"/dashboard\",\n },\n onStepSubmit: async (stepId, answers) => {\n if (stepId === \"profile\" && answers.displayName) {\n const saved = await saveDisplayName(answers.displayName);\n if (!saved.success) throw new Error(saved.error);\n }\n },\n};\n",
|
|
60
|
+
"type": "registry:file",
|
|
61
|
+
"target": "lib/onboarding-steps.ts"
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"path": "base/onboarding/messages/en.json",
|
|
65
|
+
"content": "{\n \"skipToContent\": \"Skip to content\",\n \"step\": {\n \"back\": \"Back\",\n \"continue\": \"Continue\",\n \"saving\": \"Saving…\"\n },\n \"wizard\": {\n \"skip\": \"Skip for now\",\n \"skipping\": \"Skipping…\",\n \"progressLabel\": \"Onboarding progress\",\n \"finishing\": \"Finishing…\"\n },\n \"errors\": {\n \"forbidden\": \"Please sign in and try again.\",\n \"unexpected\": \"Something went wrong. Please try again.\",\n \"generic\": \"Something went wrong.\"\n },\n \"steps\": {\n \"purpose\": {\n \"title\": \"What will you use this workspace for?\",\n \"description\": \"This helps us tailor your experience.\",\n \"label\": \"Purpose\",\n \"options\": {\n \"personal\": {\n \"label\": \"A personal project\",\n \"description\": \"Just me, exploring on my own\"\n },\n \"team\": {\n \"label\": \"My team or company\",\n \"description\": \"I'll invite others to this workspace\"\n },\n \"clients\": {\n \"label\": \"Client work\",\n \"description\": \"I'm working on behalf of clients\"\n }\n }\n },\n \"profile\": {\n \"title\": \"What should we call you?\",\n \"description\": \"Your display name is shown to teammates you invite.\",\n \"label\": \"Display name\",\n \"placeholder\": \"Ada Lovelace\"\n }\n },\n \"complete\": {\n \"title\": \"You're all set\",\n \"description\": \"Your workspace is ready to go.\",\n \"cta\": \"Go to dashboard\"\n }\n}\n",
|
|
66
|
+
"type": "registry:file",
|
|
67
|
+
"target": "messages/en/onboarding.json"
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"path": "base/onboarding/error.tsx",
|
|
71
|
+
"content": "\"use client\";\n\n/**\n * Error boundary for the onboarding wizard, which renders outside the app\n * shell. Renders `RouteError` from the `route-error` item, which must be\n * installed too.\n */\n\nimport { RouteError } from \"@/components/shared/route-error\";\n\nexport default function SegmentError({\n error,\n reset,\n}: {\n error: Error & { digest?: string };\n reset: () => void;\n}) {\n return (\n <RouteError\n error={error}\n reset={reset}\n scope=\"onboarding\"\n fullScreen\n homeHref=\"/login\"\n homeKey=\"goToLogin\"\n />\n );\n}\n",
|
|
72
|
+
"type": "registry:page",
|
|
73
|
+
"target": "app/[locale]/onboarding/error.tsx"
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"path": "base/onboarding/lib/onboarding-profile.ts",
|
|
77
|
+
"content": "import \"server-only\";\n\n/**\n * A profile service for saving the display name, bound here so\n * onboarding does not depend on the `profile-settings` item.\n * `updateProfile` needs no ports. With both items installed you can\n * re-export one instance.\n */\n\nimport { createProfileService } from \"@intelligo-dev/auth\";\n\nexport const profile = createProfileService();\n",
|
|
78
|
+
"type": "registry:file",
|
|
79
|
+
"target": "lib/onboarding-profile.ts"
|
|
80
|
+
}
|
|
81
|
+
],
|
|
82
|
+
"type": "registry:block"
|
|
83
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "otp-input",
|
|
4
|
+
"title": "OTP Input",
|
|
5
|
+
"description": "A one-time-code input on Base UI's OTP Field whose characters roll into their slots under a blinking caret, shaking on a rejected code and drawing a check on an accepted one.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"@base-ui/react",
|
|
8
|
+
"motion"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"@intelligo/ai-motion"
|
|
12
|
+
],
|
|
13
|
+
"files": [
|
|
14
|
+
{
|
|
15
|
+
"path": "base/ui/otp-input/otp-input.tsx",
|
|
16
|
+
"content": "\"use client\";\n\n/*\n * A one-time-code input: a row of slots whose characters roll in as they\n * land, a blinking caret on the focused slot, a shake when the code is\n * rejected and a drawn check when it is accepted. Typing, paste, autofill,\n * arrow keys and the per-slot inputs come from Base UI's OTP Field.\n */\n\nimport * as React from \"react\";\nimport { OTPField } from \"@base-ui/react/otp-field\";\nimport {\n AnimatePresence,\n animate,\n motion,\n useReducedMotion,\n} from \"motion/react\";\n\nimport { EASE_OUT, SPRING_SWAP } from \"@/components/ui/ai-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport type OTPStatus = \"idle\" | \"error\" | \"success\";\n\nexport interface OTPInputLabels {\n /** Visible label above the slots; also the first slot's accessible name. */\n label?: React.ReactNode;\n /** Helper text below the slots while idle. */\n hint?: React.ReactNode;\n /** Message below the slots when the code is rejected. */\n error?: React.ReactNode;\n /** Message below the slots when the code is accepted. */\n success?: React.ReactNode;\n /** Accessible name of the whole field when no visible label is given. */\n field: string;\n /** Accessible name of each slot after the first. */\n slot: (index: number, length: number) => string;\n}\n\nconst DEFAULT_LABELS: OTPInputLabels = {\n field: \"One-time code\",\n slot: (index, length) => `Character ${index + 1} of ${length}`,\n};\n\nexport interface OTPInputProps {\n /** Number of slots. */\n length?: number;\n value?: string;\n defaultValue?: string;\n onValueChange?: (value: string) => void;\n /** Fires when every slot is filled (and on a complete paste). */\n onComplete?: (value: string) => void;\n /** Validation feedback: \"error\" shakes, \"success\" draws a check. */\n status?: OTPStatus;\n /** Shorthand for `status=\"error\"`. */\n invalid?: boolean;\n /** Obscure the characters as they are typed. */\n mask?: boolean;\n disabled?: boolean;\n autoFocus?: boolean;\n /** Which characters a slot accepts. */\n validationType?: OTPField.Root.Props[\"validationType\"];\n /** Form field name for the hidden input. */\n name?: string;\n /** Submit the owning form once the code is complete. */\n autoSubmit?: boolean;\n id?: string;\n labels?: Partial<OTPInputLabels>;\n className?: string;\n}\n\nexport function OTPInput({\n length = 6,\n value: valueProp,\n defaultValue = \"\",\n onValueChange,\n onComplete,\n status: statusProp = \"idle\",\n invalid = false,\n mask = false,\n disabled = false,\n autoFocus = false,\n validationType = \"numeric\",\n name,\n autoSubmit,\n id: idProp,\n labels: labelsProp,\n className,\n}: OTPInputProps) {\n const labels = { ...DEFAULT_LABELS, ...labelsProp };\n const reduced = useReducedMotion() ?? false;\n const generatedId = React.useId();\n const id = idProp ?? generatedId;\n const messageId = `${id}-message`;\n const slotsRef = React.useRef<HTMLDivElement>(null);\n\n const [uncontrolled, setUncontrolled] = React.useState(defaultValue);\n const value = valueProp ?? uncontrolled;\n const [focusedIndex, setFocusedIndex] = React.useState(-1);\n\n const status: OTPStatus = invalid ? \"error\" : statusProp;\n const error = status === \"error\";\n const success = status === \"success\";\n\n // The shake replays on every transition into \"error\".\n React.useEffect(() => {\n if (!error || reduced || !slotsRef.current) return;\n animate(\n slotsRef.current,\n { x: [0, -5, 5, -3, 3, -1, 0] },\n { duration: 0.45, ease: EASE_OUT }\n );\n }, [error, reduced]);\n\n const message = success ? labels.success : error ? labels.error : labels.hint;\n\n return (\n <div\n data-slot=\"otp-input\"\n className={cn(\"inline-flex flex-col gap-2\", className)}\n >\n {labels.label ? (\n <label htmlFor={id} className=\"text-sm font-medium text-foreground\">\n {labels.label}\n </label>\n ) : null}\n\n <div className=\"relative inline-flex w-max\">\n <motion.div ref={slotsRef}>\n <OTPField.Root\n id={id}\n length={length}\n value={value}\n onValueChange={(next) => {\n if (valueProp === undefined) setUncontrolled(next);\n onValueChange?.(next);\n }}\n onValueComplete={(next) => onComplete?.(next)}\n mask={mask}\n disabled={disabled}\n validationType={validationType}\n name={name}\n autoSubmit={autoSubmit}\n aria-describedby={message ? messageId : undefined}\n className=\"flex items-center gap-2\"\n >\n {Array.from({ length }, (_, index) => {\n const char = value[index] ?? \"\";\n const focused = focusedIndex === index;\n return (\n <div\n // A fixed-length slot grid, never reordered.\n key={index}\n data-slot=\"otp-input-slot\"\n data-filled={char !== \"\" || undefined}\n data-focused={focused || undefined}\n className={cn(\n \"relative grid h-12 w-10 place-items-center overflow-hidden rounded-lg border bg-background text-xl font-semibold text-foreground tabular-nums transition-[border-color,box-shadow] duration-200\",\n success\n ? \"border-success/60\"\n : error\n ? \"border-destructive/60\"\n : focused\n ? \"border-ring ring-3 ring-ring/30\"\n : char\n ? \"border-input\"\n : \"border-border\",\n disabled && \"opacity-50\"\n )}\n >\n <OTPField.Input\n aria-label={\n index === 0\n ? labels.label\n ? undefined\n : labels.field\n : labels.slot(index, length)\n }\n aria-invalid={error || undefined}\n autoFocus={autoFocus && index === 0}\n onFocus={() => setFocusedIndex(index)}\n onBlur={() =>\n setFocusedIndex((current) =>\n current === index ? -1 : current\n )\n }\n // The input owns focus, typing and paste; the glyph and\n // caret drawn over it are presentational.\n className=\"absolute inset-0 size-full cursor-text bg-transparent text-center text-transparent caret-transparent outline-none selection:bg-transparent disabled:cursor-not-allowed\"\n />\n\n {focused && !success ? (\n <motion.span\n aria-hidden\n animate={reduced ? undefined : { opacity: [1, 1, 0, 0] }}\n transition={\n reduced\n ? undefined\n : { duration: 1, repeat: Infinity, ease: \"linear\" }\n }\n className={cn(\n \"pointer-events-none absolute top-1/2 h-6 w-px -translate-y-1/2 bg-foreground\",\n char ? \"right-2\" : \"left-1/2 -translate-x-1/2\"\n )}\n />\n ) : null}\n\n {/* Each glyph is centred absolutely so enter and exit overlap in place. */}\n <AnimatePresence initial={false}>\n {char ? (\n <motion.span\n key={char}\n aria-hidden\n initial={\n reduced\n ? { opacity: 0 }\n : {\n opacity: 0,\n y: 14,\n scale: 0.8,\n filter: \"blur(4px)\",\n }\n }\n animate={{\n opacity: 1,\n y: 0,\n scale: 1,\n filter: \"blur(0px)\",\n }}\n exit={\n reduced\n ? { opacity: 0 }\n : { opacity: 0, y: -14, filter: \"blur(4px)\" }\n }\n transition={reduced ? { duration: 0 } : SPRING_SWAP}\n className=\"pointer-events-none absolute inset-0 grid place-items-center leading-none\"\n >\n {mask ? \"•\" : char}\n </motion.span>\n ) : null}\n </AnimatePresence>\n </div>\n );\n })}\n </OTPField.Root>\n </motion.div>\n\n <AnimatePresence>\n {success ? (\n <motion.span\n aria-hidden\n initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n animate={{ opacity: 1, scale: 1 }}\n exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n transition={\n reduced\n ? { duration: 0 }\n : { type: \"spring\", stiffness: 500, damping: 28 }\n }\n className=\"pointer-events-none absolute top-1/2 -right-7 -translate-y-1/2 text-success\"\n >\n <svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={3}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n className=\"size-5\"\n >\n <motion.path\n d=\"M5 13l4 4L19 7\"\n initial={{ pathLength: reduced ? 1 : 0 }}\n animate={{ pathLength: 1 }}\n transition={\n reduced\n ? { duration: 0 }\n : { duration: 0.35, ease: EASE_OUT, delay: 0.1 }\n }\n />\n </svg>\n </motion.span>\n ) : null}\n </AnimatePresence>\n </div>\n\n {message ? (\n <p\n id={messageId}\n aria-live=\"polite\"\n className={cn(\n \"text-sm\",\n success\n ? \"text-success\"\n : error\n ? \"text-destructive\"\n : \"text-muted-foreground\"\n )}\n >\n {message}\n </p>\n ) : null}\n </div>\n );\n}\n",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"target": "components/ui/otp-input.tsx"
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "page-header",
|
|
4
|
+
"title": "Page Header",
|
|
5
|
+
"description": "The one page title every Intelligo page renders: heading, description and actions.",
|
|
6
|
+
"files": [
|
|
7
|
+
{
|
|
8
|
+
"path": "base/ui/page-header/page-header.tsx",
|
|
9
|
+
"content": "import * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * The one page title every page renders: a heading, an optional\n * description, and actions that wrap under the heading on narrow screens.\n */\nfunction PageHeader({ className, ...props }: React.ComponentProps<\"header\">) {\n return (\n <header\n data-slot=\"page-header\"\n className={cn(\n \"flex flex-wrap items-start justify-between gap-x-6 gap-y-3\",\n className\n )}\n {...props}\n />\n );\n}\n\nfunction PageHeaderContent({\n className,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"page-header-content\"\n className={cn(\"flex min-w-0 flex-col gap-1\", className)}\n {...props}\n />\n );\n}\n\nfunction PageHeaderTitle({\n className,\n level = 1,\n ...props\n}: React.ComponentProps<\"h1\"> & {\n /**\n * 1 for the page's title; 2 for a section rendered under a layout that\n * already carries it (a settings tab under \"Settings\").\n */\n level?: 1 | 2;\n}) {\n const Heading = level === 2 ? \"h2\" : \"h1\";\n return (\n <Heading\n data-slot=\"page-header-title\"\n data-level={level}\n className={cn(\n \"font-semibold tracking-tight text-balance\",\n level === 2 ? \"text-lg\" : \"text-2xl\",\n className\n )}\n {...props}\n />\n );\n}\n\nfunction PageHeaderDescription({\n className,\n ...props\n}: React.ComponentProps<\"p\">) {\n return (\n <p\n data-slot=\"page-header-description\"\n className={cn(\"max-w-prose text-sm text-muted-foreground\", className)}\n {...props}\n />\n );\n}\n\nfunction PageHeaderActions({\n className,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"page-header-actions\"\n className={cn(\"flex shrink-0 flex-wrap items-center gap-2\", className)}\n {...props}\n />\n );\n}\n\nexport {\n PageHeader,\n PageHeaderContent,\n PageHeaderTitle,\n PageHeaderDescription,\n PageHeaderActions,\n};\n",
|
|
10
|
+
"type": "registry:ui",
|
|
11
|
+
"target": "components/ui/page-header.tsx"
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"type": "registry:ui"
|
|
15
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "payment-poll",
|
|
4
|
+
"title": "QR Payment (poll)",
|
|
5
|
+
"description": "The non-card checkout the catalogue was missing: issue an invoice, show a QR the user scans in their banking app, poll until the provider confirms. Provider-agnostic — QPay, SocialPay, PIX, UPI, PromptPay all fit — with the invoice recorded and a paid one granted on the server; lib/local-payment.ts says what each reference costs and grants. Requires the pricing item for lib/billing-config.ts's CURRENCY.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"@intelligo-dev/auth",
|
|
8
|
+
"@intelligo-dev/billing",
|
|
9
|
+
"@intelligo-dev/next",
|
|
10
|
+
"lucide-react",
|
|
11
|
+
"server-only",
|
|
12
|
+
"next-intl"
|
|
13
|
+
],
|
|
14
|
+
"registryDependencies": [
|
|
15
|
+
"@intelligo/button",
|
|
16
|
+
"@intelligo/dialog",
|
|
17
|
+
"@intelligo/spinner"
|
|
18
|
+
],
|
|
19
|
+
"files": [
|
|
20
|
+
{
|
|
21
|
+
"path": "base/payment-poll/components/local-payment-modal.tsx",
|
|
22
|
+
"content": "\"use client\";\n\n/**\n * QR-and-poll payment: issue an invoice, show a code the user scans in\n * their banking app, wait for the provider to confirm.\n *\n * Four states: preparing the invoice, waiting for payment, paid, and\n * failed. Polling stops at `timeoutMs` with a \"still waiting?\" message\n * and a retry, rather than spinning forever.\n *\n * Provider work happens server-side through `@/actions/payment`: the\n * browser never sees provider credentials, never says who is paying —\n * that comes from the session — and never grants anything. A poll that\n * sees the invoice paid has already granted what it bought, as\n * `@/lib/local-payment` prices it.\n */\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { CheckCircle2, XCircle } from \"lucide-react\";\nimport { useFormatter, useTranslations } from \"next-intl\";\n\nimport type { CreatePaymentResult } from \"@intelligo-dev/billing/payment\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogHeader,\n DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { CURRENCY } from \"@/lib/billing-config\";\nimport { paymentPollConfig } from \"@/lib/payment-poll-config\";\nimport { pollLocalPayment, startLocalPayment } from \"@/actions/payment\";\n\ntype LocalPaymentInvoice = Pick<\n CreatePaymentResult,\n \"invoiceId\" | \"qrCode\" | \"deeplinks\"\n>;\n\ntype Step = \"creating\" | \"waiting\" | \"timedOut\" | \"paid\" | \"failed\";\n\ninterface LocalPaymentModalProps {\n open: boolean;\n onClose: () => void;\n /** What is being bought — a plan slug, a bundle id, an order id. */\n reference: string;\n /**\n * Amount to display, in major units of `CURRENCY` (29.5 is twenty-nine\n * and a half). The server prices the invoice itself.\n */\n amount: number;\n /** Localized name of what is being bought. */\n label: string;\n /**\n * Called once the payment is confirmed, after the server granted what\n * it bought — for closing the modal or moving on, not for granting.\n */\n onPaid: () => void;\n}\n\nexport function LocalPaymentModal({\n open,\n onClose,\n reference,\n amount,\n label,\n onPaid,\n}: LocalPaymentModalProps) {\n const t = useTranslations(\"payment-poll\");\n const format = useFormatter();\n\n const [step, setStep] = useState<Step>(\"creating\");\n const [invoice, setInvoice] = useState<LocalPaymentInvoice | null>(null);\n const [error, setError] = useState<string | null>(null);\n const [attempt, setAttempt] = useState(0);\n\n // `onPaid` is usually an inline arrow; holding it in a ref keeps the\n // polling effect from restarting on every parent render.\n const onPaidRef = useRef(onPaid);\n onPaidRef.current = onPaid;\n\n const createInvoice = useCallback(async () => {\n setStep(\"creating\");\n setError(null);\n const result = await startLocalPayment(reference);\n if (!result.success) {\n setError(result.error);\n setStep(\"failed\");\n return;\n }\n setInvoice(result.data);\n setStep(\"waiting\");\n }, [reference]);\n\n useEffect(() => {\n if (!open) return;\n void createInvoice();\n }, [open, attempt, createInvoice]);\n\n // One poll at a time, only while the dialog is open: closing it, or a\n // poll outliving its interval, must not run `onPaid` twice or after\n // the reader has walked away.\n useEffect(() => {\n if (!open || step !== \"waiting\" || !invoice) return;\n\n const { pollIntervalMs, timeoutMs } = paymentPollConfig;\n const startedAt = Date.now();\n let stopped = false;\n let timer: ReturnType<typeof setTimeout> | undefined;\n\n const poll = async () => {\n if (stopped) return;\n if (Date.now() - startedAt > timeoutMs) {\n setStep(\"timedOut\");\n return;\n }\n\n const result = await pollLocalPayment(invoice.invoiceId);\n if (stopped) return;\n // A failed poll is not a failed payment — the provider may just\n // be slow. Keep waiting; the timeout is the only give-up.\n if (result.success && result.data === \"paid\") {\n setStep(\"paid\");\n timer = setTimeout(() => {\n if (!stopped) onPaidRef.current();\n }, 1200);\n return;\n }\n if (result.success && result.data === \"failed\") {\n setError(t(\"errors.declined\"));\n setStep(\"failed\");\n return;\n }\n timer = setTimeout(poll, pollIntervalMs);\n };\n timer = setTimeout(poll, pollIntervalMs);\n\n return () => {\n stopped = true;\n clearTimeout(timer);\n };\n }, [open, step, invoice, t]);\n\n const description =\n step === \"creating\"\n ? t(\"state.creating\")\n : step === \"waiting\"\n ? t(\"state.waiting\")\n : step === \"timedOut\"\n ? t(\"state.timedOut\")\n : step === \"paid\"\n ? t(\"state.paid\")\n : t(\"state.failed\");\n\n return (\n <Dialog open={open} onOpenChange={onClose}>\n <DialogContent className=\"sm:max-w-md\">\n <DialogHeader>\n <DialogTitle className=\"text-center\">\n {t(\"title\", {\n label,\n // The currency's own decimals: 12.50 is not 13.\n amount: format.number(amount, {\n style: \"currency\",\n currency: CURRENCY,\n }),\n })}\n </DialogTitle>\n <DialogDescription className=\"text-center\">\n {description}\n </DialogDescription>\n </DialogHeader>\n\n <div className=\"flex flex-col items-center py-4\">\n {step === \"creating\" ? (\n <Spinner className=\"size-8 text-primary\" aria-hidden />\n ) : null}\n\n {step === \"waiting\" && invoice ? (\n <>\n {invoice.qrCode ? (\n // A plain <img>: the QR is usually a data: URI minted\n // per invoice, so there is nothing for next/image to\n // optimize, and a remote QR host would need adding to\n // next.config's remotePatterns in every consumer.\n <img\n src={invoice.qrCode}\n alt={t(\"qrAlt\")}\n width={200}\n height={200}\n className=\"mb-4 rounded-xl bg-white p-4\"\n />\n ) : null}\n\n {invoice.deeplinks?.length ? (\n <div className=\"mt-2 w-full space-y-2\">\n <p className=\"mb-2 text-center text-xs text-muted-foreground\">\n {t(\"orChooseApp\")}\n </p>\n {invoice.deeplinks.map((link) => (\n <Button\n key={link.url}\n variant=\"outline\"\n className=\"w-full\"\n render={<a href={link.url} />}\n nativeButton={false}\n >\n {link.app}\n </Button>\n ))}\n </div>\n ) : null}\n\n <div className=\"mt-4 flex items-center gap-2 text-sm text-muted-foreground\">\n <Spinner className=\"size-3.5\" aria-hidden />\n <span>{t(\"state.polling\")}</span>\n </div>\n </>\n ) : null}\n\n {step === \"timedOut\" ? (\n <div className=\"space-y-3 text-center\">\n <p className=\"text-sm text-muted-foreground\">\n {t(\"timedOutHelp\")}\n </p>\n <Button onClick={() => setAttempt((n) => n + 1)}>\n {t(\"retry\")}\n </Button>\n </div>\n ) : null}\n\n {step === \"paid\" ? (\n <div className=\"text-center\">\n <CheckCircle2\n className=\"mx-auto size-16 text-primary\"\n aria-hidden\n />\n <p className=\"mt-4 text-lg font-semibold\">{t(\"paidTitle\")}</p>\n <p className=\"mt-1 text-sm text-muted-foreground\">\n {t(\"paidBody\", { label })}\n </p>\n </div>\n ) : null}\n\n {step === \"failed\" ? (\n <div className=\"text-center\">\n <XCircle\n className=\"mx-auto size-16 text-destructive\"\n aria-hidden\n />\n <p className=\"mt-4 text-lg font-semibold\">{t(\"failedTitle\")}</p>\n <p className=\"mt-1 text-sm text-muted-foreground\">{error}</p>\n <div className=\"mt-4 flex justify-center gap-2\">\n <Button onClick={() => setAttempt((n) => n + 1)}>\n {t(\"retry\")}\n </Button>\n <Button variant=\"outline\" onClick={onClose}>\n {t(\"close\")}\n </Button>\n </div>\n </div>\n ) : null}\n </div>\n </DialogContent>\n </Dialog>\n );\n}\n",
|
|
23
|
+
"type": "registry:component",
|
|
24
|
+
"target": "components/billing/local-payment-modal.tsx"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"path": "base/payment-poll/actions.ts",
|
|
28
|
+
"content": "\"use server\";\n\n/**\n * Transport for the QR-and-poll payment flow: resolve the caller's\n * workspace, ask `@/lib/local-payment` what the reference costs and\n * grants, and hand the rest to `@intelligo-dev/billing`, which records\n * the invoice and grants a paid one on the server.\n *\n * Neither action takes a user id, a workspace id or an amount from the\n * browser: who pays is a session fact, and what it costs is priced\n * server-side from `reference`.\n */\n\nimport { revalidatePath } from \"next/cache\";\nimport { getTranslations } from \"next-intl/server\";\n\nimport { requireWorkspace } from \"@intelligo-dev/auth\";\nimport type { ActionResult } from \"@intelligo-dev/next\";\nimport {\n isBillingServiceError,\n openLocalInvoice,\n settleLocalInvoice,\n type LocalPaymentStatus,\n} from \"@intelligo-dev/billing\";\nimport type { CreatePaymentResult } from \"@intelligo-dev/billing/payment\";\n\nimport { priceLocalPayment } from \"@/lib/local-payment\";\n\nexport type PaymentActionResult<T> = ActionResult<T>;\n\ntype LocalPaymentInvoice = Pick<\n CreatePaymentResult,\n \"invoiceId\" | \"qrCode\" | \"deeplinks\"\n>;\n\nexport async function startLocalPayment(\n reference: string\n): Promise<PaymentActionResult<LocalPaymentInvoice>> {\n const t = await getTranslations(\"payment-poll\");\n\n if (typeof reference !== \"string\" || reference.length === 0) {\n return { success: false, error: t(\"errors.unavailable\") };\n }\n\n let context;\n try {\n context = await requireWorkspace();\n } catch {\n return { success: false, error: t(\"errors.unauthorized\") };\n }\n\n try {\n const offer = await priceLocalPayment(reference);\n if (!offer) return { success: false, error: t(\"errors.unavailable\") };\n\n const invoice = await openLocalInvoice({\n workspaceId: context.workspace.id,\n userId: context.user.id,\n reference,\n price: offer.price,\n description: offer.description,\n });\n return {\n success: true,\n data: {\n invoiceId: invoice.invoiceId,\n qrCode: invoice.qrCode,\n deeplinks: invoice.deeplinks,\n },\n };\n } catch (error) {\n // Provider errors can carry account identifiers and endpoint\n // details; the caller gets the generic message, the log the cause.\n console.error(\"[payment-poll]\", error);\n return { success: false, error: t(\"errors.createFailed\") };\n }\n}\n\nexport async function pollLocalPayment(\n invoiceId: string\n): Promise<PaymentActionResult<LocalPaymentStatus>> {\n const t = await getTranslations(\"payment-poll\");\n\n if (typeof invoiceId !== \"string\" || invoiceId.length === 0) {\n return { success: false, error: t(\"errors.statusFailed\") };\n }\n\n let context;\n try {\n context = await requireWorkspace();\n } catch {\n return { success: false, error: t(\"errors.unauthorized\") };\n }\n\n try {\n const status = await settleLocalInvoice({\n invoiceId,\n workspaceId: context.workspace.id,\n fulfil: async (payment) => {\n const offer = await priceLocalPayment(payment.reference);\n if (!offer) {\n throw new Error(\n `\"${payment.reference}\" no longer prices through lib/local-payment.ts; ` +\n `invoice ${payment.invoiceId} is paid and left unfulfilled.`\n );\n }\n return offer.grant;\n },\n });\n // What was bought shows everywhere the workspace's plan or balance\n // is rendered, so every page is stale once it lands.\n if (status === \"paid\") revalidatePath(\"/\", \"layout\");\n return { success: true, data: status };\n } catch (error) {\n if (!isBillingServiceError(error) || error.code !== \"payment_not_found\") {\n console.error(\"[payment-poll]\", error);\n }\n return { success: false, error: t(\"errors.statusFailed\") };\n }\n}\n",
|
|
29
|
+
"type": "registry:file",
|
|
30
|
+
"target": "actions/payment.ts"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"path": "base/payment-poll/lib/local-payment.ts",
|
|
34
|
+
"content": "import \"server-only\";\n\n/**\n * What a local payment buys — consumer-owned.\n *\n * Card checkout is a redirect: you send the user to the processor and\n * they come back. Most of the world's payment methods are not that.\n * QR-and-poll — QPay and SocialPay in Mongolia, PIX in Brazil, UPI in\n * India, PromptPay in Thailand — issues an invoice, shows a code the\n * user scans in their own banking app, and waits for the provider to\n * say it was paid. `LocalPaymentModal` renders that flow.\n *\n * The framework does the rest: `@intelligo-dev/billing` issues the\n * invoice through the provider your composition root registered\n * (`registerPaymentProvider`, selected by PAYMENT_MODE; outside\n * production an unset PAYMENT_MODE is an in-memory mock), records it\n * against the caller's workspace, and when the provider reports it\n * paid, grants what you return here once, on the server.\n *\n * This file answers one question: what does `reference` cost, and what\n * does paying it grant? It is asked when the invoice is opened and again\n * when it is settled. Price it here, never from anything the browser\n * sent: an amount that arrives as an argument is an amount the buyer\n * chose. Return null for a reference you do not sell this way.\n *\n * The default throws rather than pricing anything — a payment flow that\n * silently no-ops is worse than one that is obviously unbound.\n *\n * An implementation, over the plan catalogue:\n *\n * const plan = getPlanBySlug(reference);\n * if (!plan) return null;\n * return {\n * price: fromMajor(plan.priceOneTime, CURRENCY),\n * grant: { plan: plan.slug },\n * description: plan.name,\n * };\n *\n * or a credit bundle: `grant: { credits: fromMajor(5, \"USD\") }`, in the\n * deployment's billing currency.\n */\n\nimport type { LocalPaymentOffer } from \"@intelligo-dev/billing\";\n\nexport async function priceLocalPayment(\n _reference: string\n): Promise<LocalPaymentOffer | null> {\n throw new Error(\n \"priceLocalPayment is not bound. Implement it in lib/local-payment.ts \" +\n \"with what each reference costs and grants, or remove the payment-poll item.\"\n );\n}\n",
|
|
35
|
+
"type": "registry:file",
|
|
36
|
+
"target": "lib/local-payment.ts"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"path": "base/payment-poll/lib/payment-poll-config.ts",
|
|
40
|
+
"content": "/**\n * Polling behaviour for the QR payment modal — consumer-owned.\n *\n * `pollIntervalMs`: how often to ask the provider whether the invoice\n * settled. Three seconds is a compromise: fast enough that the success\n * screen feels immediate, slow enough not to hammer a provider that\n * rate-limits status reads.\n *\n * `timeoutMs`: when to stop asking and offer a retry instead. Five\n * minutes is long enough for someone to switch apps, log into their\n * bank, and confirm — the common case that a shorter timeout would\n * cut off mid-payment.\n */\n\nexport interface PaymentPollConfig {\n pollIntervalMs: number;\n timeoutMs: number;\n}\n\nexport const paymentPollConfig: PaymentPollConfig = {\n pollIntervalMs: 3_000,\n timeoutMs: 5 * 60_000,\n};\n",
|
|
41
|
+
"type": "registry:file",
|
|
42
|
+
"target": "lib/payment-poll-config.ts"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"path": "base/payment-poll/messages/en.json",
|
|
46
|
+
"content": "{\n \"title\": \"{label} — {amount}\",\n \"qrAlt\": \"Payment QR code\",\n \"orChooseApp\": \"Or open your banking app\",\n \"timedOutHelp\": \"We stopped checking after a few minutes. If you've already paid, it may still land — otherwise start a new payment.\",\n \"retry\": \"Try again\",\n \"close\": \"Close\",\n \"paidTitle\": \"Payment received\",\n \"paidBody\": \"{label} is active.\",\n \"failedTitle\": \"Payment failed\",\n \"state\": {\n \"creating\": \"Preparing your payment…\",\n \"waiting\": \"Scan the code or choose your bank\",\n \"polling\": \"Waiting for payment…\",\n \"timedOut\": \"Still waiting?\",\n \"paid\": \"All set\",\n \"failed\": \"Something went wrong\"\n },\n \"errors\": {\n \"unauthorized\": \"Please sign in and try again.\",\n \"unavailable\": \"This can't be paid for this way.\",\n \"createFailed\": \"We couldn't start the payment. Try again in a moment.\",\n \"statusFailed\": \"We couldn't check the payment status.\",\n \"declined\": \"The payment was declined.\"\n }\n}\n",
|
|
47
|
+
"type": "registry:file",
|
|
48
|
+
"target": "messages/en/payment-poll.json"
|
|
49
|
+
}
|
|
50
|
+
],
|
|
51
|
+
"type": "registry:block"
|
|
52
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "popover-morph",
|
|
4
|
+
"title": "Popover Morph",
|
|
5
|
+
"description": "A non-modal popover whose trigger surface grows into the panel through a shared layout and folds back on close.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"@base-ui/react",
|
|
8
|
+
"motion"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"@intelligo/ai-motion"
|
|
12
|
+
],
|
|
13
|
+
"files": [
|
|
14
|
+
{
|
|
15
|
+
"path": "base/ui/popover-morph/popover-morph.tsx",
|
|
16
|
+
"content": "\"use client\";\n\n/*\n * A popover whose trigger becomes its panel: the trigger's surface and the\n * panel share a layoutId, so opening grows the one surface out of the\n * trigger and closing folds it back in.\n *\n * Behaviour is Base UI's non-modal Dialog rather than its Popover: the\n * Popover's Positioner owns the popup's geometry and settles it after\n * mount, which fights a shared-layout morph measuring the same box. A\n * non-modal Dialog keeps what a popover needs — Escape and outside-press\n * dismissal, closing when focus leaves, focus in and back to the trigger —\n * and leaves placement to this item, which anchors the panel to the\n * trigger's rect.\n */\n\nimport * as React from \"react\";\nimport { Dialog as DialogPrimitive } from \"@base-ui/react/dialog\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nimport { EASE_OUT, SPRING_LAYOUT, useOpenState } from \"@/components/ui/ai-motion\";\nimport { cn } from \"@/lib/utils\";\n\ntype Side = \"top\" | \"bottom\";\ntype Align = \"start\" | \"end\";\n\ntype AnchorRect = {\n top: number;\n right: number;\n bottom: number;\n left: number;\n viewportWidth: number;\n viewportHeight: number;\n};\n\ntype MorphPopoverContextValue = {\n open: boolean;\n /** True once the panel can be placed; the trigger hands its surface over then. */\n showPanel: boolean;\n anchor: AnchorRect | null;\n layoutId: string;\n triggerRef: React.RefObject<HTMLElement | null>;\n};\n\nconst MorphPopoverContext =\n React.createContext<MorphPopoverContextValue | null>(null);\n\nfunction useMorphPopover(component: string) {\n const context = React.useContext(MorphPopoverContext);\n if (!context) {\n throw new Error(`${component} must be used within <MorphPopover>`);\n }\n return context;\n}\n\n// The layout morph settles in about this long; the popup stays mounted\n// through it so the panel can fold back into the trigger.\nconst MORPH_HOLD = 0.4;\n\nfunction measure(node: HTMLElement): AnchorRect {\n const rect = node.getBoundingClientRect();\n const root = document.documentElement;\n return {\n top: rect.top,\n right: rect.right,\n bottom: rect.bottom,\n left: rect.left,\n viewportWidth: root.clientWidth,\n viewportHeight: root.clientHeight,\n };\n}\n\nfunction sameRect(a: AnchorRect | null, b: AnchorRect) {\n return (\n a !== null &&\n a.top === b.top &&\n a.right === b.right &&\n a.bottom === b.bottom &&\n a.left === b.left &&\n a.viewportWidth === b.viewportWidth &&\n a.viewportHeight === b.viewportHeight\n );\n}\n\nexport type MorphPopoverProps = Omit<DialogPrimitive.Root.Props, \"modal\">;\n\n/**\n * The root. Controlled (`open` + `onOpenChange`) or uncontrolled\n * (`defaultOpen`), like any Base UI root.\n */\nfunction MorphPopover({\n open: openProp,\n defaultOpen,\n onOpenChange,\n children,\n ...props\n}: MorphPopoverProps) {\n const [open, setOpen] = useOpenState(openProp, defaultOpen, onOpenChange);\n const [anchor, setAnchor] = React.useState<AnchorRect | null>(null);\n const triggerRef = React.useRef<HTMLElement | null>(null);\n const layoutId = `morph-popover-${React.useId()}`;\n\n // Track the trigger while open, so the panel follows a scroll or resize.\n React.useLayoutEffect(() => {\n const trigger = triggerRef.current;\n if (!open || !trigger) return;\n const update = () => {\n const next = measure(trigger);\n setAnchor((current) => (sameRect(current, next) ? current : next));\n };\n update();\n const observer = new ResizeObserver(update);\n observer.observe(trigger);\n window.addEventListener(\"scroll\", update, true);\n window.addEventListener(\"resize\", update);\n return () => {\n observer.disconnect();\n window.removeEventListener(\"scroll\", update, true);\n window.removeEventListener(\"resize\", update);\n };\n }, [open]);\n\n const context = React.useMemo<MorphPopoverContextValue>(\n () => ({\n open,\n showPanel: open && anchor !== null,\n anchor,\n layoutId,\n triggerRef,\n }),\n [open, anchor, layoutId]\n );\n\n return (\n <MorphPopoverContext.Provider value={context}>\n <DialogPrimitive.Root\n data-slot=\"morph-popover\"\n modal={false}\n open={open}\n onOpenChange={setOpen}\n {...props}\n >\n {children}\n </DialogPrimitive.Root>\n </MorphPopoverContext.Provider>\n );\n}\n\nexport interface MorphPopoverTriggerProps\n extends React.ComponentProps<typeof DialogPrimitive.Trigger> {\n /** The trigger surface's corner radius, in px. Default 8. */\n radius?: number;\n}\n\n/**\n * The trigger. Its surface is the shape the panel grows out of; its label\n * fades while the panel is out. Pass `render` to swap the element — a\n * transparent one, since the surface draws the fill.\n */\nfunction MorphPopoverTrigger({\n radius = 8,\n className,\n children,\n ref,\n ...props\n}: MorphPopoverTriggerProps) {\n const context = useMorphPopover(\"MorphPopoverTrigger\");\n const reduced = useReducedMotion() ?? false;\n const { triggerRef } = context;\n\n const setRef = React.useCallback(\n (node: HTMLButtonElement | null) => {\n triggerRef.current = node;\n if (typeof ref === \"function\") ref(node);\n else if (ref) ref.current = node;\n },\n [ref, triggerRef]\n );\n\n return (\n <DialogPrimitive.Trigger\n data-slot=\"morph-popover-trigger\"\n ref={setRef}\n className={cn(\n \"relative isolate inline-flex h-8 items-center gap-1.5 px-3 text-sm font-medium text-foreground outline-none select-none focus-visible:ring-3 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n className\n )}\n style={{ borderRadius: radius }}\n {...props}\n >\n {!context.showPanel && (\n <motion.span\n aria-hidden\n layoutId={reduced ? undefined : context.layoutId}\n transition={SPRING_LAYOUT}\n className=\"absolute inset-0 -z-10 border border-border bg-popover shadow-xs\"\n style={{ borderRadius: radius }}\n />\n )}\n <span\n className={cn(\n \"inline-flex items-center gap-1.5 transition-opacity duration-fast ease-standard motion-reduce:transition-none\",\n context.showPanel && \"opacity-0\"\n )}\n >\n {children}\n </span>\n </DialogPrimitive.Trigger>\n );\n}\n\nexport interface MorphPopoverContentProps\n extends Omit<DialogPrimitive.Popup.Props, \"children\" | \"className\"> {\n className?: string;\n children?: React.ReactNode;\n /** Which side of the trigger the panel opens on. Default \"bottom\". */\n side?: Side;\n /** Which trigger edge the panel lines up with. Default \"end\". */\n align?: Align;\n /** Gap between trigger and panel, in px. Default 8. */\n sideOffset?: number;\n /** Panel corner radius, in px. Default 8. */\n radius?: number;\n}\n\n/** The panel, grown out of the trigger's surface. */\nfunction MorphPopoverContent({\n side = \"bottom\",\n align = \"end\",\n sideOffset = 8,\n radius = 8,\n className,\n style,\n children,\n ...props\n}: MorphPopoverContentProps) {\n const context = useMorphPopover(\"MorphPopoverContent\");\n const reduced = useReducedMotion() ?? false;\n const anchor = context.anchor;\n\n const placement: React.CSSProperties = anchor\n ? {\n position: \"fixed\",\n ...(side === \"bottom\"\n ? { top: anchor.bottom + sideOffset }\n : { bottom: anchor.viewportHeight - anchor.top + sideOffset }),\n ...(align === \"start\"\n ? { left: anchor.left }\n : { right: anchor.viewportWidth - anchor.right }),\n }\n : { position: \"fixed\" };\n\n return (\n <AnimatePresence>\n {context.showPanel && (\n <DialogPrimitive.Portal keepMounted>\n <DialogPrimitive.Popup\n data-slot=\"morph-popover-content\"\n // Opacity stays on the popup so Base UI waits out the fold back.\n render={\n <motion.div\n initial={{ opacity: 0.9999 }}\n animate={{ opacity: 1 }}\n exit={{\n opacity: 0.9999,\n transition: { duration: reduced ? 0 : MORPH_HOLD },\n }}\n />\n }\n className=\"z-popover text-sm text-popover-foreground outline-none\"\n style={{ ...placement, ...style }}\n {...props}\n >\n <motion.div\n layoutId={reduced ? undefined : context.layoutId}\n transition={SPRING_LAYOUT}\n className=\"overflow-hidden border border-border bg-popover shadow-lg\"\n style={{ borderRadius: radius }}\n >\n <motion.div\n layout={reduced ? false : \"position\"}\n initial={{ opacity: 0 }}\n animate={{\n opacity: 1,\n transition: {\n duration: reduced ? 0 : 0.2,\n delay: reduced ? 0 : 0.06,\n ease: EASE_OUT,\n },\n }}\n exit={{\n opacity: 0,\n transition: { duration: reduced ? 0 : 0.1, ease: EASE_OUT },\n }}\n className={cn(\"p-3\", className)}\n >\n {children}\n </motion.div>\n </motion.div>\n </DialogPrimitive.Popup>\n </DialogPrimitive.Portal>\n )}\n </AnimatePresence>\n );\n}\n\nfunction MorphPopoverTitle({\n className,\n ...props\n}: DialogPrimitive.Title.Props) {\n return (\n <DialogPrimitive.Title\n data-slot=\"morph-popover-title\"\n className={cn(\"text-sm font-medium\", className)}\n {...props}\n />\n );\n}\n\nfunction MorphPopoverDescription({\n className,\n ...props\n}: DialogPrimitive.Description.Props) {\n return (\n <DialogPrimitive.Description\n data-slot=\"morph-popover-description\"\n className={cn(\"text-sm text-muted-foreground\", className)}\n {...props}\n />\n );\n}\n\nfunction MorphPopoverClose(props: DialogPrimitive.Close.Props) {\n return <DialogPrimitive.Close data-slot=\"morph-popover-close\" {...props} />;\n}\n\nexport {\n MorphPopover,\n MorphPopoverClose,\n MorphPopoverContent,\n MorphPopoverDescription,\n MorphPopoverTitle,\n MorphPopoverTrigger,\n};\n",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"target": "components/ui/popover-morph.tsx"
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "popover",
|
|
4
|
+
"title": "Popover",
|
|
5
|
+
"description": "Floating content in a rounded panel that scales out of its trigger.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"@base-ui/react",
|
|
8
|
+
"motion"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"@intelligo/ai-motion"
|
|
12
|
+
],
|
|
13
|
+
"files": [
|
|
14
|
+
{
|
|
15
|
+
"path": "base/ui/popover/popover.tsx",
|
|
16
|
+
"content": "\"use client\";\n\n/*\n * The popover: a rounded panel that scales out of its trigger. The panel\n * springs open with motion; the root is kept controlled so the exit plays.\n */\n\nimport * as React from \"react\";\nimport { Popover as PopoverPrimitive } from \"@base-ui/react/popover\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nimport { popupMotion, useOpenState } from \"@/components/ui/ai-motion\";\nimport { cn } from \"@/lib/utils\";\n\nconst PopoverOpenContext = React.createContext<boolean | null>(null);\n\nfunction Popover({\n open: openProp,\n defaultOpen,\n onOpenChange,\n ...props\n}: PopoverPrimitive.Root.Props) {\n const [open, setOpen] = useOpenState(openProp, defaultOpen, onOpenChange);\n return (\n <PopoverOpenContext.Provider value={open}>\n <PopoverPrimitive.Root\n data-slot=\"popover\"\n open={open}\n onOpenChange={setOpen}\n {...props}\n />\n </PopoverOpenContext.Provider>\n );\n}\n\nfunction PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {\n return <PopoverPrimitive.Trigger data-slot=\"popover-trigger\" {...props} />;\n}\n\nfunction PopoverContent({\n className,\n align = \"center\",\n alignOffset = 0,\n side = \"bottom\",\n sideOffset = 6,\n ...props\n}: PopoverPrimitive.Popup.Props &\n Pick<\n PopoverPrimitive.Positioner.Props,\n \"align\" | \"alignOffset\" | \"side\" | \"sideOffset\"\n >) {\n const open = React.useContext(PopoverOpenContext) ?? true;\n const reduced = useReducedMotion() ?? false;\n\n return (\n <AnimatePresence>\n {open && (\n <PopoverPrimitive.Portal keepMounted>\n <PopoverPrimitive.Positioner\n align={align}\n alignOffset={alignOffset}\n side={side}\n sideOffset={sideOffset}\n className=\"isolate z-popover\"\n >\n <PopoverPrimitive.Popup\n data-slot=\"popover-content\"\n render={<motion.div {...popupMotion(reduced)} />}\n className={cn(\n \"flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg border border-border bg-popover p-3 text-sm text-popover-foreground shadow-lg outline-hidden\",\n className\n )}\n {...props}\n />\n </PopoverPrimitive.Positioner>\n </PopoverPrimitive.Portal>\n )}\n </AnimatePresence>\n );\n}\n\nfunction PopoverHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"popover-header\"\n className={cn(\"flex flex-col gap-0.5 text-sm\", className)}\n {...props}\n />\n );\n}\n\nfunction PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {\n return (\n <PopoverPrimitive.Title\n data-slot=\"popover-title\"\n className={cn(\"font-medium\", className)}\n {...props}\n />\n );\n}\n\nfunction PopoverDescription({\n className,\n ...props\n}: PopoverPrimitive.Description.Props) {\n return (\n <PopoverPrimitive.Description\n data-slot=\"popover-description\"\n className={cn(\"text-muted-foreground\", className)}\n {...props}\n />\n );\n}\n\nexport {\n Popover,\n PopoverContent,\n PopoverDescription,\n PopoverHeader,\n PopoverTitle,\n PopoverTrigger,\n};\n",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"target": "components/ui/popover.tsx"
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|