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,27 @@
1
+ import { SpanStatusCode, trace } from '@opentelemetry/api'
2
+
3
+ function getSpan() {
4
+ const span = trace.getActiveSpan()
5
+ return span
6
+ }
7
+
8
+ export function recordWideEvent(attributes: Record<string, any>) {
9
+ const span = getSpan()
10
+ if (!span) return
11
+
12
+ span.setAttributes(attributes)
13
+ }
14
+
15
+ export function recordError(err: unknown) {
16
+ const span = getSpan()
17
+ if (!span) return
18
+
19
+ span.setStatus({
20
+ code: SpanStatusCode.ERROR,
21
+ message: err instanceof Error ? err.message : String(err)
22
+ })
23
+
24
+ span.recordException(err instanceof Error ? err : String(err))
25
+
26
+ return span.spanContext().traceId
27
+ }
@@ -0,0 +1,63 @@
1
+ import { env } from '~/env'
2
+
3
+ export type Plan = {
4
+ id: 'free' | 'pro' | 'ultimate'
5
+ name: string
6
+ maxMembers: number
7
+ stripePrice?: {
8
+ monthly: {
9
+ id: string
10
+ amount: number
11
+ currency: string
12
+ }
13
+ annual: {
14
+ id: string
15
+ amount: number
16
+ currency: string
17
+ }
18
+ }
19
+ canUseTrial?: boolean
20
+ }
21
+
22
+ export const PLANS: Plan[] = [
23
+ {
24
+ id: 'free',
25
+ name: 'Free',
26
+ maxMembers: 1
27
+ },
28
+ {
29
+ id: 'pro',
30
+ name: 'Pro',
31
+ maxMembers: 25,
32
+ stripePrice: {
33
+ monthly: {
34
+ id: env.STRIPE_PRICE_PRO_MONTHLY,
35
+ amount: 2000,
36
+ currency: 'USD'
37
+ },
38
+ annual: {
39
+ id: env.STRIPE_PRICE_PRO_ANNUAL,
40
+ amount: 20000,
41
+ currency: 'USD'
42
+ }
43
+ },
44
+ canUseTrial: true
45
+ },
46
+ {
47
+ id: 'ultimate',
48
+ name: 'Ultimate',
49
+ maxMembers: 100,
50
+ stripePrice: {
51
+ monthly: {
52
+ id: env.STRIPE_PRICE_ULTIMATE_MONTHLY,
53
+ amount: 10000,
54
+ currency: 'USD'
55
+ },
56
+ annual: {
57
+ id: env.STRIPE_PRICE_ULTIMATE_ANNUAL,
58
+ amount: 100000,
59
+ currency: 'USD'
60
+ }
61
+ }
62
+ }
63
+ ]
@@ -0,0 +1,75 @@
1
+ 'use client'
2
+
3
+ import {
4
+ useMutation,
5
+ useQuery,
6
+ type UseMutationOptions,
7
+ type UseQueryOptions
8
+ } from '@tanstack/react-query'
9
+ import type { SafeActionResult } from 'next-safe-action'
10
+
11
+ type AnyAction = (
12
+ ...args: any[]
13
+ ) => Promise<SafeActionResult<any, any, any, any, any>>
14
+ type ActionInput<T extends AnyAction> = Parameters<T>[0]
15
+ type ActionData<T extends AnyAction> = Awaited<ReturnType<T>>['data']
16
+
17
+ class SafeActionError extends Error {
18
+ constructor(
19
+ public message: string,
20
+ public validationErrors?: Record<string, string[] | undefined>
21
+ ) {
22
+ super(message)
23
+ }
24
+ }
25
+
26
+ export function useSafeActionQuery<TAction extends AnyAction>(
27
+ key: string,
28
+ action: TAction,
29
+ input: ActionInput<TAction>,
30
+ options?: Omit<UseQueryOptions<ActionData<TAction>>, 'queryKey' | 'queryFn'>
31
+ ) {
32
+ return useQuery({
33
+ queryKey: [key, input],
34
+ queryFn: async () => {
35
+ const result = await action(input)
36
+
37
+ if (result?.serverError) {
38
+ throw new SafeActionError(result.serverError)
39
+ }
40
+
41
+ if (result?.validationErrors) {
42
+ throw new SafeActionError('Validation failed', result.validationErrors)
43
+ }
44
+
45
+ return result?.data
46
+ },
47
+ ...options
48
+ })
49
+ }
50
+
51
+ export function useSafeActionMutation<TAction extends AnyAction>(
52
+ action: TAction,
53
+ options?: UseMutationOptions<
54
+ ActionData<TAction>,
55
+ SafeActionError,
56
+ ActionInput<TAction>
57
+ >
58
+ ) {
59
+ return useMutation({
60
+ mutationFn: async (input: ActionInput<TAction>) => {
61
+ const result = await action(input)
62
+
63
+ if (result?.serverError) {
64
+ throw new SafeActionError(result.serverError)
65
+ }
66
+
67
+ if (result?.validationErrors) {
68
+ throw new SafeActionError('Validation failed', result.validationErrors)
69
+ }
70
+
71
+ return result?.data
72
+ },
73
+ ...options
74
+ })
75
+ }
@@ -0,0 +1,67 @@
1
+ import { createSafeActionClient } from 'next-safe-action'
2
+ import { getAuthSession } from './auth'
3
+ import { recordWideEvent } from './otel'
4
+
5
+ export const actionClient = createSafeActionClient().use(
6
+ async ({ next, ctx }) => {
7
+ const authSession = await getAuthSession()
8
+
9
+ if (authSession)
10
+ recordWideEvent({
11
+ 'user.id': authSession.user.id,
12
+ 'user.admin': authSession.isAdmin,
13
+ 'session.id': authSession.session.id
14
+ })
15
+
16
+ return next({
17
+ ctx: {
18
+ ...ctx,
19
+ ...authSession
20
+ }
21
+ })
22
+ }
23
+ )
24
+
25
+ export const authActionClient = actionClient.use(async ({ ctx, next }) => {
26
+ if (!ctx?.user || !ctx?.session) {
27
+ throw new Error('Unauthorized')
28
+ }
29
+
30
+ return next({
31
+ ctx: {
32
+ ...ctx,
33
+ user: ctx.user,
34
+ session: ctx.session
35
+ }
36
+ })
37
+ })
38
+
39
+ export const adminActionClient = authActionClient.use(async ({ ctx, next }) => {
40
+ if (!ctx.isAdmin) {
41
+ throw new Error('Forbidden')
42
+ }
43
+
44
+ return next({
45
+ ctx: {
46
+ ...ctx,
47
+ user: ctx.user,
48
+ session: ctx.session
49
+ }
50
+ })
51
+ })
52
+
53
+ export const superAdminActionClient = adminActionClient.use(
54
+ async ({ ctx, next }) => {
55
+ if (!ctx.isSuperAdmin) {
56
+ throw new Error('Forbidden')
57
+ }
58
+
59
+ return next({
60
+ ctx: {
61
+ ...ctx,
62
+ user: ctx.user,
63
+ session: ctx.session
64
+ }
65
+ })
66
+ }
67
+ )
@@ -0,0 +1,4 @@
1
+ export const siteConfig = {
2
+ name: 'Project Name',
3
+ description: 'Project Description'
4
+ }
@@ -0,0 +1,181 @@
1
+ import {
2
+ DeleteObjectCommand,
3
+ PutObjectCommand,
4
+ S3Client
5
+ } from '@aws-sdk/client-s3'
6
+ import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
7
+ import { createId } from '@paralleldrive/cuid2'
8
+ import { and, eq, lt } from 'drizzle-orm'
9
+ import { db } from '~/db'
10
+ import { fileUploadsTable } from '~/db/schema'
11
+ import { env } from '~/env'
12
+ import {
13
+ PRESIGNED_URL_TTL_SECONDS,
14
+ TEMP_CLEANUP_THRESHOLD_MS
15
+ } from './constants'
16
+
17
+ /*
18
+ R2 Policy need to be set
19
+ [
20
+ {
21
+ "AllowedOrigins": [
22
+ "http://localhost:3000"
23
+ ],
24
+ "AllowedMethods": [
25
+ "PUT"
26
+ ],
27
+ "AllowedHeaders": [
28
+ "content-type",
29
+ "content-length"
30
+ ]
31
+ }
32
+ ]
33
+ */
34
+
35
+ export function isR2Configured(): boolean {
36
+ return !!(
37
+ env.R2_ACCOUNT_ID &&
38
+ env.R2_ACCESS_KEY_ID &&
39
+ env.R2_SECRET_ACCESS_KEY &&
40
+ env.R2_BUCKET_NAME &&
41
+ env.R2_PUBLIC_URL
42
+ )
43
+ }
44
+
45
+ let _r2Client: S3Client | null = null
46
+
47
+ function getR2Client(): S3Client {
48
+ if (_r2Client) return _r2Client
49
+
50
+ const configured = isR2Configured()
51
+ if (!configured) {
52
+ throw new Error('R2 storage is not configured')
53
+ }
54
+
55
+ _r2Client = new S3Client({
56
+ region: 'auto',
57
+ endpoint: `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
58
+ credentials: {
59
+ accessKeyId: env.R2_ACCESS_KEY_ID!,
60
+ secretAccessKey: env.R2_SECRET_ACCESS_KEY!
61
+ }
62
+ })
63
+
64
+ return _r2Client
65
+ }
66
+
67
+ export async function deleteFile(key: string): Promise<void> {
68
+ const client = getR2Client()
69
+ await client.send(
70
+ new DeleteObjectCommand({
71
+ Bucket: env.R2_BUCKET_NAME,
72
+ Key: key
73
+ })
74
+ )
75
+
76
+ await db.delete(fileUploadsTable).where(eq(fileUploadsTable.key, key))
77
+ }
78
+
79
+ export async function generateUploadUrl(params: {
80
+ filename: string
81
+ mimeType: string
82
+ size: number
83
+ uploadedBy: string
84
+ organizationId?: string | null
85
+ }): Promise<{
86
+ uploadUrl: string
87
+ key: string
88
+ }> {
89
+ const ext = params.filename.split('.').pop()?.toLowerCase() ?? 'bin'
90
+ const folder = params.organizationId
91
+ ? `uploads/organizations/${params.organizationId}`
92
+ : `uploads/misc`
93
+
94
+ const fileId = createId()
95
+ const key = `${folder}/${fileId}.${ext}`
96
+
97
+ const client = getR2Client()
98
+ const uploadUrl = await getSignedUrl(
99
+ client,
100
+ new PutObjectCommand({
101
+ Bucket: env.R2_BUCKET_NAME!,
102
+ Key: key,
103
+ ContentType: params.mimeType,
104
+ ContentLength: params.size
105
+ }),
106
+ { expiresIn: PRESIGNED_URL_TTL_SECONDS }
107
+ )
108
+
109
+ await db.insert(fileUploadsTable).values({
110
+ id: `file_upload_${fileId}`,
111
+ key,
112
+ originalName: params.filename,
113
+ mimeType: params.mimeType,
114
+ size: params.size,
115
+ isTemp: true,
116
+ uploadedBy: params.uploadedBy,
117
+ organizationId: params.organizationId
118
+ })
119
+
120
+ return { uploadUrl, key }
121
+ }
122
+
123
+ export async function confirmUpload(
124
+ newKey: string,
125
+ oldKey?: string | null
126
+ ): Promise<void> {
127
+ await db
128
+ .update(fileUploadsTable)
129
+ .set({ isTemp: false })
130
+ .where(eq(fileUploadsTable.key, newKey))
131
+
132
+ if (oldKey && isR2Configured()) {
133
+ await deleteFile(oldKey).catch(() => {
134
+ // Non-fatal: old file cleanup failure should not block the save
135
+ })
136
+ }
137
+ }
138
+
139
+ export function isR2Key(value: string | null | undefined): value is string {
140
+ return !!value && !value.startsWith('data:') && !value.startsWith('https://')
141
+ }
142
+
143
+ export function resolveImageUrl(
144
+ value: string | null | undefined
145
+ ): string | null {
146
+ if (!value) return null
147
+ if (!isR2Key(value)) return value
148
+ if (!isR2Configured()) return null
149
+ return `${env.R2_PUBLIC_URL}/${value}`
150
+ }
151
+
152
+ export async function cleanupTempUploads(): Promise<{
153
+ deleted: number
154
+ errors: number
155
+ }> {
156
+ const threshold = new Date(Date.now() - TEMP_CLEANUP_THRESHOLD_MS)
157
+
158
+ const stale = await db
159
+ .select({ id: fileUploadsTable.id, key: fileUploadsTable.key })
160
+ .from(fileUploadsTable)
161
+ .where(
162
+ and(
163
+ eq(fileUploadsTable.isTemp, true),
164
+ lt(fileUploadsTable.createdAt, threshold)
165
+ )
166
+ )
167
+
168
+ let deleted = 0
169
+ let errors = 0
170
+
171
+ for (const file of stale) {
172
+ try {
173
+ await deleteFile(file.key)
174
+ deleted++
175
+ } catch {
176
+ errors++
177
+ }
178
+ }
179
+
180
+ return { deleted, errors }
181
+ }
@@ -0,0 +1,6 @@
1
+ import Stripe from 'stripe'
2
+ import { env } from '~/env'
3
+
4
+ export const stripeClient = new Stripe(env.STRIPE_SECRET_KEY, {
5
+ apiVersion: '2026-05-27.dahlia'
6
+ })
@@ -0,0 +1,15 @@
1
+ 'use client'
2
+
3
+ import { toast } from 'sonner'
4
+
5
+ export const toastSuccessMessage = (message: string) =>
6
+ toast.success(message, {
7
+ richColors: true,
8
+ position: 'top-center'
9
+ })
10
+
11
+ export const toastErrorMessage = (message?: string) =>
12
+ toast.error(message || 'Something went wrong. Please try again later.', {
13
+ richColors: true,
14
+ position: 'top-center'
15
+ })
@@ -0,0 +1,12 @@
1
+ import z from 'zod'
2
+
3
+ export const phoneValidation = z
4
+ .string()
5
+ .trim()
6
+ .min(10)
7
+ .max(20)
8
+ .regex(/^[\d+\-().\s]+$/)
9
+
10
+ export const emailValidation = z.email().trim().toLowerCase()
11
+
12
+ export const stringValidation = z.string().trim().min(1).max(255)
@@ -0,0 +1,22 @@
1
+ import { sql } from 'drizzle-orm'
2
+ import { db } from '~/db'
3
+ import { env } from '~/env'
4
+
5
+ const resetDb = async () => {
6
+ if (env.APP_ENV === 'production') {
7
+ throw new Error('Cannot reset database in production environment.')
8
+ }
9
+
10
+ await db.execute(sql`DROP SCHEMA IF EXISTS public CASCADE;`)
11
+ await db.execute(sql`CREATE SCHEMA public;`)
12
+ }
13
+
14
+ if (import.meta.main) {
15
+ try {
16
+ await resetDb()
17
+ process.exit(0)
18
+ } catch (err) {
19
+ console.error('Error running reset-db:', err)
20
+ process.exit(1)
21
+ }
22
+ }
@@ -0,0 +1,11 @@
1
+ const seedData = async () => {}
2
+
3
+ if (import.meta.main) {
4
+ try {
5
+ await seedData()
6
+ } catch (err) {
7
+ console.error('Error seeding data:', err)
8
+ } finally {
9
+ process.exit(0)
10
+ }
11
+ }
@@ -0,0 +1,200 @@
1
+ import { writeFile } from 'fs/promises'
2
+ import type Stripe from 'stripe'
3
+ import { env } from '~/env'
4
+ import { PLANS } from '~/lib/plans'
5
+ import { stripeClient } from '~/lib/stripe'
6
+
7
+ const checkStripePriceExists = async (priceId: string) => {
8
+ try {
9
+ await stripeClient.prices.retrieve(priceId)
10
+ return true
11
+ } catch (err) {
12
+ if (
13
+ err &&
14
+ typeof err === 'object' &&
15
+ 'code' in err &&
16
+ (err as { code?: string }).code === 'resource_missing'
17
+ ) {
18
+ return false
19
+ }
20
+ throw err
21
+ }
22
+ }
23
+
24
+ const createStripeProduct = async (name: string, planId: string) => {
25
+ const newProduct = await stripeClient.products.create({
26
+ name,
27
+ metadata: { planId }
28
+ })
29
+
30
+ return newProduct.id
31
+ }
32
+
33
+ const createStripePrice = async (
34
+ productId: string,
35
+ planId: string,
36
+ interval: 'month' | 'year',
37
+ amount: number,
38
+ currency: string
39
+ ) => {
40
+ const newPrice = await stripeClient.prices.create({
41
+ product: productId,
42
+ currency,
43
+ unit_amount: amount,
44
+ recurring: { interval },
45
+ metadata: { planId }
46
+ })
47
+
48
+ return newPrice.id
49
+ }
50
+
51
+ type PaidPlanPrices = {
52
+ planId: string
53
+ productId: string
54
+ monthlyPriceId: string
55
+ annualPriceId: string
56
+ }
57
+
58
+ const setupStripePlans = async () => {
59
+ let newEnvString = ''
60
+ const paidPlans: PaidPlanPrices[] = []
61
+
62
+ for (const plan of PLANS) {
63
+ if (!plan.stripePrice) continue
64
+
65
+ const monthlyExists = await checkStripePriceExists(
66
+ plan.stripePrice.monthly.id
67
+ )
68
+ const annualExists = await checkStripePriceExists(
69
+ plan.stripePrice.annual.id
70
+ )
71
+
72
+ if ((!monthlyExists && annualExists) || (monthlyExists && !annualExists)) {
73
+ throw new Error(
74
+ `Inconsistent Stripe price setup for plan ${plan.name}. Monthly exists: ${monthlyExists}, Annual exists: ${annualExists}`
75
+ )
76
+ }
77
+
78
+ if (monthlyExists && annualExists) {
79
+ const price = await stripeClient.prices.retrieve(
80
+ plan.stripePrice.monthly.id
81
+ )
82
+ paidPlans.push({
83
+ planId: plan.id,
84
+ productId:
85
+ typeof price.product === 'string' ? price.product : price.product.id,
86
+ monthlyPriceId: plan.stripePrice.monthly.id,
87
+ annualPriceId: plan.stripePrice.annual.id
88
+ })
89
+ continue
90
+ }
91
+
92
+ const productId = await createStripeProduct(plan.name, plan.id)
93
+ const monthlyPriceId = await createStripePrice(
94
+ productId,
95
+ plan.id,
96
+ 'month',
97
+ plan.stripePrice.monthly.amount,
98
+ plan.stripePrice.monthly.currency
99
+ )
100
+
101
+ newEnvString += `\nSTRIPE_PRICE_${plan.id.toUpperCase()}_MONTHLY="${monthlyPriceId}"`
102
+
103
+ const annualPriceId = await createStripePrice(
104
+ productId,
105
+ plan.id,
106
+ 'year',
107
+ plan.stripePrice.annual.amount,
108
+ plan.stripePrice.annual.currency
109
+ )
110
+
111
+ newEnvString += `\nSTRIPE_PRICE_${plan.id.toUpperCase()}_ANNUAL="${annualPriceId}"`
112
+
113
+ paidPlans.push({
114
+ planId: plan.id,
115
+ productId,
116
+ monthlyPriceId,
117
+ annualPriceId
118
+ })
119
+ }
120
+
121
+ const portalConfigId = await setupPortalConfiguration(paidPlans)
122
+ if (portalConfigId) {
123
+ newEnvString += `\nSTRIPE_PORTAL_CONFIGURATION_ID="${portalConfigId}"`
124
+ }
125
+
126
+ newEnvString = newEnvString.trim()
127
+
128
+ if (newEnvString) {
129
+ await writeFile('stripe.txt', newEnvString)
130
+ console.info('Stripe setup complete. New values written to stripe.txt')
131
+ } else {
132
+ console.info('All Stripe plans and portal config already exist.')
133
+ }
134
+ }
135
+
136
+ const setupPortalConfiguration = async (paidPlans: PaidPlanPrices[]) => {
137
+ if (paidPlans.length === 0) return null
138
+
139
+ const params: Stripe.BillingPortal.ConfigurationCreateParams = {
140
+ features: {
141
+ customer_update: {
142
+ enabled: true,
143
+ allowed_updates: ['address', 'name', 'tax_id']
144
+ },
145
+ invoice_history: { enabled: true },
146
+ payment_method_update: { enabled: true },
147
+ subscription_cancel: {
148
+ enabled: true,
149
+ mode: 'at_period_end',
150
+ cancellation_reason: {
151
+ enabled: true,
152
+ options: [
153
+ 'too_expensive',
154
+ 'missing_features',
155
+ 'switched_service',
156
+ 'unused',
157
+ 'other'
158
+ ]
159
+ }
160
+ },
161
+ subscription_update: {
162
+ enabled: true,
163
+ default_allowed_updates: ['price'],
164
+ proration_behavior: 'create_prorations',
165
+ products: paidPlans.map(p => ({
166
+ product: p.productId,
167
+ prices: [p.monthlyPriceId, p.annualPriceId]
168
+ }))
169
+ }
170
+ }
171
+ }
172
+
173
+ const existingId = env.STRIPE_PORTAL_CONFIGURATION_ID
174
+ if (existingId) {
175
+ try {
176
+ await stripeClient.billingPortal.configurations.update(existingId, params)
177
+ return null
178
+ } catch (err) {
179
+ const isMissing =
180
+ err &&
181
+ typeof err === 'object' &&
182
+ 'code' in err &&
183
+ (err as { code?: string }).code === 'resource_missing'
184
+ if (!isMissing) throw err
185
+ }
186
+ }
187
+
188
+ const created = await stripeClient.billingPortal.configurations.create(params)
189
+ return created.id
190
+ }
191
+
192
+ if (import.meta.main) {
193
+ try {
194
+ await setupStripePlans()
195
+ } catch (err) {
196
+ console.error('Error setting up stripe plans:', err)
197
+ } finally {
198
+ process.exit(0)
199
+ }
200
+ }