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.
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1366 -0
- package/docs-template/README.md +255 -0
- package/docs-template/[slug]/[subslug]/page.tsx +1242 -0
- package/docs-template/[slug]/page.tsx +422 -0
- package/docs-template/api/page.tsx +47 -0
- package/docs-template/components/docs/docs-category-page.tsx +162 -0
- package/docs-template/components/docs/docs-code-card.tsx +135 -0
- package/docs-template/components/docs/docs-header.tsx +69 -0
- package/docs-template/components/docs/docs-nav.ts +95 -0
- package/docs-template/components/docs/docs-sidebar.tsx +112 -0
- package/docs-template/components/docs/docs-toc.tsx +38 -0
- package/docs-template/components/ui/badge.tsx +47 -0
- package/docs-template/components/ui/button.tsx +60 -0
- package/docs-template/components/ui/card.tsx +93 -0
- package/docs-template/components/ui/sheet.tsx +140 -0
- package/docs-template/documentation/page.tsx +80 -0
- package/docs-template/layout.tsx +27 -0
- package/docs-template/lib/utils.ts +7 -0
- package/docs-template/page.tsx +360 -0
- package/package.json +66 -0
- package/template/.env.example +45 -0
- package/template/README.md +239 -0
- package/template/app/api/auth/[...all]/route.ts +4 -0
- package/template/app/api/chat/route.ts +16 -0
- package/template/app/api/subscription/route.ts +25 -0
- package/template/app/api/upload-image/route.ts +64 -0
- package/template/app/blog/[slug]/page.tsx +314 -0
- package/template/app/blog/page.tsx +107 -0
- package/template/app/dashboard/_components/chart-interactive.tsx +289 -0
- package/template/app/dashboard/_components/chatbot.tsx +39 -0
- package/template/app/dashboard/_components/mode-toggle.tsx +46 -0
- package/template/app/dashboard/_components/navbar.tsx +84 -0
- package/template/app/dashboard/_components/section-cards.tsx +102 -0
- package/template/app/dashboard/_components/sidebar.tsx +90 -0
- package/template/app/dashboard/_components/subscribe-button.tsx +49 -0
- package/template/app/dashboard/billing/page.tsx +277 -0
- package/template/app/dashboard/chat/page.tsx +73 -0
- package/template/app/dashboard/cli/page.tsx +260 -0
- package/template/app/dashboard/layout.tsx +24 -0
- package/template/app/dashboard/page.tsx +216 -0
- package/template/app/dashboard/payment/_components/manage-subscription.tsx +22 -0
- package/template/app/dashboard/payment/page.tsx +126 -0
- package/template/app/dashboard/settings/page.tsx +613 -0
- package/template/app/dashboard/upload/page.tsx +324 -0
- package/template/app/error.tsx +78 -0
- package/template/app/favicon.ico +0 -0
- package/template/app/globals.css +126 -0
- package/template/app/layout.tsx +135 -0
- package/template/app/not-found.tsx +45 -0
- package/template/app/page.tsx +28 -0
- package/template/app/pricing/_component/pricing-table.tsx +276 -0
- package/template/app/pricing/page.tsx +23 -0
- package/template/app/privacy-policy/page.tsx +280 -0
- package/template/app/robots.txt +12 -0
- package/template/app/sign-in/page.tsx +228 -0
- package/template/app/sign-up/page.tsx +243 -0
- package/template/app/sitemap.ts +62 -0
- package/template/app/success/page.tsx +123 -0
- package/template/app/terms-of-service/page.tsx +212 -0
- package/template/auth-schema.ts +47 -0
- package/template/components/homepage/cli-workflow-section.tsx +138 -0
- package/template/components/homepage/features-section.tsx +150 -0
- package/template/components/homepage/footer.tsx +53 -0
- package/template/components/homepage/hero-section.tsx +112 -0
- package/template/components/homepage/integrations.tsx +124 -0
- package/template/components/homepage/navigation.tsx +116 -0
- package/template/components/homepage/news-section.tsx +82 -0
- package/template/components/homepage/testimonials-section.tsx +34 -0
- package/template/components/logos/BetterAuth.tsx +21 -0
- package/template/components/logos/NeonPostgres.tsx +41 -0
- package/template/components/logos/Nextjs.tsx +72 -0
- package/template/components/logos/Polar.tsx +7 -0
- package/template/components/logos/TailwindCSS.tsx +27 -0
- package/template/components/logos/index.ts +6 -0
- package/template/components/logos/shadcnui.tsx +8 -0
- package/template/components/provider.tsx +8 -0
- package/template/components/ui/avatar.tsx +53 -0
- package/template/components/ui/badge.tsx +46 -0
- package/template/components/ui/button.tsx +59 -0
- package/template/components/ui/card.tsx +92 -0
- package/template/components/ui/chart.tsx +353 -0
- package/template/components/ui/checkbox.tsx +32 -0
- package/template/components/ui/dialog.tsx +135 -0
- package/template/components/ui/dropdown-menu.tsx +257 -0
- package/template/components/ui/form.tsx +167 -0
- package/template/components/ui/input.tsx +21 -0
- package/template/components/ui/label.tsx +24 -0
- package/template/components/ui/progress.tsx +31 -0
- package/template/components/ui/resizable.tsx +56 -0
- package/template/components/ui/select.tsx +185 -0
- package/template/components/ui/separator.tsx +28 -0
- package/template/components/ui/sheet.tsx +139 -0
- package/template/components/ui/skeleton.tsx +13 -0
- package/template/components/ui/sonner.tsx +25 -0
- package/template/components/ui/switch.tsx +31 -0
- package/template/components/ui/tabs.tsx +66 -0
- package/template/components/ui/textarea.tsx +18 -0
- package/template/components/ui/toggle-group.tsx +73 -0
- package/template/components/ui/toggle.tsx +47 -0
- package/template/components/ui/tooltip.tsx +61 -0
- package/template/components/user-profile.tsx +139 -0
- package/template/components.json +21 -0
- package/template/db/drizzle.ts +14 -0
- package/template/db/migrations/0000_worried_rawhide_kid.sql +77 -0
- package/template/db/migrations/meta/0000_snapshot.json +494 -0
- package/template/db/migrations/meta/_journal.json +13 -0
- package/template/db/schema.ts +85 -0
- package/template/drizzle.config.ts +13 -0
- package/template/emails/components/layout.tsx +181 -0
- package/template/emails/password-reset.tsx +67 -0
- package/template/emails/payment-failed.tsx +167 -0
- package/template/emails/subscription-confirmation.tsx +129 -0
- package/template/emails/welcome.tsx +100 -0
- package/template/eslint.config.mjs +16 -0
- package/template/hooks/use-mobile.ts +21 -0
- package/template/lib/auth-client.ts +8 -0
- package/template/lib/auth.ts +276 -0
- package/template/lib/email.ts +118 -0
- package/template/lib/polar-products.ts +49 -0
- package/template/lib/subscription.ts +148 -0
- package/template/lib/upload-image.ts +28 -0
- package/template/lib/utils.ts +6 -0
- package/template/middleware.ts +30 -0
- package/template/next-env.d.ts +5 -0
- package/template/next.config.ts +27 -0
- package/template/package.json +99 -0
- package/template/postcss.config.mjs +5 -0
- package/template/public/add.png +0 -0
- package/template/public/favicon.svg +4 -0
- package/template/public/file.svg +1 -0
- package/template/public/globe.svg +1 -0
- package/template/public/iphone.png +0 -0
- package/template/public/logo.png +0 -0
- package/template/public/next.svg +1 -0
- package/template/public/polar-sh.svg +1 -0
- package/template/public/shadcn-ui.svg +1 -0
- package/template/public/site.webmanifest +21 -0
- package/template/public/vercel.svg +1 -0
- package/template/public/window.svg +1 -0
- package/template/tailwind.config.ts +89 -0
- package/template/template.config.json +138 -0
- 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,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,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;
|