create-ej-app 24.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 (161) hide show
  1. package/cli.js +6 -0
  2. package/cli.ts +8 -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 +20 -20
  7. package/template/nextjs/postcss.config.mjs +3 -1
  8. package/template/saas-starter/.dockerignore +7 -0
  9. package/template/saas-starter/.env.example +25 -0
  10. package/template/saas-starter/.vscode/extensions.json +11 -0
  11. package/template/saas-starter/.vscode/settings.json +3 -0
  12. package/template/saas-starter/Dockerfile +15 -0
  13. package/template/saas-starter/README.md +3 -0
  14. package/template/saas-starter/components.json +25 -0
  15. package/template/saas-starter/drizzle.config.ts +10 -0
  16. package/template/saas-starter/eslint.config.mjs +23 -0
  17. package/template/saas-starter/gitignore +9 -0
  18. package/template/saas-starter/next-env.d.ts +6 -0
  19. package/template/saas-starter/next.config.ts +14 -0
  20. package/template/saas-starter/package.json +92 -0
  21. package/template/saas-starter/postcss.config.mjs +7 -0
  22. package/template/saas-starter/prettier.config.mjs +11 -0
  23. package/template/saas-starter/public/placeholder.svg +1 -0
  24. package/template/saas-starter/src/actions/uploads.ts +28 -0
  25. package/template/saas-starter/src/actions/uploads.validation.ts +14 -0
  26. package/template/saas-starter/src/app/(admin)/actions/users.ts +97 -0
  27. package/template/saas-starter/src/app/(admin)/admin/_components/admin-header.tsx +21 -0
  28. package/template/saas-starter/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
  29. package/template/saas-starter/src/app/(admin)/admin/_components/admin-sidebar.tsx +40 -0
  30. package/template/saas-starter/src/app/(admin)/admin/layout.tsx +57 -0
  31. package/template/saas-starter/src/app/(admin)/admin/page.tsx +3 -0
  32. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/create-user-dialog.tsx +183 -0
  33. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/edit-name-dialog.tsx +129 -0
  34. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/users-table.tsx +415 -0
  35. package/template/saas-starter/src/app/(admin)/admin/settings/users/page.tsx +15 -0
  36. package/template/saas-starter/src/app/(app)/actions/billing.ts +152 -0
  37. package/template/saas-starter/src/app/(app)/actions/members.ts +163 -0
  38. package/template/saas-starter/src/app/(app)/actions/organization.ts +92 -0
  39. package/template/saas-starter/src/app/(app)/app/[orgId]/dashboard/page.tsx +20 -0
  40. package/template/saas-starter/src/app/(app)/app/[orgId]/layout.tsx +34 -0
  41. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx +100 -0
  42. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx +364 -0
  43. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/general-settings-form.tsx +155 -0
  44. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/plans-section.tsx +232 -0
  45. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/billing/page.tsx +116 -0
  46. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/invite-member-dialog.tsx +169 -0
  47. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/members-tabs.tsx +571 -0
  48. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/page.tsx +40 -0
  49. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/page.tsx +69 -0
  50. package/template/saas-starter/src/app/(app)/app/_components/app-header.tsx +29 -0
  51. package/template/saas-starter/src/app/(app)/app/_components/app-search.tsx +17 -0
  52. package/template/saas-starter/src/app/(app)/app/_components/app-sidebar.tsx +27 -0
  53. package/template/saas-starter/src/app/(app)/app/_components/organization-context.tsx +41 -0
  54. package/template/saas-starter/src/app/(app)/app/_components/organization-switcher.tsx +140 -0
  55. package/template/saas-starter/src/app/(app)/app/layout.tsx +52 -0
  56. package/template/saas-starter/src/app/(app)/app/onboarding/_components/create-organization-form.tsx +129 -0
  57. package/template/saas-starter/src/app/(app)/app/onboarding/page.tsx +42 -0
  58. package/template/saas-starter/src/app/(app)/app/page.tsx +81 -0
  59. package/template/saas-starter/src/app/(auth)/login/email-login-form.tsx +109 -0
  60. package/template/saas-starter/src/app/(auth)/login/page.tsx +87 -0
  61. package/template/saas-starter/src/app/(auth)/login/social-login-buttons.tsx +75 -0
  62. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx +88 -0
  63. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/page.tsx +167 -0
  64. package/template/saas-starter/src/app/(marketing)/layout.tsx +14 -0
  65. package/template/saas-starter/src/app/(marketing)/page.tsx +3 -0
  66. package/template/saas-starter/src/app/(marketing)/privacy/page.tsx +3 -0
  67. package/template/saas-starter/src/app/(marketing)/terms/page.tsx +3 -0
  68. package/template/saas-starter/src/app/api/auth/[...all]/route.ts +4 -0
  69. package/template/saas-starter/src/app/api/webhooks/stripe/route.ts +94 -0
  70. package/template/saas-starter/src/app/favicon.ico +0 -0
  71. package/template/saas-starter/src/app/globals.css +240 -0
  72. package/template/saas-starter/src/app/layout.tsx +53 -0
  73. package/template/saas-starter/src/app/not-found.tsx +27 -0
  74. package/template/saas-starter/src/app/proxy.ts +28 -0
  75. package/template/saas-starter/src/app/robots.ts +32 -0
  76. package/template/saas-starter/src/app/sitemap.ts +20 -0
  77. package/template/saas-starter/src/components/dashboard-header.tsx +64 -0
  78. package/template/saas-starter/src/components/data-table/enum.ts +5 -0
  79. package/template/saas-starter/src/components/data-table/index.tsx +960 -0
  80. package/template/saas-starter/src/components/data-table/types.ts +75 -0
  81. package/template/saas-starter/src/components/error.tsx +26 -0
  82. package/template/saas-starter/src/components/file-upload.tsx +215 -0
  83. package/template/saas-starter/src/components/impersonation-banner.tsx +44 -0
  84. package/template/saas-starter/src/components/loader.tsx +25 -0
  85. package/template/saas-starter/src/components/navigation-search.tsx +140 -0
  86. package/template/saas-starter/src/components/navigation-sidebar.tsx +241 -0
  87. package/template/saas-starter/src/components/page-heading.tsx +32 -0
  88. package/template/saas-starter/src/components/react-query-provider.tsx +12 -0
  89. package/template/saas-starter/src/components/screen-size.tsx +40 -0
  90. package/template/saas-starter/src/components/site-footer.tsx +23 -0
  91. package/template/saas-starter/src/components/theme-provider.tsx +67 -0
  92. package/template/saas-starter/src/components/theme-toggle.tsx +22 -0
  93. package/template/saas-starter/src/components/ui/avatar.tsx +112 -0
  94. package/template/saas-starter/src/components/ui/badge.tsx +49 -0
  95. package/template/saas-starter/src/components/ui/button.tsx +67 -0
  96. package/template/saas-starter/src/components/ui/card.tsx +103 -0
  97. package/template/saas-starter/src/components/ui/checkbox.tsx +32 -0
  98. package/template/saas-starter/src/components/ui/collapsible.tsx +33 -0
  99. package/template/saas-starter/src/components/ui/command.tsx +192 -0
  100. package/template/saas-starter/src/components/ui/dialog.tsx +164 -0
  101. package/template/saas-starter/src/components/ui/dropdown-menu.tsx +273 -0
  102. package/template/saas-starter/src/components/ui/field.tsx +238 -0
  103. package/template/saas-starter/src/components/ui/input-group.tsx +156 -0
  104. package/template/saas-starter/src/components/ui/input.tsx +19 -0
  105. package/template/saas-starter/src/components/ui/label.tsx +24 -0
  106. package/template/saas-starter/src/components/ui/popover.tsx +89 -0
  107. package/template/saas-starter/src/components/ui/select.tsx +195 -0
  108. package/template/saas-starter/src/components/ui/separator.tsx +28 -0
  109. package/template/saas-starter/src/components/ui/sheet.tsx +143 -0
  110. package/template/saas-starter/src/components/ui/sidebar.tsx +702 -0
  111. package/template/saas-starter/src/components/ui/skeleton.tsx +13 -0
  112. package/template/saas-starter/src/components/ui/sonner.tsx +45 -0
  113. package/template/saas-starter/src/components/ui/switch.tsx +33 -0
  114. package/template/saas-starter/src/components/ui/table.tsx +116 -0
  115. package/template/saas-starter/src/components/ui/tabs.tsx +90 -0
  116. package/template/saas-starter/src/components/ui/textarea.tsx +18 -0
  117. package/template/saas-starter/src/components/ui/tooltip.tsx +57 -0
  118. package/template/saas-starter/src/components/user-menu.tsx +90 -0
  119. package/template/saas-starter/src/db/common.ts +31 -0
  120. package/template/saas-starter/src/db/enums.ts +26 -0
  121. package/template/saas-starter/src/db/index.ts +51 -0
  122. package/template/saas-starter/src/db/relations.ts +75 -0
  123. package/template/saas-starter/src/db/schema.ts +191 -0
  124. package/template/saas-starter/src/emails/components/email-layout.tsx +63 -0
  125. package/template/saas-starter/src/emails/invitation.tsx +56 -0
  126. package/template/saas-starter/src/emails/magic-link.tsx +49 -0
  127. package/template/saas-starter/src/emails/test.tsx +32 -0
  128. package/template/saas-starter/src/emails/welcome.tsx +34 -0
  129. package/template/saas-starter/src/env.ts +66 -0
  130. package/template/saas-starter/src/hooks/use-mobile.ts +19 -0
  131. package/template/saas-starter/src/instrumentation.ts +26 -0
  132. package/template/saas-starter/src/lib/actions.ts +21 -0
  133. package/template/saas-starter/src/lib/admin-navigation.ts +31 -0
  134. package/template/saas-starter/src/lib/app-navigation.ts +59 -0
  135. package/template/saas-starter/src/lib/auth-client.ts +29 -0
  136. package/template/saas-starter/src/lib/auth-permissions.ts +22 -0
  137. package/template/saas-starter/src/lib/auth.ts +211 -0
  138. package/template/saas-starter/src/lib/billing.ts +117 -0
  139. package/template/saas-starter/src/lib/cn.ts +4 -0
  140. package/template/saas-starter/src/lib/constants.ts +22 -0
  141. package/template/saas-starter/src/lib/delay.ts +2 -0
  142. package/template/saas-starter/src/lib/email-service.ts +89 -0
  143. package/template/saas-starter/src/lib/format-currency.ts +27 -0
  144. package/template/saas-starter/src/lib/format-date.ts +12 -0
  145. package/template/saas-starter/src/lib/nodemailer.ts +43 -0
  146. package/template/saas-starter/src/lib/organization-access.ts +93 -0
  147. package/template/saas-starter/src/lib/organization-plan-limits.ts +38 -0
  148. package/template/saas-starter/src/lib/otel.ts +27 -0
  149. package/template/saas-starter/src/lib/plans.ts +63 -0
  150. package/template/saas-starter/src/lib/safe-action-client.ts +75 -0
  151. package/template/saas-starter/src/lib/safe-action.ts +67 -0
  152. package/template/saas-starter/src/lib/siteConfig.ts +4 -0
  153. package/template/saas-starter/src/lib/storage.ts +181 -0
  154. package/template/saas-starter/src/lib/stripe.ts +6 -0
  155. package/template/saas-starter/src/lib/toast-message.ts +15 -0
  156. package/template/saas-starter/src/lib/validations.ts +12 -0
  157. package/template/saas-starter/src/scripts/reset-db.ts +22 -0
  158. package/template/saas-starter/src/scripts/seed.ts +11 -0
  159. package/template/saas-starter/src/scripts/setup-stripe-plans.ts +200 -0
  160. package/template/saas-starter/src/scripts/test-mail.ts +35 -0
  161. package/template/saas-starter/tsconfig.json +51 -0
@@ -0,0 +1,163 @@
1
+ 'use server'
2
+
3
+ import { and, asc, count, desc, eq, ilike, inArray, or } from 'drizzle-orm'
4
+ import { z } from 'zod'
5
+ import { SortOrderEnum } from '~/components/data-table/enum'
6
+ import { db } from '~/db'
7
+ import { invitationsTable, membersTable, usersTable } from '~/db/schema'
8
+ import { getPaginationSchema, getSortSchema } from '~/lib/actions'
9
+ import { assertUserCanManageOrganization } from '~/lib/organization-access'
10
+ import { authActionClient } from '~/lib/safe-action'
11
+ import { resolveImageUrl } from '~/lib/storage'
12
+ import { stringValidation } from '~/lib/validations'
13
+
14
+ export const getMembers = authActionClient
15
+ .inputSchema(
16
+ z.object({
17
+ organizationId: stringValidation,
18
+ ...getPaginationSchema(),
19
+ ...getSortSchema(['name', 'email', 'role', 'createdAt']),
20
+ search: z.string().trim().optional(),
21
+ filters: z
22
+ .object({
23
+ role: z.array(z.string()).optional()
24
+ })
25
+ .optional()
26
+ })
27
+ )
28
+ .action(
29
+ async ({
30
+ parsedInput: {
31
+ organizationId,
32
+ page,
33
+ limit,
34
+ sortBy,
35
+ sortOrder,
36
+ search,
37
+ filters
38
+ }
39
+ }) => {
40
+ await assertUserCanManageOrganization(organizationId)
41
+
42
+ const where = and(
43
+ eq(membersTable.organizationId, organizationId),
44
+ search
45
+ ? or(
46
+ ilike(usersTable.name, `%${search}%`),
47
+ ilike(usersTable.email, `%${search}%`)
48
+ )
49
+ : undefined,
50
+ filters?.role && filters.role.length > 0
51
+ ? inArray(membersTable.role, filters.role)
52
+ : undefined
53
+ )
54
+
55
+ const sortColumn =
56
+ sortBy === 'name'
57
+ ? usersTable.name
58
+ : sortBy === 'email'
59
+ ? usersTable.email
60
+ : sortBy === 'role'
61
+ ? membersTable.role
62
+ : membersTable.createdAt
63
+
64
+ const direction = sortOrder === SortOrderEnum.ASC ? asc : desc
65
+
66
+ const [rows, total] = await Promise.all([
67
+ db
68
+ .select({
69
+ id: membersTable.id,
70
+ userId: usersTable.id,
71
+ name: usersTable.name,
72
+ email: usersTable.email,
73
+ image: usersTable.image,
74
+ role: membersTable.role,
75
+ createdAt: membersTable.createdAt
76
+ })
77
+ .from(membersTable)
78
+ .innerJoin(usersTable, eq(membersTable.userId, usersTable.id))
79
+ .where(where)
80
+ .orderBy(direction(sortColumn))
81
+ .limit(limit)
82
+ .offset((page - 1) * limit),
83
+ db
84
+ .select({ count: count() })
85
+ .from(membersTable)
86
+ .innerJoin(usersTable, eq(membersTable.userId, usersTable.id))
87
+ .where(where)
88
+ ])
89
+
90
+ const data = rows.map(row => ({
91
+ ...row,
92
+ image: resolveImageUrl(row.image)
93
+ }))
94
+
95
+ return [data, total[0]?.count ?? 0] as const
96
+ }
97
+ )
98
+
99
+ export const getInvitations = authActionClient
100
+ .inputSchema(
101
+ z.object({
102
+ organizationId: stringValidation,
103
+ ...getPaginationSchema(),
104
+ ...getSortSchema(['email', 'status', 'createdAt', 'expiresAt']),
105
+ search: z.string().trim().optional(),
106
+ filters: z
107
+ .object({
108
+ status: z.array(z.string()).optional()
109
+ })
110
+ .optional()
111
+ })
112
+ )
113
+ .action(
114
+ async ({
115
+ parsedInput: {
116
+ organizationId,
117
+ page,
118
+ limit,
119
+ sortBy,
120
+ sortOrder,
121
+ search,
122
+ filters
123
+ }
124
+ }) => {
125
+ await assertUserCanManageOrganization(organizationId)
126
+
127
+ const where = and(
128
+ eq(invitationsTable.organizationId, organizationId),
129
+ search ? ilike(invitationsTable.email, `%${search}%`) : undefined,
130
+ filters?.status && filters.status.length > 0
131
+ ? inArray(invitationsTable.status, filters.status)
132
+ : undefined
133
+ )
134
+
135
+ const sortColumn = invitationsTable[sortBy ?? 'createdAt']
136
+ const direction = sortOrder === SortOrderEnum.ASC ? asc : desc
137
+
138
+ const [rows, total] = await Promise.all([
139
+ db.query.invitationsTable.findMany({
140
+ where,
141
+ with: {
142
+ user: { columns: { name: true } }
143
+ },
144
+ limit,
145
+ offset: (page - 1) * limit,
146
+ orderBy: [direction(sortColumn)]
147
+ }),
148
+ db.select({ count: count() }).from(invitationsTable).where(where)
149
+ ])
150
+
151
+ const data = rows.map(row => ({
152
+ id: row.id,
153
+ email: row.email,
154
+ role: row.role ?? 'member',
155
+ status: row.status,
156
+ expiresAt: row.expiresAt,
157
+ createdAt: row.createdAt,
158
+ inviterName: row.user?.name ?? null
159
+ }))
160
+
161
+ return [data, total[0]?.count ?? 0] as const
162
+ }
163
+ )
@@ -0,0 +1,92 @@
1
+ 'use server'
2
+
3
+ import { and, eq } from 'drizzle-orm'
4
+ import { z } from 'zod'
5
+ import { db } from '~/db'
6
+ import { membersTable, organizationsTable } from '~/db/schema'
7
+ import {
8
+ assertUserCanManageOrganization,
9
+ getUserMembershipCached
10
+ } from '~/lib/organization-access'
11
+ import { authActionClient } from '~/lib/safe-action'
12
+ import { confirmUpload, isR2Configured, isR2Key } from '~/lib/storage'
13
+ import { stringValidation } from '~/lib/validations'
14
+
15
+ export const updateOrganizationLogoAction = authActionClient
16
+ .inputSchema(
17
+ z.object({
18
+ organizationId: stringValidation,
19
+ key: z.string().min(1),
20
+ oldKey: z.string().min(1).nullish()
21
+ })
22
+ )
23
+ .action(async ({ parsedInput }) => {
24
+ if (!isR2Configured() || !isR2Key(parsedInput.key)) {
25
+ return { success: true }
26
+ }
27
+
28
+ await assertUserCanManageOrganization(parsedInput.organizationId)
29
+
30
+ const org = await db.query.organizationsTable.findFirst({
31
+ where: eq(organizationsTable.id, parsedInput.organizationId),
32
+ columns: {
33
+ id: true,
34
+ logo: true
35
+ }
36
+ })
37
+
38
+ if (!org) {
39
+ throw new Error('Organization not found')
40
+ }
41
+
42
+ if (org.logo !== parsedInput.key) {
43
+ throw new Error(
44
+ 'Organization logo key does not match the current logo key'
45
+ )
46
+ }
47
+
48
+ await confirmUpload(parsedInput.key, parsedInput.oldKey)
49
+ return true
50
+ })
51
+
52
+ export const transferOrganizationOwnershipAction = authActionClient
53
+ .inputSchema(
54
+ z.object({
55
+ organizationId: stringValidation,
56
+ targetMemberId: stringValidation
57
+ })
58
+ )
59
+ .action(async ({ parsedInput: { organizationId, targetMemberId } }) => {
60
+ const membership = await getUserMembershipCached(organizationId)
61
+ if (!membership || membership.role !== 'owner') {
62
+ throw new Error('Only the organization owner can transfer ownership')
63
+ }
64
+
65
+ if (targetMemberId === membership.id) {
66
+ throw new Error('You are already the owner of this organization')
67
+ }
68
+
69
+ const target = await db.query.membersTable.findFirst({
70
+ where: and(
71
+ eq(membersTable.id, targetMemberId),
72
+ eq(membersTable.organizationId, organizationId)
73
+ )
74
+ })
75
+ if (!target) {
76
+ throw new Error('Selected member does not belong to this organization')
77
+ }
78
+
79
+ await db.transaction(async tx => {
80
+ await tx
81
+ .update(membersTable)
82
+ .set({ role: 'owner' })
83
+ .where(eq(membersTable.id, target.id))
84
+
85
+ await tx
86
+ .update(membersTable)
87
+ .set({ role: 'admin' })
88
+ .where(eq(membersTable.id, membership.id))
89
+ })
90
+
91
+ return true
92
+ })
@@ -0,0 +1,20 @@
1
+ 'use client'
2
+
3
+ import { LayoutGrid } from 'lucide-react'
4
+ import { PageHeading } from '~/components/page-heading'
5
+ import { useOrganizationContext } from '../../_components/organization-context'
6
+
7
+ export default function Page() {
8
+ const org = useOrganizationContext()
9
+
10
+ return (
11
+ <div className="space-y-6">
12
+ <PageHeading
13
+ title={org.name}
14
+ description="Your organization's home base."
15
+ icon={LayoutGrid}
16
+ />
17
+ Hello, World!
18
+ </div>
19
+ )
20
+ }
@@ -0,0 +1,34 @@
1
+ import { redirect } from 'next/navigation'
2
+ import { getUserOrganizationsCached } from '~/lib/organization-access'
3
+ import { OrganizationContextProvider } from '../_components/organization-context'
4
+
5
+ export default async function Layout({
6
+ children,
7
+ params
8
+ }: {
9
+ children: React.ReactNode
10
+ params: Promise<{ orgId: string }>
11
+ }) {
12
+ const { orgId } = await params
13
+
14
+ const organizations = await getUserOrganizationsCached()
15
+ const org = organizations.find(o => o.id === orgId)
16
+
17
+ if (!org) {
18
+ return redirect('/app')
19
+ }
20
+
21
+ return (
22
+ <OrganizationContextProvider
23
+ value={{
24
+ id: org.id,
25
+ name: org.name,
26
+ role: org.role,
27
+ memberId: org.memberId,
28
+ userId: org.userId
29
+ }}
30
+ >
31
+ {children}
32
+ </OrganizationContextProvider>
33
+ )
34
+ }
@@ -0,0 +1,100 @@
1
+ 'use client'
2
+
3
+ import { zodResolver } from '@hookform/resolvers/zod'
4
+ import { Loader2 } from 'lucide-react'
5
+ import { useRouter } from 'next/navigation'
6
+ import { useForm } from 'react-hook-form'
7
+ import { z } from 'zod'
8
+ import { updateBillingEmailAction } from '~/app/(app)/actions/billing'
9
+ import { Button } from '~/components/ui/button'
10
+ import {
11
+ Field,
12
+ FieldContent,
13
+ FieldDescription,
14
+ FieldError,
15
+ FieldTitle
16
+ } from '~/components/ui/field'
17
+ import { Input } from '~/components/ui/input'
18
+ import { useSafeActionMutation } from '~/lib/safe-action-client'
19
+ import { toastErrorMessage, toastSuccessMessage } from '~/lib/toast-message'
20
+ import { emailValidation } from '~/lib/validations'
21
+
22
+ const schema = z.object({
23
+ billingEmail: emailValidation
24
+ })
25
+
26
+ type Form = z.infer<typeof schema>
27
+
28
+ export function BillingEmailForm({
29
+ orgId,
30
+ defaultBillingEmail
31
+ }: {
32
+ orgId: string
33
+ defaultBillingEmail: string | null
34
+ }) {
35
+ const router = useRouter()
36
+ const { mutateAsync: updateBillingEmail } = useSafeActionMutation(
37
+ updateBillingEmailAction
38
+ )
39
+
40
+ const {
41
+ register,
42
+ handleSubmit,
43
+ reset,
44
+ formState: { errors, isDirty, isSubmitting }
45
+ } = useForm<Form>({
46
+ resolver: zodResolver(schema),
47
+ defaultValues: { billingEmail: defaultBillingEmail ?? '' }
48
+ })
49
+
50
+ async function onSubmit(values: Form) {
51
+ try {
52
+ await updateBillingEmail({
53
+ organizationId: orgId,
54
+ billingEmail: values.billingEmail
55
+ })
56
+ reset({ billingEmail: values.billingEmail })
57
+ toastSuccessMessage('Billing email updated')
58
+ router.refresh()
59
+ } catch (error) {
60
+ toastErrorMessage(
61
+ error instanceof Error
62
+ ? error.message
63
+ : 'Failed to update billing email'
64
+ )
65
+ }
66
+ }
67
+
68
+ return (
69
+ <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-6">
70
+ <Field orientation="vertical">
71
+ <FieldContent>
72
+ <FieldTitle className="text-foreground mb-1.5 text-[13px] font-semibold">
73
+ Billing email
74
+ </FieldTitle>
75
+ <Input
76
+ type="email"
77
+ placeholder="billing@company.com"
78
+ {...register('billingEmail')}
79
+ aria-invalid={!!errors.billingEmail}
80
+ />
81
+ <FieldDescription className="mt-1.5 text-[13px]">
82
+ Invoices and receipts are sent here. Required before purchasing a
83
+ plan.
84
+ </FieldDescription>
85
+ <FieldError
86
+ errors={[errors.billingEmail]}
87
+ className="mt-1.5 text-[13px]"
88
+ />
89
+ </FieldContent>
90
+ </Field>
91
+
92
+ <div>
93
+ <Button type="submit" disabled={isSubmitting || !isDirty}>
94
+ {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
95
+ Save billing email
96
+ </Button>
97
+ </div>
98
+ </form>
99
+ )
100
+ }