@allbluecn/web-app 0.4.17 → 0.4.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/package.json +10 -10
  2. package/src/components/billing/checkout-redirect.ts +1 -1
  3. package/src/components/changelog/changelog-page.tsx +1 -1
  4. package/src/components/layout/sidebar-03/app-sidebar.tsx +2 -3
  5. package/src/components/layout/sidebar-03/nav-notifications.tsx +115 -35
  6. package/src/components/layout/sidebar-03/user-menu.tsx +1 -1
  7. package/src/components/notifications/notification-list.tsx +232 -0
  8. package/src/components/notifications/notification-preferences.tsx +156 -0
  9. package/src/components/settings/ai/create-custom-provider-dialog.tsx +145 -0
  10. package/src/components/settings/ai/provider-grid.tsx +46 -25
  11. package/src/components/settings/personal-team/team/team-client.tsx +118 -15
  12. package/src/components/settings/personal-team/team/team-invitations.tsx +887 -416
  13. package/src/components/settings/personal-team/team/team-member-list.tsx +122 -136
  14. package/src/hooks/__tests__/use-notifications.test.ts +41 -0
  15. package/src/hooks/use-notifications.ts +119 -0
  16. package/src/lib/app-version.ts +4 -1
  17. package/src/lib/auth/auth.config.ts +17 -1
  18. package/src/lib/changelog/sdk-versions-types.ts +1 -0
  19. package/src/lib/changelog/sdk-versions.ts +29 -19
  20. package/src/lib/collections/ai/index.ts +0 -1
  21. package/src/lib/collections/index.ts +0 -1
  22. package/src/plugins/.gen-manifest.json +13 -0
  23. package/src/plugins/modules.gen.ts +20 -0
  24. package/src/plugins/plugins.gen.ts +20 -0
  25. package/src/plugins/ui.gen.ts +10 -0
  26. package/src/routeTree.gen.ts +70 -0
  27. package/src/routes/_login/login/route.tsx +10 -1
  28. package/src/routes/_login/register/route.lazy.tsx +3 -2
  29. package/src/routes/_login/register/route.tsx +5 -0
  30. package/src/routes/_nav/inspiration/route.tsx +8 -0
  31. package/src/routes/_nav/notifications/route.lazy.tsx +23 -0
  32. package/src/routes/_nav/notifications/route.tsx +8 -0
  33. package/src/routes/_nav/prd/$id/route.tsx +9 -0
  34. package/src/routes/_nav/prd/index.tsx +9 -0
  35. package/src/routes/_nav/prd/route.tsx +8 -0
  36. package/src/routes/_nav/settings/-settings-tabs.tsx +2 -1
  37. package/src/routes/_nav/settings/design/route.lazy.tsx +1 -1
  38. package/src/routes/_nav/settings/notifications/route.lazy.tsx +37 -0
  39. package/src/routes/_nav/settings/notifications/route.tsx +20 -0
  40. package/src/routes/_nav/settings/privacy/route.lazy.tsx +2 -2
  41. package/src/routes/_nav/settings/route.lazy.tsx +1 -1
  42. package/src/routes/_nav/tags/$id/route.tsx +8 -0
  43. package/src/routes/_nav/tags/index.tsx +8 -0
  44. package/src/routes/_nav/tags/route.tsx +8 -0
  45. package/src/routes/_nav/tags/settings/route.tsx +9 -0
  46. package/src/routes/api/notifications/stream.ts +88 -0
  47. package/src/routes/invite/$token/route.lazy.tsx +308 -44
  48. package/src/routes/invite/$token/route.tsx +16 -8
  49. package/src/routes/share/prd/$prdId/route.tsx +10 -0
  50. package/src/server/notifications/__tests__/notification-service.test.ts +114 -0
  51. package/src/server/notifications/__tests__/stream.test.ts +30 -0
  52. package/src/server/notifications/event-bus.ts +16 -0
  53. package/src/server/notifications/notification-service.ts +86 -0
  54. package/src/server/resources.ts +10 -2
  55. package/src/server/serverFns/__tests__/compliance.test.ts +6 -6
  56. package/src/server/serverFns/__tests__/notifications.test.ts +63 -0
  57. package/src/server/serverFns/__tests__/team-accept.test.ts +100 -12
  58. package/src/server/serverFns/__tests__/team-invitation.test.ts +130 -60
  59. package/src/server/serverFns/__tests__/team-join-request.test.ts +281 -0
  60. package/src/server/serverFns/__tests__/team-members.test.ts +39 -118
  61. package/src/{lib/collections/ai/ai-providers-collection.ts → server/serverFns/auth/setup.ts} +7 -12
  62. package/src/server/serverFns/billing.ts +23 -2
  63. package/src/server/serverFns/notifications.ts +155 -0
  64. package/src/server/serverFns/story/story-comments.ts +42 -0
  65. package/src/server/serverFns/team.ts +573 -53
  66. package/src/start.ts +23 -1
  67. package/vite.shared.ts +13 -0
@@ -0,0 +1,9 @@
1
+ // AUTO-GENERATED by @allbluecn/kernel — DO NOT EDIT
2
+ import { createFileRoute } from '@tanstack/react-router'
3
+ import type { PluginRouteContext } from '@allbluecn/kernel'
4
+ import { prd_route_1 } from '@allbluecn/plugins-core/prd/routes'
5
+
6
+ export const Route = createFileRoute('/_nav/prd/')({
7
+ loader: ((ctx: PluginRouteContext) => prd_route_1.loader(ctx)) as any,
8
+ component: prd_route_1.Component as any,
9
+ })
@@ -0,0 +1,8 @@
1
+ // AUTO-GENERATED by @allbluecn/kernel — DO NOT EDIT
2
+ import { createFileRoute } from '@tanstack/react-router'
3
+ import type { PluginRouteContext } from '@allbluecn/kernel'
4
+ import { prd_route_0 } from '@allbluecn/plugins-core/prd/routes'
5
+
6
+ export const Route = createFileRoute('/_nav/prd')({
7
+ component: prd_route_0.Component as any,
8
+ })
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { Link, useLocation } from '@tanstack/react-router'
19
19
  import { useTranslation } from 'react-i18next'
20
- import { User, Users, Bot, Palette, Settings, CreditCard, Plug, ShieldCheck, type LucideIcon } from 'lucide-react'
20
+ import { User, Users, Bot, Palette, Settings, CreditCard, Plug, ShieldCheck, Bell, type LucideIcon } from 'lucide-react'
21
21
  import { cn } from '@/lib/utils'
22
22
  import { mergeSettingsGroups } from '@/components/plugin-slots/plugin-settings-tabs'
23
23
 
@@ -45,6 +45,7 @@ const groups: SettingsGroup[] = [
45
45
  { labelKey: 'tabs.accountProfile', path: '/settings/account', icon: User },
46
46
  { labelKey: 'tabs.teamMembers', path: '/settings/team', icon: Users },
47
47
  { labelKey: 'tabs.dataPrivacy', path: '/settings/privacy', icon: ShieldCheck },
48
+ { labelKey: 'tabs.notifications', path: '/settings/notifications', icon: Bell },
48
49
  ],
49
50
  },
50
51
  {
@@ -70,7 +70,7 @@ function DesignSettingsPage() {
70
70
  }, [])
71
71
 
72
72
  const generateMutation = useMutation({
73
- mutationFn: () => generateSketchTokenFn(),
73
+ mutationFn: () => generateSketchTokenFn({ data: {} }),
74
74
  onSuccess: (data) => {
75
75
  toast.success(t('design.tokenGenerated'))
76
76
  setToken(data.token)
@@ -0,0 +1,37 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { createLazyFileRoute } from '@tanstack/react-router'
19
+ import { useTranslation } from 'react-i18next'
20
+ import { NotificationPreferences } from '@/components/notifications/notification-preferences'
21
+
22
+ export const Route = createLazyFileRoute('/_nav/settings/notifications')({
23
+ component: NotificationsSettingsPage,
24
+ })
25
+
26
+ function NotificationsSettingsPage() {
27
+ const { t } = useTranslation('notifications')
28
+ return (
29
+ <div className="flex flex-col gap-6 p-6">
30
+ <div className="flex flex-col gap-1">
31
+ <h1 className="text-xl font-semibold">{t('preferences.title')}</h1>
32
+ <p className="text-sm text-muted-foreground">{t('preferences.description')}</p>
33
+ </div>
34
+ <NotificationPreferences />
35
+ </div>
36
+ )
37
+ }
@@ -0,0 +1,20 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { createFileRoute } from '@tanstack/react-router'
19
+
20
+ export const Route = createFileRoute('/_nav/settings/notifications')({})
@@ -61,7 +61,7 @@ function PrivacySettingsPage() {
61
61
  const handleExport = async () => {
62
62
  setExporting(true)
63
63
  try {
64
- await requestMyExportFn()
64
+ await requestMyExportFn({ data: {} })
65
65
  toast.success(t('privacy.submitted'))
66
66
  } catch (err) {
67
67
  toast.error(err instanceof Error ? err.message : '操作失败')
@@ -74,7 +74,7 @@ function PrivacySettingsPage() {
74
74
  if (!userName || confirmName.trim() !== userName.trim()) return
75
75
  setDeleting(true)
76
76
  try {
77
- await requestMyDeletionFn()
77
+ await requestMyDeletionFn({ data: {} })
78
78
  toast.success(t('privacy.submitted'))
79
79
  setDialogOpen(false)
80
80
  setConfirmName('')
@@ -34,7 +34,7 @@ function SettingsLayout() {
34
34
  </div>
35
35
  </aside>
36
36
  <section className="min-w-0 flex-1 overflow-y-auto flex justify-center">
37
- <div className="max-w-3xl w-full">
37
+ <div className="max-w-4xl w-full">
38
38
  <Outlet />
39
39
  </div>
40
40
  </section>
@@ -0,0 +1,8 @@
1
+ // AUTO-GENERATED by @allbluecn/kernel — DO NOT EDIT
2
+ import { createFileRoute } from '@tanstack/react-router'
3
+ import type { PluginRouteContext } from '@allbluecn/kernel'
4
+ import { tags_route_2 } from '@allbluecn/plugins-core/tags/routes'
5
+
6
+ export const Route = createFileRoute('/_nav/tags/$id')({
7
+ component: tags_route_2.Component as any,
8
+ })
@@ -0,0 +1,8 @@
1
+ // AUTO-GENERATED by @allbluecn/kernel — DO NOT EDIT
2
+ import { createFileRoute } from '@tanstack/react-router'
3
+ import type { PluginRouteContext } from '@allbluecn/kernel'
4
+ import { tags_route_1 } from '@allbluecn/plugins-core/tags/routes'
5
+
6
+ export const Route = createFileRoute('/_nav/tags/')({
7
+ component: tags_route_1.Component as any,
8
+ })
@@ -0,0 +1,8 @@
1
+ // AUTO-GENERATED by @allbluecn/kernel — DO NOT EDIT
2
+ import { createFileRoute } from '@tanstack/react-router'
3
+ import type { PluginRouteContext } from '@allbluecn/kernel'
4
+ import { tags_route_0 } from '@allbluecn/plugins-core/tags/routes'
5
+
6
+ export const Route = createFileRoute('/_nav/tags')({
7
+ component: tags_route_0.Component as any,
8
+ })
@@ -0,0 +1,9 @@
1
+ // AUTO-GENERATED by @allbluecn/kernel — DO NOT EDIT
2
+ import { createFileRoute } from '@tanstack/react-router'
3
+ import type { PluginRouteContext } from '@allbluecn/kernel'
4
+ import { tags_route_3 } from '@allbluecn/plugins-core/tags/routes'
5
+
6
+ export const Route = createFileRoute('/_nav/tags/settings')({
7
+ loader: ((ctx: PluginRouteContext) => tags_route_3.loader(ctx)) as any,
8
+ component: tags_route_3.Component as any,
9
+ })
@@ -0,0 +1,88 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS for a PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { createFileRoute } from '@tanstack/react-router'
19
+ import { toServerSentEventsResponse } from '@tanstack/ai'
20
+ import type { StreamChunk } from '@tanstack/ai'
21
+ import { prisma } from '@allbluecn/database'
22
+ import { getCurrentSession } from '@/lib/session'
23
+ import { notificationBus } from '@/server/notifications/event-bus'
24
+ import type { NotificationSseValue } from '@allbluecn/shared'
25
+
26
+ export const Route = createFileRoute('/api/notifications/stream')({
27
+ server: {
28
+ handlers: {
29
+ GET: async ({ request }) => {
30
+ let session = null as any
31
+ try {
32
+ session = await getCurrentSession(request)
33
+ } catch {
34
+ return new Response('Unauthorized', { status: 401 })
35
+ }
36
+ if (!session?.user?.id) {
37
+ return new Response('Unauthorized', { status: 401 })
38
+ }
39
+ const userId = session.user.id
40
+
41
+ const abortController = new AbortController()
42
+ request.signal.addEventListener('abort', () => abortController.abort())
43
+
44
+ const queue: NotificationSseValue[] = []
45
+ let notify: (() => void) | null = null
46
+
47
+ const unsubscribe = notificationBus.subscribe(userId, (value) => {
48
+ queue.push(value)
49
+ notify?.()
50
+ })
51
+
52
+ async function* stream(): AsyncGenerator<StreamChunk> {
53
+ try {
54
+ const count = await prisma.notification.count({
55
+ where: { userId, readAt: null },
56
+ })
57
+ yield {
58
+ type: 'CUSTOM',
59
+ name: 'notifications',
60
+ value: { kind: 'unread_snapshot', count },
61
+ } as unknown as StreamChunk
62
+
63
+ while (!abortController.signal.aborted) {
64
+ if (queue.length === 0) {
65
+ await new Promise<void>((resolve) => {
66
+ notify = resolve
67
+ setTimeout(resolve, 25000)
68
+ })
69
+ notify = null
70
+ continue
71
+ }
72
+ const value = queue.shift()!
73
+ yield {
74
+ type: 'CUSTOM',
75
+ name: 'notifications',
76
+ value,
77
+ } as unknown as StreamChunk
78
+ }
79
+ } finally {
80
+ unsubscribe()
81
+ }
82
+ }
83
+
84
+ return toServerSentEventsResponse(stream(), { abortController })
85
+ },
86
+ },
87
+ },
88
+ })
@@ -12,64 +12,328 @@
12
12
  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
13
  // GNU Affero General Public License for more details.
14
14
  //
15
- // You should have received a copy of this license.
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
16
17
 
17
- import { createLazyFileRoute, useNavigate } from '@tanstack/react-router'
18
- import { useQuery, useMutation } from '@tanstack/react-query'
18
+ import { createLazyFileRoute, Link, useNavigate } from '@tanstack/react-router'
19
+ import { useState, useEffect } from 'react'
20
+ import { useMutation } from '@tanstack/react-query'
19
21
  import { useTranslation } from 'react-i18next'
20
- import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
21
- import { Button } from '@/components/ui/button'
22
- import { Badge } from '@/components/ui/badge'
23
- import { toast } from 'sonner'
24
- import { getTeamInvitationByTokenFn, acceptTeamInvitationFn } from '@/server/serverFns/team'
25
- import { ArrowRight } from 'lucide-react'
22
+ import { toast } from '@/components/ui/sonner'
23
+ import { RainbowButton } from '@/components/magicui/rainbow-button'
24
+ import { UserAvatar } from '@allbluecn/ui'
25
+ import { AuthLayout, AuthLayoutLeft, AuthLayoutRight } from '@/components/auth/auth-layout'
26
+ import { AuthBrandPanel } from '@/components/auth/auth-brand-panel'
27
+ import { IconInput } from '@/components/auth/icon-input'
28
+ import { Card, CardContent } from '@/components/ui/card'
29
+ import { ArrowRight, CheckCircle2, Info, Loader2, Mail, UserCircle } from 'lucide-react'
30
+ import { useDebouncedCallback } from '@/lib/pacer'
31
+ import { acceptTeamInvitationFn, applyTeamInvitationFn, checkInviteEmailRegisteredFn } from '@/server/serverFns/team'
32
+
33
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
26
34
 
27
35
  export const Route = createLazyFileRoute('/invite/$token')({
28
36
  component: InvitePage,
29
37
  })
30
38
 
39
+ type EmailCheckState = 'idle' | 'checking' | 'registered' | 'unregistered'
40
+
31
41
  function InvitePage() {
32
- const { token } = Route.useRouteContext()
42
+ const { token, inv } = Route.useRouteContext()
33
43
  const navigate = useNavigate()
34
44
  const { t } = useTranslation('invite')
35
- const { data, isLoading } = useQuery({
36
- queryKey: ['teamInvitation', token],
37
- queryFn: () => getTeamInvitationByTokenFn({ data: { token } }),
38
- })
39
- const accept = useMutation({
45
+ const ctx = Route.useRouteContext() as any
46
+ const session = ctx?.session
47
+ const isLoggedIn = !!session?.user?.id
48
+ const emailPrefilled = inv?.channel === 'email' && !!inv?.email
49
+
50
+ const [emailInput, setEmailInput] = useState('')
51
+ const [emailCheck, setEmailCheck] = useState<EmailCheckState>(
52
+ emailPrefilled ? 'checking' : 'idle',
53
+ )
54
+ const [applied, setApplied] = useState(false)
55
+
56
+ const acceptMutation = useMutation({
40
57
  mutationFn: () => acceptTeamInvitationFn({ data: { token } }),
41
- onSuccess: () => {
42
- toast.success(t('accepted'))
43
- navigate({ to: '/dashboard' })
44
- },
58
+ onSuccess: () => setApplied(true),
45
59
  onError: (e: Error) => toast.error(e.message),
46
60
  })
47
- if (isLoading) return null
48
- if (!data) return <div className="p-10">{t('notFound')}</div>
49
- const ok = data.effectiveStatus === 'pending'
50
- return (
51
- <div className="flex min-h-dvh items-center justify-center p-6">
52
- <Card className="w-full max-w-md">
53
- <CardHeader>
54
- <CardTitle>{t('title', { owner: data.owner.username })}</CardTitle>
55
- </CardHeader>
56
- <CardContent className="space-y-4">
57
- <div className="flex items-center gap-2 text-sm text-muted-foreground">
58
- {t('seatType')}:
59
- <Badge variant="outline">{data.seatType === 'guest' ? t('guest') : t('member')}</Badge>
61
+
62
+ const applyMutation = useMutation({
63
+ mutationFn: (email: string) => applyTeamInvitationFn({ data: { token, email } }),
64
+ onSuccess: () => setApplied(true),
65
+ onError: (e: Error) => toast.error(e.message),
66
+ })
67
+
68
+ const checkEmail = useDebouncedCallback(async (email: string) => {
69
+ try {
70
+ const { registered } = await checkInviteEmailRegisteredFn({ data: { email } })
71
+ setEmailCheck(registered ? 'registered' : 'unregistered')
72
+ } catch {
73
+ setEmailCheck('idle')
74
+ }
75
+ }, { wait: 500 })
76
+
77
+ useEffect(() => {
78
+ const email = (emailPrefilled ? inv!.email ?? '' : emailInput).trim()
79
+ if (!EMAIL_RE.test(email)) {
80
+ setEmailCheck('idle')
81
+ return
82
+ }
83
+ setEmailCheck('checking')
84
+ void checkEmail(email.toLowerCase())
85
+ }, [emailInput, emailPrefilled, inv?.email, checkEmail])
86
+
87
+ if (!inv || inv.effectiveStatus === 'expired' || inv.effectiveStatus === 'revoked') {
88
+ return (
89
+ <AuthLayout>
90
+ <AuthLayoutLeft>
91
+ <div className="space-y-4 text-center">
92
+ <div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
93
+ <Info className="size-8 text-destructive" />
94
+ </div>
95
+ <h1 className="text-2xl font-semibold">{t('expiredOrInvalid')}</h1>
96
+ <p className="text-sm text-muted-foreground">{t('expiredOrInvalidDesc')}</p>
97
+ <Link to="/login" className="mt-4 inline-block text-sm text-primary hover:underline">
98
+ {t('goToLogin')}
99
+ </Link>
60
100
  </div>
61
- {ok ? (
62
- <Button className="w-full" disabled={accept.isPending} onClick={() => accept.mutate()}>
63
- {t('accept')}
64
- <ArrowRight className="ml-2 size-4" />
65
- </Button>
66
- ) : (
67
- <p className="text-sm text-muted-foreground">
68
- {data.effectiveStatus === 'expired' ? t('expired') : t('invalid')}
69
- </p>
70
- )}
71
- </CardContent>
72
- </Card>
101
+ </AuthLayoutLeft>
102
+ <AuthLayoutRight>
103
+ <AuthBrandPanel footer={<p className="text-sm text-muted-foreground">{t('brandFooter')}</p>} title={t('brandTitle')} />
104
+ </AuthLayoutRight>
105
+ </AuthLayout>
106
+ )
107
+ }
108
+
109
+ if (!isLoggedIn && inv.channel === 'email' && inv.effectiveStatus === 'pending_approval') {
110
+ return (
111
+ <AuthLayout>
112
+ <AuthLayoutLeft>
113
+ <div className="space-y-4 text-center">
114
+ <div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
115
+ <Loader2 className="size-8 animate-spin text-primary" />
116
+ </div>
117
+ <h1 className="text-2xl font-semibold">{t('awaitingApproval')}</h1>
118
+ <p className="text-sm text-muted-foreground">{t('awaitingApprovalDesc')}</p>
119
+ <div className="space-y-2">
120
+ <Link
121
+ to="/login"
122
+ search={{ redirect: `/invite/${token}` }}
123
+ className="block w-full rounded-lg border border-primary/20 bg-primary/5 px-4 py-2 text-sm text-primary font-medium hover:bg-primary/10 transition-colors"
124
+ >
125
+ {t('loginNow')}
126
+ </Link>
127
+ </div>
128
+ </div>
129
+ </AuthLayoutLeft>
130
+ <AuthLayoutRight>
131
+ <AuthBrandPanel footer={<p className="text-sm text-muted-foreground">{t('brandFooter')}</p>} title={t('brandTitle')} />
132
+ </AuthLayoutRight>
133
+ </AuthLayout>
134
+ )
135
+ }
136
+
137
+ const owner = inv.owner
138
+ const features = [
139
+ { icon: Mail, label: t('featureCollaboration'), desc: t('featureCollaborationDesc') },
140
+ { icon: UserCircle, label: t('featureTeam'), desc: t('featureTeamDesc') },
141
+ ]
142
+
143
+ const awaitingBlock = (
144
+ <div className="space-y-3">
145
+ <div className="flex items-center gap-2 rounded-lg border border-primary/20 bg-primary/5 p-3">
146
+ <Loader2 className="size-4 animate-spin text-primary shrink-0" />
147
+ <p className="text-sm text-primary font-medium">{t('awaitingApproval')}</p>
148
+ </div>
149
+ <p className="text-center text-xs text-muted-foreground">{t('awaitingApprovalDesc')}</p>
150
+ <Link
151
+ to="/dashboard"
152
+ className="flex w-full items-center justify-center gap-2 rounded-lg border py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
153
+ >
154
+ {t('goToDashboard')}
155
+ <ArrowRight className="size-4" />
156
+ </Link>
73
157
  </div>
74
158
  )
159
+
160
+ return (
161
+ <AuthLayout>
162
+ <AuthLayoutLeft>
163
+ <div className="space-y-3 text-center">
164
+ <UserAvatar
165
+ avatarConfig={owner.avatarConfig as any}
166
+ bgColor={owner.bgColor}
167
+ bgShape={owner.bgShape as 'circle' | 'rounded' | 'square' | null}
168
+ className="mx-auto size-16"
169
+ name={owner.username}
170
+ size="2xl"
171
+ />
172
+ <div className="space-y-1">
173
+ <p className="text-2xl font-bold tracking-tight">{owner.username}</p>
174
+ <p className="text-sm font-medium text-primary">
175
+ {inv.channel === 'link'
176
+ ? t('inviteYou', { owner: owner.email })
177
+ : t('seatMessage', { seat: inv.selectedRole === 'member' ? t('seatMember') : t('seatViewer') })}
178
+ </p>
179
+ </div>
180
+ </div>
181
+
182
+ {isLoggedIn ? (
183
+ <div className="space-y-4">
184
+ <Card className="border border-border">
185
+ <CardContent>
186
+ <div className="flex items-center gap-3">
187
+ <UserAvatar
188
+ avatarConfig={session.user?.avatarConfig as any}
189
+ bgColor={session.user?.bgColor}
190
+ bgShape={session.user?.bgShape as 'circle' | 'rounded' | 'square' | null}
191
+ className="shrink-0"
192
+ name={session.user?.username ?? session.user?.name}
193
+ size="md"
194
+ />
195
+ <div className="min-w-0 flex-1">
196
+ <p className="truncate text-sm font-medium">
197
+ {session.user?.name ?? session.user?.email}
198
+ </p>
199
+ <p className="truncate text-xs text-muted-foreground">{session.user?.email}</p>
200
+ </div>
201
+ <CheckCircle2 className="size-4 text-green-500 shrink-0" />
202
+ </div>
203
+ </CardContent>
204
+ </Card>
205
+
206
+ {applied || inv.myRequestStatus === 'pending' || (inv.channel === 'email' && inv.effectiveStatus === 'pending_approval') ? (
207
+ awaitingBlock
208
+ ) : inv.alreadyJoined ? (
209
+ <div className="space-y-3">
210
+ <div className="flex items-center gap-2 rounded-lg border border-green-500/30 bg-green-500/10 p-3">
211
+ <CheckCircle2 className="size-4 text-green-500 shrink-0" />
212
+ <p className="text-sm font-medium text-green-600">{t('alreadyJoined')}</p>
213
+ </div>
214
+ <p className="text-center text-xs text-muted-foreground">{t('alreadyJoinedDesc')}</p>
215
+ <Link
216
+ to="/dashboard"
217
+ className="flex w-full items-center justify-center gap-2 rounded-lg border py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
218
+ >
219
+ {t('goToDashboard')}
220
+ </Link>
221
+ </div>
222
+ ) : (
223
+ <>
224
+ <RainbowButton
225
+ className="w-full"
226
+ disabled={acceptMutation.isPending}
227
+ onClick={() => acceptMutation.mutate()}
228
+ >
229
+ {acceptMutation.isPending ? (
230
+ <>
231
+ <Loader2 className="mr-2 size-4 animate-spin" />
232
+ {t('joining')}
233
+ </>
234
+ ) : (
235
+ <>
236
+ {inv.channel === 'email' ? t('acceptJoin') : t('requestToJoin')}
237
+ <ArrowRight className="ml-2 size-4" />
238
+ </>
239
+ )}
240
+ </RainbowButton>
241
+ {inv.channel === 'link' && (
242
+ <p className="text-center text-xs text-muted-foreground">{t('linkApprovalNote')}</p>
243
+ )}
244
+ </>
245
+ )}
246
+ </div>
247
+ ) : (
248
+ <div className="space-y-4">
249
+ <IconInput
250
+ icon={Mail}
251
+ label={t('emailLabel')}
252
+ onChange={(e) => setEmailInput(e.target.value)}
253
+ placeholder="name@example.com"
254
+ type="email"
255
+ value={emailPrefilled ? inv!.email ?? '' : emailInput}
256
+ disabled={emailPrefilled}
257
+ />
258
+
259
+ {applied ? (
260
+ <>
261
+ <div className="flex items-center gap-2 rounded-lg border border-primary/20 bg-primary/5 p-3">
262
+ <CheckCircle2 className="size-4 text-primary shrink-0" />
263
+ <div className="min-w-0">
264
+ <p className="text-sm font-medium text-primary">{t('appliedHint')}</p>
265
+ <p className="text-xs text-muted-foreground">{t('appliedHintLogin')}</p>
266
+ </div>
267
+ </div>
268
+ <Link to="/login" search={{ redirect: `/invite/${token}` }} className="block">
269
+ <RainbowButton className="w-full">
270
+ {t('loginNow')}
271
+ <ArrowRight className="ml-2 size-4" />
272
+ </RainbowButton>
273
+ </Link>
274
+ </>
275
+ ) : emailCheck === 'unregistered' ? (
276
+ <>
277
+ <RainbowButton
278
+ className="w-full"
279
+ onClick={() => {
280
+ const email = (emailPrefilled ? inv!.email ?? '' : emailInput).trim()
281
+ navigate({ to: '/register', search: { invite: token, email } })
282
+ }}
283
+ >
284
+ {t('registerNow')}
285
+ <ArrowRight className="ml-2 size-4" />
286
+ </RainbowButton>
287
+ <p className="text-center text-xs text-muted-foreground">{t('emailUnregisteredHint')}</p>
288
+ </>
289
+ ) : (
290
+ <>
291
+ <RainbowButton
292
+ className="w-full"
293
+ disabled={emailCheck !== 'registered' || applyMutation.isPending}
294
+ onClick={() => {
295
+ const email = (emailPrefilled ? inv!.email ?? '' : emailInput).trim()
296
+ if (!EMAIL_RE.test(email)) {
297
+ toast.error(t('invalidEmail'))
298
+ return
299
+ }
300
+ applyMutation.mutate(email.toLowerCase())
301
+ }}
302
+ >
303
+ {applyMutation.isPending || emailCheck === 'checking' ? (
304
+ <>
305
+ <Loader2 className="mr-2 size-4 animate-spin" />
306
+ {t('checking')}
307
+ </>
308
+ ) : (
309
+ <>
310
+ {t('applyToJoin')}
311
+ <ArrowRight className="ml-2 size-4" />
312
+ </>
313
+ )}
314
+ </RainbowButton>
315
+ <p className="text-center text-xs text-muted-foreground">
316
+ {emailCheck === 'registered' ? t('emailRegisteredHint') : t('notRegisteredNote')}
317
+ </p>
318
+ </>
319
+ )}
320
+
321
+ <div className="text-center text-sm">
322
+ {t('alreadyHaveAccount')}{' '}
323
+ <Link to="/login" className="font-medium text-primary hover:underline">
324
+ {t('login')}
325
+ </Link>
326
+ </div>
327
+ </div>
328
+ )}
329
+ </AuthLayoutLeft>
330
+ <AuthLayoutRight>
331
+ <AuthBrandPanel
332
+ features={features}
333
+ footer={<p className="text-sm text-muted-foreground">{t('brandFooter')}</p>}
334
+ title={t('brandTitle')}
335
+ />
336
+ </AuthLayoutRight>
337
+ </AuthLayout>
338
+ )
75
339
  }