shipd 0.1.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 (145) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +205 -0
  3. package/dist/index.d.ts +1 -0
  4. package/dist/index.js +1366 -0
  5. package/docs-template/README.md +255 -0
  6. package/docs-template/[slug]/[subslug]/page.tsx +1242 -0
  7. package/docs-template/[slug]/page.tsx +422 -0
  8. package/docs-template/api/page.tsx +47 -0
  9. package/docs-template/components/docs/docs-category-page.tsx +162 -0
  10. package/docs-template/components/docs/docs-code-card.tsx +135 -0
  11. package/docs-template/components/docs/docs-header.tsx +69 -0
  12. package/docs-template/components/docs/docs-nav.ts +95 -0
  13. package/docs-template/components/docs/docs-sidebar.tsx +112 -0
  14. package/docs-template/components/docs/docs-toc.tsx +38 -0
  15. package/docs-template/components/ui/badge.tsx +47 -0
  16. package/docs-template/components/ui/button.tsx +60 -0
  17. package/docs-template/components/ui/card.tsx +93 -0
  18. package/docs-template/components/ui/sheet.tsx +140 -0
  19. package/docs-template/documentation/page.tsx +80 -0
  20. package/docs-template/layout.tsx +27 -0
  21. package/docs-template/lib/utils.ts +7 -0
  22. package/docs-template/page.tsx +360 -0
  23. package/package.json +66 -0
  24. package/template/.env.example +45 -0
  25. package/template/README.md +239 -0
  26. package/template/app/api/auth/[...all]/route.ts +4 -0
  27. package/template/app/api/chat/route.ts +16 -0
  28. package/template/app/api/subscription/route.ts +25 -0
  29. package/template/app/api/upload-image/route.ts +64 -0
  30. package/template/app/blog/[slug]/page.tsx +314 -0
  31. package/template/app/blog/page.tsx +107 -0
  32. package/template/app/dashboard/_components/chart-interactive.tsx +289 -0
  33. package/template/app/dashboard/_components/chatbot.tsx +39 -0
  34. package/template/app/dashboard/_components/mode-toggle.tsx +46 -0
  35. package/template/app/dashboard/_components/navbar.tsx +84 -0
  36. package/template/app/dashboard/_components/section-cards.tsx +102 -0
  37. package/template/app/dashboard/_components/sidebar.tsx +90 -0
  38. package/template/app/dashboard/_components/subscribe-button.tsx +49 -0
  39. package/template/app/dashboard/billing/page.tsx +277 -0
  40. package/template/app/dashboard/chat/page.tsx +73 -0
  41. package/template/app/dashboard/cli/page.tsx +260 -0
  42. package/template/app/dashboard/layout.tsx +24 -0
  43. package/template/app/dashboard/page.tsx +216 -0
  44. package/template/app/dashboard/payment/_components/manage-subscription.tsx +22 -0
  45. package/template/app/dashboard/payment/page.tsx +126 -0
  46. package/template/app/dashboard/settings/page.tsx +613 -0
  47. package/template/app/dashboard/upload/page.tsx +324 -0
  48. package/template/app/error.tsx +78 -0
  49. package/template/app/favicon.ico +0 -0
  50. package/template/app/globals.css +126 -0
  51. package/template/app/layout.tsx +135 -0
  52. package/template/app/not-found.tsx +45 -0
  53. package/template/app/page.tsx +28 -0
  54. package/template/app/pricing/_component/pricing-table.tsx +276 -0
  55. package/template/app/pricing/page.tsx +23 -0
  56. package/template/app/privacy-policy/page.tsx +280 -0
  57. package/template/app/robots.txt +12 -0
  58. package/template/app/sign-in/page.tsx +228 -0
  59. package/template/app/sign-up/page.tsx +243 -0
  60. package/template/app/sitemap.ts +62 -0
  61. package/template/app/success/page.tsx +123 -0
  62. package/template/app/terms-of-service/page.tsx +212 -0
  63. package/template/auth-schema.ts +47 -0
  64. package/template/components/homepage/cli-workflow-section.tsx +138 -0
  65. package/template/components/homepage/features-section.tsx +150 -0
  66. package/template/components/homepage/footer.tsx +53 -0
  67. package/template/components/homepage/hero-section.tsx +112 -0
  68. package/template/components/homepage/integrations.tsx +124 -0
  69. package/template/components/homepage/navigation.tsx +116 -0
  70. package/template/components/homepage/news-section.tsx +82 -0
  71. package/template/components/homepage/testimonials-section.tsx +34 -0
  72. package/template/components/logos/BetterAuth.tsx +21 -0
  73. package/template/components/logos/NeonPostgres.tsx +41 -0
  74. package/template/components/logos/Nextjs.tsx +72 -0
  75. package/template/components/logos/Polar.tsx +7 -0
  76. package/template/components/logos/TailwindCSS.tsx +27 -0
  77. package/template/components/logos/index.ts +6 -0
  78. package/template/components/logos/shadcnui.tsx +8 -0
  79. package/template/components/provider.tsx +8 -0
  80. package/template/components/ui/avatar.tsx +53 -0
  81. package/template/components/ui/badge.tsx +46 -0
  82. package/template/components/ui/button.tsx +59 -0
  83. package/template/components/ui/card.tsx +92 -0
  84. package/template/components/ui/chart.tsx +353 -0
  85. package/template/components/ui/checkbox.tsx +32 -0
  86. package/template/components/ui/dialog.tsx +135 -0
  87. package/template/components/ui/dropdown-menu.tsx +257 -0
  88. package/template/components/ui/form.tsx +167 -0
  89. package/template/components/ui/input.tsx +21 -0
  90. package/template/components/ui/label.tsx +24 -0
  91. package/template/components/ui/progress.tsx +31 -0
  92. package/template/components/ui/resizable.tsx +56 -0
  93. package/template/components/ui/select.tsx +185 -0
  94. package/template/components/ui/separator.tsx +28 -0
  95. package/template/components/ui/sheet.tsx +139 -0
  96. package/template/components/ui/skeleton.tsx +13 -0
  97. package/template/components/ui/sonner.tsx +25 -0
  98. package/template/components/ui/switch.tsx +31 -0
  99. package/template/components/ui/tabs.tsx +66 -0
  100. package/template/components/ui/textarea.tsx +18 -0
  101. package/template/components/ui/toggle-group.tsx +73 -0
  102. package/template/components/ui/toggle.tsx +47 -0
  103. package/template/components/ui/tooltip.tsx +61 -0
  104. package/template/components/user-profile.tsx +139 -0
  105. package/template/components.json +21 -0
  106. package/template/db/drizzle.ts +14 -0
  107. package/template/db/migrations/0000_worried_rawhide_kid.sql +77 -0
  108. package/template/db/migrations/meta/0000_snapshot.json +494 -0
  109. package/template/db/migrations/meta/_journal.json +13 -0
  110. package/template/db/schema.ts +85 -0
  111. package/template/drizzle.config.ts +13 -0
  112. package/template/emails/components/layout.tsx +181 -0
  113. package/template/emails/password-reset.tsx +67 -0
  114. package/template/emails/payment-failed.tsx +167 -0
  115. package/template/emails/subscription-confirmation.tsx +129 -0
  116. package/template/emails/welcome.tsx +100 -0
  117. package/template/eslint.config.mjs +16 -0
  118. package/template/hooks/use-mobile.ts +21 -0
  119. package/template/lib/auth-client.ts +8 -0
  120. package/template/lib/auth.ts +276 -0
  121. package/template/lib/email.ts +118 -0
  122. package/template/lib/polar-products.ts +49 -0
  123. package/template/lib/subscription.ts +148 -0
  124. package/template/lib/upload-image.ts +28 -0
  125. package/template/lib/utils.ts +6 -0
  126. package/template/middleware.ts +30 -0
  127. package/template/next-env.d.ts +5 -0
  128. package/template/next.config.ts +27 -0
  129. package/template/package.json +99 -0
  130. package/template/postcss.config.mjs +5 -0
  131. package/template/public/add.png +0 -0
  132. package/template/public/favicon.svg +4 -0
  133. package/template/public/file.svg +1 -0
  134. package/template/public/globe.svg +1 -0
  135. package/template/public/iphone.png +0 -0
  136. package/template/public/logo.png +0 -0
  137. package/template/public/next.svg +1 -0
  138. package/template/public/polar-sh.svg +1 -0
  139. package/template/public/shadcn-ui.svg +1 -0
  140. package/template/public/site.webmanifest +21 -0
  141. package/template/public/vercel.svg +1 -0
  142. package/template/public/window.svg +1 -0
  143. package/template/tailwind.config.ts +89 -0
  144. package/template/template.config.json +138 -0
  145. package/template/tsconfig.json +27 -0
@@ -0,0 +1,276 @@
1
+ import { db } from "@/db/drizzle";
2
+ import { account, session, subscription, user, verification } from "@/db/schema";
3
+ import {
4
+ checkout,
5
+ polar,
6
+ portal,
7
+ usage,
8
+ webhooks,
9
+ } from "@polar-sh/better-auth";
10
+ import { Polar } from "@polar-sh/sdk";
11
+ import { betterAuth } from "better-auth";
12
+ import { drizzleAdapter } from "better-auth/adapters/drizzle";
13
+ import { nextCookies } from "better-auth/next-js";
14
+ import {
15
+ sendPasswordResetEmail,
16
+ sendSubscriptionConfirmationEmail,
17
+ sendPaymentFailedEmail,
18
+ } from "@/lib/email";
19
+
20
+ // Utility function to safely parse dates
21
+ function safeParseDate(value: string | Date | null | undefined): Date | null {
22
+ if (!value) return null;
23
+ if (value instanceof Date) return value;
24
+ return new Date(value);
25
+ }
26
+
27
+ // Check if Polar is configured
28
+ const isPolarConfigured = !!(
29
+ process.env.POLAR_ACCESS_TOKEN &&
30
+ process.env.POLAR_WEBHOOK_SECRET &&
31
+ process.env.NEXT_PUBLIC_STARTER_TIER &&
32
+ process.env.NEXT_PUBLIC_STARTER_SLUG
33
+ );
34
+
35
+ if (!isPolarConfigured && process.env.NODE_ENV === "development") {
36
+ console.warn(
37
+ "⚠️ Polar.sh not configured - subscription features disabled. Configure POLAR_* env vars to enable payments.",
38
+ );
39
+ }
40
+
41
+ const polarClient = isPolarConfigured
42
+ ? new Polar({
43
+ accessToken: process.env.POLAR_ACCESS_TOKEN,
44
+ })
45
+ : null;
46
+
47
+ export const auth = betterAuth({
48
+ trustedOrigins: [`${process.env.NEXT_PUBLIC_APP_URL}`],
49
+ allowedDevOrigins: [`${process.env.NEXT_PUBLIC_APP_URL}`],
50
+ cookieCache: {
51
+ enabled: true,
52
+ maxAge: 5 * 60, // Cache duration in seconds
53
+ },
54
+ database: drizzleAdapter(db, {
55
+ provider: "pg",
56
+ schema: {
57
+ user,
58
+ session,
59
+ account,
60
+ verification,
61
+ subscription,
62
+ },
63
+ }),
64
+ emailAndPassword: {
65
+ enabled: true,
66
+ sendResetPassword: async ({ user, url }) => {
67
+ // Send password reset email
68
+ await sendPasswordResetEmail(user.email, url);
69
+ },
70
+ },
71
+ user: {
72
+ additionalFields: {
73
+ name: {
74
+ type: "string",
75
+ required: false,
76
+ },
77
+ },
78
+ },
79
+ socialProviders: {
80
+ google: {
81
+ clientId: process.env.GOOGLE_CLIENT_ID!,
82
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
83
+ },
84
+ },
85
+ // TODO: Implement welcome emails via a custom sign-up API route
86
+ // Better Auth hooks API may have changed - using webhook approach instead
87
+ plugins: [
88
+ ...(isPolarConfigured && polarClient
89
+ ? [
90
+ polar({
91
+ client: polarClient,
92
+ createCustomerOnSignUp: true,
93
+ use: [
94
+ checkout({
95
+ products: [
96
+ {
97
+ productId: process.env.NEXT_PUBLIC_STARTER_TIER!,
98
+ slug: process.env.NEXT_PUBLIC_STARTER_SLUG!,
99
+ },
100
+ ],
101
+ successUrl: `${process.env.NEXT_PUBLIC_APP_URL}/${process.env.POLAR_SUCCESS_URL}`,
102
+ authenticatedUsersOnly: true,
103
+ }),
104
+ portal(),
105
+ usage(),
106
+ webhooks({
107
+ secret: process.env.POLAR_WEBHOOK_SECRET!,
108
+ onPayload: async ({ data, type }) => {
109
+ if (
110
+ type === "subscription.created" ||
111
+ type === "subscription.active" ||
112
+ type === "subscription.canceled" ||
113
+ type === "subscription.revoked" ||
114
+ type === "subscription.uncanceled" ||
115
+ type === "subscription.updated"
116
+ ) {
117
+ console.log("🎯 Processing subscription webhook:", type);
118
+ console.log("📦 Payload data:", JSON.stringify(data, null, 2));
119
+
120
+ try {
121
+ // STEP 1: Extract user ID from customer data
122
+ const userId = data.customer?.externalId;
123
+ // STEP 2: Build subscription data
124
+ const subscriptionData = {
125
+ id: data.id,
126
+ createdAt: new Date(data.createdAt),
127
+ modifiedAt: safeParseDate(data.modifiedAt),
128
+ amount: data.amount,
129
+ currency: data.currency,
130
+ recurringInterval: data.recurringInterval,
131
+ status: data.status,
132
+ currentPeriodStart:
133
+ safeParseDate(data.currentPeriodStart) || new Date(),
134
+ currentPeriodEnd:
135
+ safeParseDate(data.currentPeriodEnd) || new Date(),
136
+ cancelAtPeriodEnd: data.cancelAtPeriodEnd || false,
137
+ canceledAt: safeParseDate(data.canceledAt),
138
+ startedAt: safeParseDate(data.startedAt) || new Date(),
139
+ endsAt: safeParseDate(data.endsAt),
140
+ endedAt: safeParseDate(data.endedAt),
141
+ customerId: data.customerId,
142
+ productId: data.productId,
143
+ discountId: data.discountId || null,
144
+ checkoutId: data.checkoutId || "",
145
+ customerCancellationReason:
146
+ data.customerCancellationReason || null,
147
+ customerCancellationComment:
148
+ data.customerCancellationComment || null,
149
+ metadata: data.metadata
150
+ ? JSON.stringify(data.metadata)
151
+ : null,
152
+ customFieldData: data.customFieldData
153
+ ? JSON.stringify(data.customFieldData)
154
+ : null,
155
+ userId: userId as string | null,
156
+ };
157
+
158
+ console.log("💾 Final subscription data:", {
159
+ id: subscriptionData.id,
160
+ status: subscriptionData.status,
161
+ userId: subscriptionData.userId,
162
+ amount: subscriptionData.amount,
163
+ });
164
+
165
+ // STEP 3: Use Drizzle's onConflictDoUpdate for proper upsert
166
+ await db
167
+ .insert(subscription)
168
+ .values(subscriptionData)
169
+ .onConflictDoUpdate({
170
+ target: subscription.id,
171
+ set: {
172
+ modifiedAt: subscriptionData.modifiedAt || new Date(),
173
+ amount: subscriptionData.amount,
174
+ currency: subscriptionData.currency,
175
+ recurringInterval: subscriptionData.recurringInterval,
176
+ status: subscriptionData.status,
177
+ currentPeriodStart: subscriptionData.currentPeriodStart,
178
+ currentPeriodEnd: subscriptionData.currentPeriodEnd,
179
+ cancelAtPeriodEnd: subscriptionData.cancelAtPeriodEnd,
180
+ canceledAt: subscriptionData.canceledAt,
181
+ startedAt: subscriptionData.startedAt,
182
+ endsAt: subscriptionData.endsAt,
183
+ endedAt: subscriptionData.endedAt,
184
+ customerId: subscriptionData.customerId,
185
+ productId: subscriptionData.productId,
186
+ discountId: subscriptionData.discountId,
187
+ checkoutId: subscriptionData.checkoutId,
188
+ customerCancellationReason:
189
+ subscriptionData.customerCancellationReason,
190
+ customerCancellationComment:
191
+ subscriptionData.customerCancellationComment,
192
+ metadata: subscriptionData.metadata,
193
+ customFieldData: subscriptionData.customFieldData,
194
+ userId: subscriptionData.userId,
195
+ },
196
+ });
197
+
198
+ console.log("✅ Upserted subscription:", data.id);
199
+
200
+ // Send subscription emails based on event type
201
+ try {
202
+ // Get user email from database
203
+ if (userId) {
204
+ const userRecord = await db
205
+ .select()
206
+ .from(user)
207
+ .where((users) => users.id === userId)
208
+ .limit(1);
209
+
210
+ if (userRecord[0]?.email) {
211
+ const userEmail = userRecord[0].email;
212
+
213
+ // Send confirmation email for new or reactivated subscriptions
214
+ if (
215
+ type === "subscription.created" ||
216
+ type === "subscription.active"
217
+ ) {
218
+ const amount = `$${(data.amount / 100).toFixed(2)}/${data.recurringInterval}`;
219
+ await sendSubscriptionConfirmationEmail(
220
+ userEmail,
221
+ "Premium", // TODO: Get actual plan name from product
222
+ amount
223
+ );
224
+ console.log("📧 Sent subscription confirmation email to:", userEmail);
225
+ }
226
+ }
227
+ }
228
+ } catch (emailError) {
229
+ console.error("📧 Failed to send subscription email:", emailError);
230
+ // Don't throw - email failures shouldn't block webhook processing
231
+ }
232
+ } catch (error) {
233
+ console.error(
234
+ "💥 Error processing subscription webhook:",
235
+ error,
236
+ );
237
+ // Don't throw - let webhook succeed to avoid retries
238
+ }
239
+ }
240
+
241
+ // Handle payment failed events
242
+ if (type === "subscription.payment_failed") {
243
+ try {
244
+ const userId = data.customer?.externalId;
245
+ if (userId) {
246
+ const userRecord = await db
247
+ .select()
248
+ .from(user)
249
+ .where((users) => users.id === userId)
250
+ .limit(1);
251
+
252
+ if (userRecord[0]?.email) {
253
+ const retryDate = new Date();
254
+ retryDate.setDate(retryDate.getDate() + 3); // 3 days from now
255
+
256
+ await sendPaymentFailedEmail(
257
+ userRecord[0].email,
258
+ "Premium", // TODO: Get actual plan name
259
+ retryDate.toLocaleDateString()
260
+ );
261
+ console.log("📧 Sent payment failed email to:", userRecord[0].email);
262
+ }
263
+ }
264
+ } catch (error) {
265
+ console.error("📧 Failed to send payment failed email:", error);
266
+ }
267
+ }
268
+ },
269
+ }),
270
+ ],
271
+ }),
272
+ ]
273
+ : []),
274
+ nextCookies(),
275
+ ],
276
+ });
@@ -0,0 +1,118 @@
1
+ import { Resend } from 'resend';
2
+
3
+ // Check if email is configured
4
+ const isEmailConfigured = !!process.env.RESEND_API_KEY;
5
+
6
+ if (!isEmailConfigured && process.env.NODE_ENV === 'development') {
7
+ console.warn('⚠️ Resend not configured - email sending disabled. Set RESEND_API_KEY to enable emails.');
8
+ }
9
+
10
+ export const resend = isEmailConfigured ? new Resend(process.env.RESEND_API_KEY) : null;
11
+
12
+ /**
13
+ * Send an email using Resend
14
+ * @param to - Recipient email address
15
+ * @param subject - Email subject line
16
+ * @param react - React email component
17
+ * @param from - Sender email (defaults to noreply@yourdomain.com)
18
+ */
19
+ export async function sendEmail({
20
+ to,
21
+ subject,
22
+ react,
23
+ from = process.env.EMAIL_FROM || 'SaaS Scaffold <noreply@saas-scaffold.com>',
24
+ }: {
25
+ to: string;
26
+ subject: string;
27
+ react: React.ReactElement;
28
+ from?: string;
29
+ }) {
30
+ // Skip if email is not configured
31
+ if (!resend) {
32
+ console.log(`📧 Email not sent (Resend not configured): ${subject} to ${to}`);
33
+ return { success: true, skipped: true };
34
+ }
35
+
36
+ try {
37
+ const { data, error } = await resend.emails.send({
38
+ from,
39
+ to,
40
+ subject,
41
+ react,
42
+ });
43
+
44
+ if (error) {
45
+ console.error('Failed to send email:', error);
46
+ throw new Error(`Email send failed: ${error.message}`);
47
+ }
48
+
49
+ console.log('Email sent successfully:', data);
50
+ return { success: true, data };
51
+ } catch (error) {
52
+ console.error('Email send error:', error);
53
+ return {
54
+ success: false,
55
+ error: error instanceof Error ? error.message : 'Unknown error'
56
+ };
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Send welcome email to new users
62
+ */
63
+ export async function sendWelcomeEmail(email: string, name?: string) {
64
+ const { WelcomeEmail } = await import('@/emails/welcome');
65
+
66
+ return sendEmail({
67
+ to: email,
68
+ subject: 'Welcome to SaaS Scaffold!',
69
+ react: WelcomeEmail({ userName: name || 'there' }),
70
+ });
71
+ }
72
+
73
+ /**
74
+ * Send password reset email
75
+ */
76
+ export async function sendPasswordResetEmail(email: string, resetUrl: string) {
77
+ const { PasswordResetEmail } = await import('@/emails/password-reset');
78
+
79
+ return sendEmail({
80
+ to: email,
81
+ subject: 'Reset your password',
82
+ react: PasswordResetEmail({ resetUrl }),
83
+ });
84
+ }
85
+
86
+ /**
87
+ * Send subscription confirmation email
88
+ */
89
+ export async function sendSubscriptionConfirmationEmail(
90
+ email: string,
91
+ planName: string,
92
+ amount: string
93
+ ) {
94
+ const { SubscriptionConfirmationEmail } = await import('@/emails/subscription-confirmation');
95
+
96
+ return sendEmail({
97
+ to: email,
98
+ subject: `Subscription confirmed: ${planName}`,
99
+ react: SubscriptionConfirmationEmail({ planName, amount }),
100
+ });
101
+ }
102
+
103
+ /**
104
+ * Send payment failed email
105
+ */
106
+ export async function sendPaymentFailedEmail(
107
+ email: string,
108
+ planName: string,
109
+ retryDate: string
110
+ ) {
111
+ const { PaymentFailedEmail } = await import('@/emails/payment-failed');
112
+
113
+ return sendEmail({
114
+ to: email,
115
+ subject: 'Payment failed - Action required',
116
+ react: PaymentFailedEmail({ planName, retryDate }),
117
+ });
118
+ }
@@ -0,0 +1,49 @@
1
+ import { Polar } from '@polar-sh/sdk';
2
+
3
+ const polarClient = new Polar({
4
+ accessToken: process.env.POLAR_ACCESS_TOKEN!,
5
+ });
6
+
7
+ export interface ProductDetails {
8
+ id: string;
9
+ name: string;
10
+ description: string | null;
11
+ prices: Array<{
12
+ id: string;
13
+ amount: number;
14
+ currency: string;
15
+ recurring_interval: 'month' | 'year';
16
+ }>;
17
+ }
18
+
19
+ /**
20
+ * Fetch product details from Polar
21
+ */
22
+ export async function getProductDetails(
23
+ productId: string
24
+ ): Promise<ProductDetails | null> {
25
+ try {
26
+ const product = await polarClient.products.get({
27
+ id: productId,
28
+ });
29
+
30
+ if (!product) {
31
+ return null;
32
+ }
33
+
34
+ return {
35
+ id: product.id,
36
+ name: product.name,
37
+ description: product.description || null,
38
+ prices: (product.prices || []).map((price) => ({
39
+ id: price.id,
40
+ amount: price.priceAmount || 0,
41
+ currency: price.priceCurrency || 'usd',
42
+ recurring_interval: (price.recurringInterval as 'month' | 'year') || 'month',
43
+ })),
44
+ };
45
+ } catch (error) {
46
+ console.error('Error fetching product from Polar:', error);
47
+ return null;
48
+ }
49
+ }
@@ -0,0 +1,148 @@
1
+ import { auth } from "@/lib/auth";
2
+ import { db } from "@/db/drizzle";
3
+ import { subscription } from "@/db/schema";
4
+ import { eq } from "drizzle-orm";
5
+ import { headers } from "next/headers";
6
+
7
+ export type SubscriptionDetails = {
8
+ id: string;
9
+ productId: string;
10
+ status: string;
11
+ amount: number;
12
+ currency: string;
13
+ recurringInterval: string;
14
+ currentPeriodStart: Date;
15
+ currentPeriodEnd: Date;
16
+ cancelAtPeriodEnd: boolean;
17
+ canceledAt: Date | null;
18
+ organizationId: string | null;
19
+ };
20
+
21
+ export type SubscriptionDetailsResult = {
22
+ hasSubscription: boolean;
23
+ subscription?: SubscriptionDetails;
24
+ error?: string;
25
+ errorType?: "CANCELED" | "EXPIRED" | "GENERAL";
26
+ };
27
+
28
+ export async function getSubscriptionDetails(): Promise<SubscriptionDetailsResult> {
29
+ try {
30
+ const session = await auth.api.getSession({
31
+ headers: await headers(),
32
+ });
33
+
34
+ if (!session?.user?.id) {
35
+ return { hasSubscription: false };
36
+ }
37
+
38
+ const userSubscriptions = await db
39
+ .select()
40
+ .from(subscription)
41
+ .where(eq(subscription.userId, session.user.id));
42
+
43
+ if (!userSubscriptions.length) {
44
+ return { hasSubscription: false };
45
+ }
46
+
47
+ // Get the most recent active subscription
48
+ const activeSubscription = userSubscriptions
49
+ .filter((sub) => sub.status === "active")
50
+ .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0];
51
+
52
+ if (!activeSubscription) {
53
+ // Check for canceled or expired subscriptions
54
+ const latestSubscription = userSubscriptions
55
+ .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0];
56
+
57
+ if (latestSubscription) {
58
+ const now = new Date();
59
+ const isExpired = new Date(latestSubscription.currentPeriodEnd) < now;
60
+ const isCanceled = latestSubscription.status === "canceled";
61
+
62
+ return {
63
+ hasSubscription: true,
64
+ subscription: {
65
+ id: latestSubscription.id,
66
+ productId: latestSubscription.productId,
67
+ status: latestSubscription.status,
68
+ amount: latestSubscription.amount,
69
+ currency: latestSubscription.currency,
70
+ recurringInterval: latestSubscription.recurringInterval,
71
+ currentPeriodStart: latestSubscription.currentPeriodStart,
72
+ currentPeriodEnd: latestSubscription.currentPeriodEnd,
73
+ cancelAtPeriodEnd: latestSubscription.cancelAtPeriodEnd,
74
+ canceledAt: latestSubscription.canceledAt,
75
+ organizationId: null,
76
+ },
77
+ error: isCanceled ? "Subscription has been canceled" : isExpired ? "Subscription has expired" : "Subscription is not active",
78
+ errorType: isCanceled ? "CANCELED" : isExpired ? "EXPIRED" : "GENERAL",
79
+ };
80
+ }
81
+
82
+ return { hasSubscription: false };
83
+ }
84
+
85
+ return {
86
+ hasSubscription: true,
87
+ subscription: {
88
+ id: activeSubscription.id,
89
+ productId: activeSubscription.productId,
90
+ status: activeSubscription.status,
91
+ amount: activeSubscription.amount,
92
+ currency: activeSubscription.currency,
93
+ recurringInterval: activeSubscription.recurringInterval,
94
+ currentPeriodStart: activeSubscription.currentPeriodStart,
95
+ currentPeriodEnd: activeSubscription.currentPeriodEnd,
96
+ cancelAtPeriodEnd: activeSubscription.cancelAtPeriodEnd,
97
+ canceledAt: activeSubscription.canceledAt,
98
+ organizationId: null,
99
+ },
100
+ };
101
+ } catch (error) {
102
+ console.error("Error fetching subscription details:", error);
103
+ return {
104
+ hasSubscription: false,
105
+ error: "Failed to load subscription details",
106
+ errorType: "GENERAL",
107
+ };
108
+ }
109
+ }
110
+
111
+ // Simple helper to check if user has an active subscription
112
+ export async function isUserSubscribed(): Promise<boolean> {
113
+ const result = await getSubscriptionDetails();
114
+ return result.hasSubscription && result.subscription?.status === "active";
115
+ }
116
+
117
+ // Helper to check if user has access to a specific product/tier
118
+ export async function hasAccessToProduct(productId: string): Promise<boolean> {
119
+ const result = await getSubscriptionDetails();
120
+ return (
121
+ result.hasSubscription &&
122
+ result.subscription?.status === "active" &&
123
+ result.subscription?.productId === productId
124
+ );
125
+ }
126
+
127
+ // Helper to get user's current subscription status
128
+ export async function getUserSubscriptionStatus(): Promise<"active" | "canceled" | "expired" | "none"> {
129
+ const result = await getSubscriptionDetails();
130
+
131
+ if (!result.hasSubscription) {
132
+ return "none";
133
+ }
134
+
135
+ if (result.subscription?.status === "active") {
136
+ return "active";
137
+ }
138
+
139
+ if (result.errorType === "CANCELED") {
140
+ return "canceled";
141
+ }
142
+
143
+ if (result.errorType === "EXPIRED") {
144
+ return "expired";
145
+ }
146
+
147
+ return "none";
148
+ }
@@ -0,0 +1,28 @@
1
+ import {
2
+ S3Client,
3
+ PutObjectCommand,
4
+ } from "@aws-sdk/client-s3";
5
+
6
+ const r2 = new S3Client({
7
+ region: "auto", // required for R2
8
+ endpoint: `https://${process.env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
9
+ credentials: {
10
+ accessKeyId: process.env.R2_UPLOAD_IMAGE_ACCESS_KEY_ID!,
11
+ secretAccessKey: process.env.R2_UPLOAD_IMAGE_SECRET_ACCESS_KEY!,
12
+ },
13
+ });
14
+
15
+ export const uploadImageAssets = async (buffer: Buffer, key: string) => {
16
+ await r2.send(
17
+ new PutObjectCommand({
18
+ Bucket: process.env.R2_UPLOAD_IMAGE_BUCKET_NAME!,
19
+ Key: key,
20
+ Body: buffer,
21
+ ContentType: "image/*",
22
+ ACL: "public-read", // optional if bucket is public
23
+ })
24
+ );
25
+
26
+ const publicUrl = `https://pub-6f0cf05705c7412b93a792350f3b3aa5.r2.dev/${key}`;
27
+ return publicUrl;
28
+ };
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from "clsx"
2
+ import { twMerge } from "tailwind-merge"
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }
@@ -0,0 +1,30 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { getSessionCookie } from "better-auth/cookies";
3
+
4
+ export async function middleware(request: NextRequest) {
5
+ const sessionCookie = getSessionCookie(request);
6
+ const { pathname, searchParams } = request.nextUrl;
7
+
8
+ // /api/payments/webhooks is a webhook endpoint that should be accessible without authentication
9
+ if (pathname.startsWith("/api/payments/webhooks")) {
10
+ return NextResponse.next();
11
+ }
12
+
13
+ // Don't redirect from sign-in/sign-up pages to avoid redirect loops
14
+ // Users will be redirected after successful auth via callbackURL
15
+ if (["/sign-in", "/sign-up"].includes(pathname)) {
16
+ return NextResponse.next();
17
+ }
18
+
19
+ // Redirect unauthenticated users from dashboard to sign-in
20
+ if (!sessionCookie && pathname.startsWith("/dashboard")) {
21
+ const returnTo = encodeURIComponent(pathname);
22
+ return NextResponse.redirect(new URL(`/sign-in?returnTo=${returnTo}`, request.url));
23
+ }
24
+
25
+ return NextResponse.next();
26
+ }
27
+
28
+ export const config = {
29
+ matcher: ["/dashboard/:path*", "/sign-in", "/sign-up"],
30
+ };
@@ -0,0 +1,5 @@
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
3
+
4
+ // NOTE: This file should not be edited
5
+ // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -0,0 +1,27 @@
1
+ import type { NextConfig } from "next";
2
+
3
+ const nextConfig: NextConfig = {
4
+ /* config options here */
5
+ reactStrictMode: false,
6
+ typescript: {
7
+ ignoreBuildErrors: true,
8
+ },
9
+ images: {
10
+ remotePatterns: [
11
+ {
12
+ protocol: "https",
13
+ hostname: "pub-6f0cf05705c7412b93a792350f3b3aa5.r2.dev",
14
+ },
15
+ {
16
+ protocol: "https",
17
+ hostname: "jdj14ctwppwprnqu.public.blob.vercel-storage.com",
18
+ },
19
+ {
20
+ protocol: "https",
21
+ hostname: "images.unsplash.com",
22
+ },
23
+ ],
24
+ },
25
+ };
26
+
27
+ export default nextConfig;