create-ej-app 25.0.0 → 26.0.0

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 (160) hide show
  1. package/cli.js +4 -0
  2. package/cli.ts +5 -0
  3. package/package.json +2 -2
  4. package/template/api/package.json +2 -2
  5. package/template/nextjs/gitignore +1 -0
  6. package/template/nextjs/package.json +18 -18
  7. package/template/saas-starter/.dockerignore +7 -0
  8. package/template/saas-starter/.env.example +25 -0
  9. package/template/saas-starter/.vscode/extensions.json +11 -0
  10. package/template/saas-starter/.vscode/settings.json +3 -0
  11. package/template/saas-starter/Dockerfile +15 -0
  12. package/template/saas-starter/README.md +3 -0
  13. package/template/saas-starter/components.json +25 -0
  14. package/template/saas-starter/drizzle.config.ts +10 -0
  15. package/template/saas-starter/eslint.config.mjs +23 -0
  16. package/template/saas-starter/gitignore +9 -0
  17. package/template/saas-starter/next-env.d.ts +6 -0
  18. package/template/saas-starter/next.config.ts +14 -0
  19. package/template/saas-starter/package.json +92 -0
  20. package/template/saas-starter/postcss.config.mjs +7 -0
  21. package/template/saas-starter/prettier.config.mjs +11 -0
  22. package/template/saas-starter/public/placeholder.svg +1 -0
  23. package/template/saas-starter/src/actions/uploads.ts +28 -0
  24. package/template/saas-starter/src/actions/uploads.validation.ts +14 -0
  25. package/template/saas-starter/src/app/(admin)/actions/users.ts +97 -0
  26. package/template/saas-starter/src/app/(admin)/admin/_components/admin-header.tsx +21 -0
  27. package/template/saas-starter/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
  28. package/template/saas-starter/src/app/(admin)/admin/_components/admin-sidebar.tsx +40 -0
  29. package/template/saas-starter/src/app/(admin)/admin/layout.tsx +57 -0
  30. package/template/saas-starter/src/app/(admin)/admin/page.tsx +3 -0
  31. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/create-user-dialog.tsx +183 -0
  32. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/edit-name-dialog.tsx +129 -0
  33. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/users-table.tsx +415 -0
  34. package/template/saas-starter/src/app/(admin)/admin/settings/users/page.tsx +15 -0
  35. package/template/saas-starter/src/app/(app)/actions/billing.ts +152 -0
  36. package/template/saas-starter/src/app/(app)/actions/members.ts +163 -0
  37. package/template/saas-starter/src/app/(app)/actions/organization.ts +92 -0
  38. package/template/saas-starter/src/app/(app)/app/[orgId]/dashboard/page.tsx +20 -0
  39. package/template/saas-starter/src/app/(app)/app/[orgId]/layout.tsx +34 -0
  40. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx +100 -0
  41. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx +364 -0
  42. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/general-settings-form.tsx +155 -0
  43. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/plans-section.tsx +232 -0
  44. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/billing/page.tsx +116 -0
  45. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/invite-member-dialog.tsx +169 -0
  46. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/members-tabs.tsx +571 -0
  47. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/page.tsx +40 -0
  48. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/page.tsx +69 -0
  49. package/template/saas-starter/src/app/(app)/app/_components/app-header.tsx +29 -0
  50. package/template/saas-starter/src/app/(app)/app/_components/app-search.tsx +17 -0
  51. package/template/saas-starter/src/app/(app)/app/_components/app-sidebar.tsx +27 -0
  52. package/template/saas-starter/src/app/(app)/app/_components/organization-context.tsx +41 -0
  53. package/template/saas-starter/src/app/(app)/app/_components/organization-switcher.tsx +140 -0
  54. package/template/saas-starter/src/app/(app)/app/layout.tsx +52 -0
  55. package/template/saas-starter/src/app/(app)/app/onboarding/_components/create-organization-form.tsx +129 -0
  56. package/template/saas-starter/src/app/(app)/app/onboarding/page.tsx +42 -0
  57. package/template/saas-starter/src/app/(app)/app/page.tsx +81 -0
  58. package/template/saas-starter/src/app/(auth)/login/email-login-form.tsx +109 -0
  59. package/template/saas-starter/src/app/(auth)/login/page.tsx +87 -0
  60. package/template/saas-starter/src/app/(auth)/login/social-login-buttons.tsx +75 -0
  61. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx +88 -0
  62. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/page.tsx +167 -0
  63. package/template/saas-starter/src/app/(marketing)/layout.tsx +14 -0
  64. package/template/saas-starter/src/app/(marketing)/page.tsx +3 -0
  65. package/template/saas-starter/src/app/(marketing)/privacy/page.tsx +3 -0
  66. package/template/saas-starter/src/app/(marketing)/terms/page.tsx +3 -0
  67. package/template/saas-starter/src/app/api/auth/[...all]/route.ts +4 -0
  68. package/template/saas-starter/src/app/api/webhooks/stripe/route.ts +94 -0
  69. package/template/saas-starter/src/app/favicon.ico +0 -0
  70. package/template/saas-starter/src/app/globals.css +240 -0
  71. package/template/saas-starter/src/app/layout.tsx +53 -0
  72. package/template/saas-starter/src/app/not-found.tsx +27 -0
  73. package/template/saas-starter/src/app/proxy.ts +28 -0
  74. package/template/saas-starter/src/app/robots.ts +32 -0
  75. package/template/saas-starter/src/app/sitemap.ts +20 -0
  76. package/template/saas-starter/src/components/dashboard-header.tsx +64 -0
  77. package/template/saas-starter/src/components/data-table/enum.ts +5 -0
  78. package/template/saas-starter/src/components/data-table/index.tsx +960 -0
  79. package/template/saas-starter/src/components/data-table/types.ts +75 -0
  80. package/template/saas-starter/src/components/error.tsx +26 -0
  81. package/template/saas-starter/src/components/file-upload.tsx +215 -0
  82. package/template/saas-starter/src/components/impersonation-banner.tsx +44 -0
  83. package/template/saas-starter/src/components/loader.tsx +25 -0
  84. package/template/saas-starter/src/components/navigation-search.tsx +140 -0
  85. package/template/saas-starter/src/components/navigation-sidebar.tsx +241 -0
  86. package/template/saas-starter/src/components/page-heading.tsx +32 -0
  87. package/template/saas-starter/src/components/react-query-provider.tsx +12 -0
  88. package/template/saas-starter/src/components/screen-size.tsx +40 -0
  89. package/template/saas-starter/src/components/site-footer.tsx +23 -0
  90. package/template/saas-starter/src/components/theme-provider.tsx +67 -0
  91. package/template/saas-starter/src/components/theme-toggle.tsx +22 -0
  92. package/template/saas-starter/src/components/ui/avatar.tsx +112 -0
  93. package/template/saas-starter/src/components/ui/badge.tsx +49 -0
  94. package/template/saas-starter/src/components/ui/button.tsx +67 -0
  95. package/template/saas-starter/src/components/ui/card.tsx +103 -0
  96. package/template/saas-starter/src/components/ui/checkbox.tsx +32 -0
  97. package/template/saas-starter/src/components/ui/collapsible.tsx +33 -0
  98. package/template/saas-starter/src/components/ui/command.tsx +192 -0
  99. package/template/saas-starter/src/components/ui/dialog.tsx +164 -0
  100. package/template/saas-starter/src/components/ui/dropdown-menu.tsx +273 -0
  101. package/template/saas-starter/src/components/ui/field.tsx +238 -0
  102. package/template/saas-starter/src/components/ui/input-group.tsx +156 -0
  103. package/template/saas-starter/src/components/ui/input.tsx +19 -0
  104. package/template/saas-starter/src/components/ui/label.tsx +24 -0
  105. package/template/saas-starter/src/components/ui/popover.tsx +89 -0
  106. package/template/saas-starter/src/components/ui/select.tsx +195 -0
  107. package/template/saas-starter/src/components/ui/separator.tsx +28 -0
  108. package/template/saas-starter/src/components/ui/sheet.tsx +143 -0
  109. package/template/saas-starter/src/components/ui/sidebar.tsx +702 -0
  110. package/template/saas-starter/src/components/ui/skeleton.tsx +13 -0
  111. package/template/saas-starter/src/components/ui/sonner.tsx +45 -0
  112. package/template/saas-starter/src/components/ui/switch.tsx +33 -0
  113. package/template/saas-starter/src/components/ui/table.tsx +116 -0
  114. package/template/saas-starter/src/components/ui/tabs.tsx +90 -0
  115. package/template/saas-starter/src/components/ui/textarea.tsx +18 -0
  116. package/template/saas-starter/src/components/ui/tooltip.tsx +57 -0
  117. package/template/saas-starter/src/components/user-menu.tsx +90 -0
  118. package/template/saas-starter/src/db/common.ts +31 -0
  119. package/template/saas-starter/src/db/enums.ts +26 -0
  120. package/template/saas-starter/src/db/index.ts +51 -0
  121. package/template/saas-starter/src/db/relations.ts +75 -0
  122. package/template/saas-starter/src/db/schema.ts +191 -0
  123. package/template/saas-starter/src/emails/components/email-layout.tsx +63 -0
  124. package/template/saas-starter/src/emails/invitation.tsx +56 -0
  125. package/template/saas-starter/src/emails/magic-link.tsx +49 -0
  126. package/template/saas-starter/src/emails/test.tsx +32 -0
  127. package/template/saas-starter/src/emails/welcome.tsx +34 -0
  128. package/template/saas-starter/src/env.ts +66 -0
  129. package/template/saas-starter/src/hooks/use-mobile.ts +19 -0
  130. package/template/saas-starter/src/instrumentation.ts +26 -0
  131. package/template/saas-starter/src/lib/actions.ts +21 -0
  132. package/template/saas-starter/src/lib/admin-navigation.ts +31 -0
  133. package/template/saas-starter/src/lib/app-navigation.ts +59 -0
  134. package/template/saas-starter/src/lib/auth-client.ts +29 -0
  135. package/template/saas-starter/src/lib/auth-permissions.ts +22 -0
  136. package/template/saas-starter/src/lib/auth.ts +211 -0
  137. package/template/saas-starter/src/lib/billing.ts +117 -0
  138. package/template/saas-starter/src/lib/cn.ts +4 -0
  139. package/template/saas-starter/src/lib/constants.ts +22 -0
  140. package/template/saas-starter/src/lib/delay.ts +2 -0
  141. package/template/saas-starter/src/lib/email-service.ts +89 -0
  142. package/template/saas-starter/src/lib/format-currency.ts +27 -0
  143. package/template/saas-starter/src/lib/format-date.ts +12 -0
  144. package/template/saas-starter/src/lib/nodemailer.ts +43 -0
  145. package/template/saas-starter/src/lib/organization-access.ts +93 -0
  146. package/template/saas-starter/src/lib/organization-plan-limits.ts +38 -0
  147. package/template/saas-starter/src/lib/otel.ts +27 -0
  148. package/template/saas-starter/src/lib/plans.ts +63 -0
  149. package/template/saas-starter/src/lib/safe-action-client.ts +75 -0
  150. package/template/saas-starter/src/lib/safe-action.ts +67 -0
  151. package/template/saas-starter/src/lib/siteConfig.ts +4 -0
  152. package/template/saas-starter/src/lib/storage.ts +181 -0
  153. package/template/saas-starter/src/lib/stripe.ts +6 -0
  154. package/template/saas-starter/src/lib/toast-message.ts +15 -0
  155. package/template/saas-starter/src/lib/validations.ts +12 -0
  156. package/template/saas-starter/src/scripts/reset-db.ts +22 -0
  157. package/template/saas-starter/src/scripts/seed.ts +11 -0
  158. package/template/saas-starter/src/scripts/setup-stripe-plans.ts +200 -0
  159. package/template/saas-starter/src/scripts/test-mail.ts +35 -0
  160. package/template/saas-starter/tsconfig.json +51 -0
@@ -0,0 +1,232 @@
1
+ 'use client'
2
+
3
+ import { Check, Loader2 } from 'lucide-react'
4
+ import { useState } from 'react'
5
+ import {
6
+ createCheckoutSessionAction,
7
+ createPortalSessionAction
8
+ } from '~/app/(app)/actions/billing'
9
+ import { Badge } from '~/components/ui/badge'
10
+ import { Button } from '~/components/ui/button'
11
+ import {
12
+ Card,
13
+ CardContent,
14
+ CardDescription,
15
+ CardFooter,
16
+ CardHeader,
17
+ CardTitle
18
+ } from '~/components/ui/card'
19
+ import { Label } from '~/components/ui/label'
20
+ import { Switch } from '~/components/ui/switch'
21
+ import { formatCurrency } from '~/lib/format-currency'
22
+ import { Plan } from '~/lib/plans'
23
+ import { useSafeActionMutation } from '~/lib/safe-action-client'
24
+ import { toastErrorMessage } from '~/lib/toast-message'
25
+
26
+ type Interval = 'monthly' | 'annual'
27
+
28
+ const ACTIVE_STATUSES = ['trialing', 'active', 'past_due']
29
+
30
+ const annualSavingsPercent = (monthly: number, annual: number) => {
31
+ const fullYear = monthly * 12
32
+ if (fullYear <= 0) return 0
33
+ return Math.round(((fullYear - annual) / fullYear) * 100)
34
+ }
35
+
36
+ export function PlansSection({
37
+ orgId,
38
+ plans,
39
+ currentPlanId,
40
+ currentStatus,
41
+ hasBillingEmail,
42
+ canStartTrial
43
+ }: {
44
+ orgId: string
45
+ plans: Plan[]
46
+ currentPlanId: 'free' | 'pro' | 'ultimate'
47
+ currentStatus: string
48
+ hasBillingEmail: boolean
49
+ canStartTrial: boolean
50
+ }) {
51
+ const [interval, setInterval] = useState<Interval>('monthly')
52
+ const [pending, setPending] = useState<string | null>(null)
53
+
54
+ const hasActiveSub = ACTIVE_STATUSES.includes(currentStatus)
55
+
56
+ const { mutateAsync: createCheckout } = useSafeActionMutation(
57
+ createCheckoutSessionAction
58
+ )
59
+ const { mutateAsync: createPortal } = useSafeActionMutation(
60
+ createPortalSessionAction
61
+ )
62
+
63
+ async function goToPortal(key: string) {
64
+ setPending(key)
65
+ try {
66
+ const result = await createPortal({ organizationId: orgId })
67
+ if (result?.url) window.location.assign(result.url)
68
+ } catch (error) {
69
+ toastErrorMessage(
70
+ error instanceof Error ? error.message : 'Failed to open billing portal'
71
+ )
72
+ setPending(null)
73
+ }
74
+ }
75
+
76
+ async function goToCheckout(plan: 'pro' | 'ultimate', withTrial: boolean) {
77
+ setPending(plan)
78
+ try {
79
+ const result = await createCheckout({
80
+ organizationId: orgId,
81
+ plan,
82
+ interval,
83
+ withTrial
84
+ })
85
+ if (result?.url) window.location.assign(result.url)
86
+ } catch (error) {
87
+ toastErrorMessage(
88
+ error instanceof Error ? error.message : 'Failed to start checkout'
89
+ )
90
+ setPending(null)
91
+ }
92
+ }
93
+
94
+ return (
95
+ <div className="space-y-6">
96
+ <div className="flex items-center justify-between gap-2">
97
+ {hasActiveSub ? (
98
+ <Button
99
+ variant="outline"
100
+ onClick={() => goToPortal('manage')}
101
+ disabled={pending !== null}
102
+ >
103
+ {pending === 'manage' && (
104
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
105
+ )}
106
+ Manage billing
107
+ </Button>
108
+ ) : (
109
+ <span />
110
+ )}
111
+
112
+ <div className="flex items-center gap-2">
113
+ <Label htmlFor="interval-toggle" className="text-[13px]">
114
+ Monthly
115
+ </Label>
116
+ <Switch
117
+ id="interval-toggle"
118
+ checked={interval === 'annual'}
119
+ onCheckedChange={checked =>
120
+ setInterval(checked ? 'annual' : 'monthly')
121
+ }
122
+ />
123
+ <Label htmlFor="interval-toggle" className="text-[13px]">
124
+ Annual
125
+ </Label>
126
+ </div>
127
+ </div>
128
+
129
+ <div className="grid gap-4 md:grid-cols-3">
130
+ {plans.map(plan => {
131
+ const isCurrent = plan.id === currentPlanId
132
+ const price = plan.stripePrice?.[interval]
133
+ const savings =
134
+ plan.stripePrice && interval === 'annual'
135
+ ? annualSavingsPercent(
136
+ plan.stripePrice.monthly.amount,
137
+ plan.stripePrice.annual.amount
138
+ )
139
+ : 0
140
+
141
+ const isPaid = plan.id !== 'free'
142
+ const offerTrial = Boolean(plan.canUseTrial) && canStartTrial
143
+ const isPending = pending === plan.id
144
+
145
+ const onClick = isCurrent
146
+ ? undefined
147
+ : hasActiveSub
148
+ ? () => goToPortal(plan.id)
149
+ : isPaid
150
+ ? () => goToCheckout(plan.id as 'pro' | 'ultimate', offerTrial)
151
+ : undefined
152
+
153
+ const label = isCurrent
154
+ ? 'Current plan'
155
+ : hasActiveSub
156
+ ? plan.id === 'free'
157
+ ? 'Cancel plan'
158
+ : `Switch to ${plan.name}`
159
+ : isPaid
160
+ ? offerTrial
161
+ ? 'Start 7-day free trial'
162
+ : `Upgrade to ${plan.name}`
163
+ : 'Current plan'
164
+
165
+ const needsBillingEmail = !hasActiveSub && isPaid && !hasBillingEmail
166
+
167
+ return (
168
+ <Card key={plan.id} className={isCurrent ? 'border-primary' : ''}>
169
+ <CardHeader>
170
+ <div className="flex items-center justify-between gap-2">
171
+ <CardTitle>{plan.name}</CardTitle>
172
+ {isCurrent && <Badge>Current</Badge>}
173
+ </div>
174
+ <CardDescription>
175
+ Up to {plan.maxMembers}{' '}
176
+ {plan.maxMembers === 1 ? 'member' : 'members'}
177
+ </CardDescription>
178
+ </CardHeader>
179
+
180
+ <CardContent className="space-y-2">
181
+ <div className="flex items-baseline gap-1">
182
+ <span className="text-2xl font-bold tracking-tight">
183
+ {price
184
+ ? formatCurrency(price.currency, price.amount / 100)
185
+ : '$0'}
186
+ </span>
187
+ <span className="text-muted-foreground text-sm">
188
+ /{interval === 'annual' ? 'year' : 'month'}
189
+ </span>
190
+ </div>
191
+ {savings > 0 && (
192
+ <p className="text-muted-foreground text-[13px]">
193
+ Save {savings}% vs monthly
194
+ </p>
195
+ )}
196
+ {plan.canUseTrial && (
197
+ <p className="text-muted-foreground flex items-center gap-1.5 text-[13px]">
198
+ <Check className="size-3.5" /> 7-day free trial, card
199
+ required
200
+ </p>
201
+ )}
202
+ </CardContent>
203
+
204
+ <CardFooter className="flex-col items-stretch gap-1.5">
205
+ <Button
206
+ className="w-full"
207
+ variant={isCurrent ? 'outline' : 'default'}
208
+ disabled={
209
+ isCurrent ||
210
+ needsBillingEmail ||
211
+ (pending !== null && !isPending)
212
+ }
213
+ onClick={onClick}
214
+ >
215
+ {isPending && (
216
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
217
+ )}
218
+ {label}
219
+ </Button>
220
+ {needsBillingEmail && (
221
+ <p className="text-muted-foreground text-center text-[12px]">
222
+ Set a billing email first
223
+ </p>
224
+ )}
225
+ </CardFooter>
226
+ </Card>
227
+ )
228
+ })}
229
+ </div>
230
+ </div>
231
+ )
232
+ }
@@ -0,0 +1,116 @@
1
+ import { CreditCard } from 'lucide-react'
2
+ import { redirect } from 'next/navigation'
3
+ import { PageHeading } from '~/components/page-heading'
4
+ import { Badge } from '~/components/ui/badge'
5
+ import { Separator } from '~/components/ui/separator'
6
+ import { canManageOrganization } from '~/lib/app-navigation'
7
+ import { formatDate } from '~/lib/format-date'
8
+ import { getUserMembershipCached } from '~/lib/organization-access'
9
+ import { getOrganizationPlanCached } from '~/lib/organization-plan-limits'
10
+ import { PLANS } from '~/lib/plans'
11
+ import { BillingEmailForm } from '../_components/billing-email-form'
12
+ import { PlansSection } from '../_components/plans-section'
13
+
14
+ const STATUS_LABELS: Record<
15
+ string,
16
+ {
17
+ label: string
18
+ variant: 'default' | 'secondary' | 'destructive' | 'outline'
19
+ }
20
+ > = {
21
+ free: { label: 'Free', variant: 'secondary' },
22
+ trialing: { label: 'Trialing', variant: 'default' },
23
+ active: { label: 'Active', variant: 'default' },
24
+ past_due: { label: 'Past due', variant: 'destructive' },
25
+ canceled: { label: 'Canceled', variant: 'outline' },
26
+ unpaid: { label: 'Unpaid', variant: 'destructive' },
27
+ paused: { label: 'Paused', variant: 'outline' },
28
+ incomplete: { label: 'Incomplete', variant: 'outline' },
29
+ incomplete_expired: { label: 'Incomplete', variant: 'outline' }
30
+ }
31
+
32
+ export default async function Page({
33
+ params
34
+ }: {
35
+ params: Promise<{ orgId: string }>
36
+ }) {
37
+ const { orgId } = await params
38
+
39
+ const membership = await getUserMembershipCached(orgId)
40
+ if (!membership || !canManageOrganization(membership.role)) {
41
+ return redirect(`/app/${orgId}/dashboard`)
42
+ }
43
+
44
+ const { plan, sub } = await getOrganizationPlanCached(orgId)
45
+
46
+ const status = STATUS_LABELS[sub.status] ?? {
47
+ label: sub.status,
48
+ variant: 'outline' as const
49
+ }
50
+ const canStartTrial = sub.status === 'free' && !sub.hasUsedTrial
51
+ const trialEndsAt = formatDate(sub.trialEndsAt)
52
+ const renewsAt = formatDate(sub.currentPeriodEnd)
53
+
54
+ return (
55
+ <div className="mx-auto w-full max-w-3xl space-y-8">
56
+ <PageHeading
57
+ title="Billing"
58
+ description="Manage your organization's plan and billing details."
59
+ icon={CreditCard}
60
+ />
61
+
62
+ <div className="space-y-3">
63
+ <div className="flex flex-wrap items-center gap-2">
64
+ <span className="text-sm font-semibold">Current plan:</span>
65
+ <span className="text-sm">{plan.name}</span>
66
+ <Badge variant={status.variant}>{status.label}</Badge>
67
+ </div>
68
+ {sub.status === 'trialing' && trialEndsAt && (
69
+ <p className="text-muted-foreground text-[13px]">
70
+ Trial ends on {trialEndsAt}. Your card will be charged then unless
71
+ you cancel.
72
+ </p>
73
+ )}
74
+ {sub.status === 'active' && renewsAt && (
75
+ <p className="text-muted-foreground text-[13px]">
76
+ {sub.cancelAtPeriodEnd
77
+ ? `Access ends on ${renewsAt}.`
78
+ : `Renews on ${renewsAt}.`}
79
+ </p>
80
+ )}
81
+ {sub.status === 'past_due' && (
82
+ <p className="text-destructive text-[13px]">
83
+ Your last payment failed. Update your payment method to keep access.
84
+ </p>
85
+ )}
86
+ </div>
87
+
88
+ <Separator />
89
+
90
+ <div className="space-y-1">
91
+ <h2 className="text-lg font-semibold">Plans</h2>
92
+ <p className="text-muted-foreground text-sm">
93
+ Choose the plan that fits your organization.
94
+ </p>
95
+ </div>
96
+ <PlansSection
97
+ orgId={orgId}
98
+ plans={PLANS}
99
+ currentPlanId={plan.id}
100
+ currentStatus={sub.status}
101
+ hasBillingEmail={Boolean(sub.billingEmail)}
102
+ canStartTrial={canStartTrial}
103
+ />
104
+
105
+ <Separator />
106
+
107
+ <div className="space-y-1">
108
+ <h2 className="text-lg font-semibold">Billing email</h2>
109
+ <p className="text-muted-foreground text-sm">
110
+ Where invoices and receipts are sent.
111
+ </p>
112
+ </div>
113
+ <BillingEmailForm orgId={orgId} defaultBillingEmail={sub.billingEmail} />
114
+ </div>
115
+ )
116
+ }
@@ -0,0 +1,169 @@
1
+ 'use client'
2
+
3
+ import { zodResolver } from '@hookform/resolvers/zod'
4
+ import { Loader2 } from 'lucide-react'
5
+ import { useEffect, useState } from 'react'
6
+ import { Controller, useForm } from 'react-hook-form'
7
+ import { z } from 'zod'
8
+ import { Button } from '~/components/ui/button'
9
+ import {
10
+ Dialog,
11
+ DialogContent,
12
+ DialogDescription,
13
+ DialogFooter,
14
+ DialogHeader,
15
+ DialogTitle,
16
+ DialogTrigger
17
+ } from '~/components/ui/dialog'
18
+ import {
19
+ Field,
20
+ FieldContent,
21
+ FieldError,
22
+ FieldTitle
23
+ } from '~/components/ui/field'
24
+ import { Input } from '~/components/ui/input'
25
+ import {
26
+ Select,
27
+ SelectContent,
28
+ SelectItem,
29
+ SelectTrigger,
30
+ SelectValue
31
+ } from '~/components/ui/select'
32
+ import { organization } from '~/lib/auth-client'
33
+ import { toastErrorMessage, toastSuccessMessage } from '~/lib/toast-message'
34
+ import { emailValidation } from '~/lib/validations'
35
+ import { useOrganizationContext } from '../../../../_components/organization-context'
36
+
37
+ const schema = z.object({
38
+ email: emailValidation,
39
+ role: z.enum(['admin', 'member'])
40
+ })
41
+
42
+ type Form = z.infer<typeof schema>
43
+
44
+ export function InviteMemberDialog({
45
+ onInvited,
46
+ children
47
+ }: {
48
+ onInvited?: () => void
49
+ children: React.ReactNode
50
+ }) {
51
+ const { id: orgId } = useOrganizationContext()
52
+ const [open, setOpen] = useState(false)
53
+
54
+ const {
55
+ register,
56
+ handleSubmit,
57
+ control,
58
+ reset,
59
+ formState: { errors, isSubmitting }
60
+ } = useForm<Form>({
61
+ resolver: zodResolver(schema),
62
+ defaultValues: { email: '', role: 'member' }
63
+ })
64
+
65
+ useEffect(() => {
66
+ if (open) reset({ email: '', role: 'member' })
67
+ }, [open, reset])
68
+
69
+ async function onSubmit(values: Form) {
70
+ const { error } = await organization.inviteMember({
71
+ organizationId: orgId,
72
+ email: values.email,
73
+ role: values.role
74
+ })
75
+
76
+ if (error) {
77
+ toastErrorMessage(error.message ?? 'Failed to send invitation')
78
+ return
79
+ }
80
+
81
+ toastSuccessMessage(`Invitation sent to ${values.email}`)
82
+ setOpen(false)
83
+ onInvited?.()
84
+ }
85
+
86
+ return (
87
+ <Dialog open={open} onOpenChange={setOpen}>
88
+ <DialogTrigger asChild>{children}</DialogTrigger>
89
+ <DialogContent className="sm:max-w-md">
90
+ <DialogHeader>
91
+ <DialogTitle>Invite a teammate</DialogTitle>
92
+ <DialogDescription>
93
+ Send an email invitation to join this organization. They&apos;ll
94
+ need to sign in with this email to accept.
95
+ </DialogDescription>
96
+ </DialogHeader>
97
+
98
+ <form onSubmit={handleSubmit(onSubmit)} className="space-y-4 py-1">
99
+ <Field orientation="vertical">
100
+ <FieldContent>
101
+ <FieldTitle className="text-foreground mb-1.5 text-[13px] font-semibold">
102
+ Email address
103
+ </FieldTitle>
104
+ <Input
105
+ type="email"
106
+ placeholder="teammate@example.com"
107
+ autoComplete="off"
108
+ {...register('email')}
109
+ aria-invalid={!!errors.email}
110
+ />
111
+ <FieldError
112
+ errors={[errors.email]}
113
+ className="mt-1.5 text-[13px]"
114
+ />
115
+ </FieldContent>
116
+ </Field>
117
+
118
+ <Field orientation="vertical">
119
+ <FieldContent>
120
+ <FieldTitle className="text-foreground mb-1.5 text-[13px] font-semibold">
121
+ Role
122
+ </FieldTitle>
123
+ <Controller
124
+ control={control}
125
+ name="role"
126
+ render={({ field }) => (
127
+ <Select value={field.value} onValueChange={field.onChange}>
128
+ <SelectTrigger className="w-full">
129
+ <SelectValue placeholder="Select role" />
130
+ </SelectTrigger>
131
+ <SelectContent>
132
+ <SelectItem value="admin">
133
+ Admin - manage members & settings
134
+ </SelectItem>
135
+ <SelectItem value="member">
136
+ Member - standard access
137
+ </SelectItem>
138
+ </SelectContent>
139
+ </Select>
140
+ )}
141
+ />
142
+ <FieldError
143
+ errors={[errors.role]}
144
+ className="mt-1.5 text-[13px]"
145
+ />
146
+ </FieldContent>
147
+ </Field>
148
+
149
+ <DialogFooter>
150
+ <Button
151
+ type="button"
152
+ variant="outline"
153
+ onClick={() => setOpen(false)}
154
+ disabled={isSubmitting}
155
+ >
156
+ Cancel
157
+ </Button>
158
+ <Button type="submit" disabled={isSubmitting}>
159
+ {isSubmitting && (
160
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
161
+ )}
162
+ Send invitation
163
+ </Button>
164
+ </DialogFooter>
165
+ </form>
166
+ </DialogContent>
167
+ </Dialog>
168
+ )
169
+ }