@codaijs/keel 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 (116) hide show
  1. package/dist/__tests__/cli.test.d.ts +2 -0
  2. package/dist/__tests__/cli.test.d.ts.map +1 -0
  3. package/dist/__tests__/cli.test.js +173 -0
  4. package/dist/__tests__/cli.test.js.map +1 -0
  5. package/dist/__tests__/registry.test.d.ts +2 -0
  6. package/dist/__tests__/registry.test.d.ts.map +1 -0
  7. package/dist/__tests__/registry.test.js +86 -0
  8. package/dist/__tests__/registry.test.js.map +1 -0
  9. package/dist/__tests__/sail-installer.test.d.ts +2 -0
  10. package/dist/__tests__/sail-installer.test.d.ts.map +1 -0
  11. package/dist/__tests__/sail-installer.test.js +158 -0
  12. package/dist/__tests__/sail-installer.test.js.map +1 -0
  13. package/dist/create-runner.d.ts +11 -0
  14. package/dist/create-runner.d.ts.map +1 -0
  15. package/dist/create-runner.js +63 -0
  16. package/dist/create-runner.js.map +1 -0
  17. package/dist/create.d.ts +10 -0
  18. package/dist/create.d.ts.map +1 -0
  19. package/dist/create.js +15 -0
  20. package/dist/create.js.map +1 -0
  21. package/dist/manage.d.ts +24 -0
  22. package/dist/manage.d.ts.map +1 -0
  23. package/dist/manage.js +1461 -0
  24. package/dist/manage.js.map +1 -0
  25. package/dist/prompts.d.ts +36 -0
  26. package/dist/prompts.d.ts.map +1 -0
  27. package/dist/prompts.js +208 -0
  28. package/dist/prompts.js.map +1 -0
  29. package/dist/sail-installer.d.ts +37 -0
  30. package/dist/sail-installer.d.ts.map +1 -0
  31. package/dist/sail-installer.js +935 -0
  32. package/dist/sail-installer.js.map +1 -0
  33. package/dist/scaffold.d.ts +10 -0
  34. package/dist/scaffold.d.ts.map +1 -0
  35. package/dist/scaffold.js +297 -0
  36. package/dist/scaffold.js.map +1 -0
  37. package/package.json +57 -0
  38. package/sails/_template/addon.json +20 -0
  39. package/sails/_template/install.ts +402 -0
  40. package/sails/admin-dashboard/README.md +117 -0
  41. package/sails/admin-dashboard/addon.json +28 -0
  42. package/sails/admin-dashboard/files/backend/middleware/admin.ts +34 -0
  43. package/sails/admin-dashboard/files/backend/routes/admin.ts +243 -0
  44. package/sails/admin-dashboard/files/frontend/components/admin/StatsCard.tsx +40 -0
  45. package/sails/admin-dashboard/files/frontend/components/admin/UsersTable.tsx +240 -0
  46. package/sails/admin-dashboard/files/frontend/hooks/useAdmin.ts +149 -0
  47. package/sails/admin-dashboard/files/frontend/pages/admin/Dashboard.tsx +173 -0
  48. package/sails/admin-dashboard/files/frontend/pages/admin/UserDetail.tsx +203 -0
  49. package/sails/admin-dashboard/install.ts +305 -0
  50. package/sails/analytics/README.md +178 -0
  51. package/sails/analytics/addon.json +27 -0
  52. package/sails/analytics/files/frontend/components/AnalyticsProvider.tsx +58 -0
  53. package/sails/analytics/files/frontend/hooks/useAnalytics.ts +64 -0
  54. package/sails/analytics/files/frontend/lib/analytics.ts +103 -0
  55. package/sails/analytics/install.ts +297 -0
  56. package/sails/file-uploads/README.md +191 -0
  57. package/sails/file-uploads/addon.json +30 -0
  58. package/sails/file-uploads/files/backend/routes/files.ts +198 -0
  59. package/sails/file-uploads/files/backend/schema/files.ts +36 -0
  60. package/sails/file-uploads/files/backend/services/file-storage.ts +128 -0
  61. package/sails/file-uploads/files/frontend/components/FileList.tsx +248 -0
  62. package/sails/file-uploads/files/frontend/components/FileUploadButton.tsx +147 -0
  63. package/sails/file-uploads/files/frontend/hooks/useFileUpload.ts +106 -0
  64. package/sails/file-uploads/files/frontend/hooks/useFiles.ts +118 -0
  65. package/sails/file-uploads/files/frontend/pages/Files.tsx +37 -0
  66. package/sails/file-uploads/install.ts +466 -0
  67. package/sails/gdpr/README.md +174 -0
  68. package/sails/gdpr/addon.json +27 -0
  69. package/sails/gdpr/files/backend/routes/gdpr.ts +140 -0
  70. package/sails/gdpr/files/backend/services/gdpr.ts +293 -0
  71. package/sails/gdpr/files/frontend/components/auth/ConsentCheckboxes.tsx +97 -0
  72. package/sails/gdpr/files/frontend/components/gdpr/AccountDeletionRequest.tsx +192 -0
  73. package/sails/gdpr/files/frontend/components/gdpr/DataExportButton.tsx +75 -0
  74. package/sails/gdpr/files/frontend/pages/PrivacyPolicy.tsx +186 -0
  75. package/sails/gdpr/install.ts +756 -0
  76. package/sails/google-oauth/README.md +121 -0
  77. package/sails/google-oauth/addon.json +22 -0
  78. package/sails/google-oauth/files/GoogleButton.tsx +50 -0
  79. package/sails/google-oauth/install.ts +252 -0
  80. package/sails/i18n/README.md +193 -0
  81. package/sails/i18n/addon.json +30 -0
  82. package/sails/i18n/files/frontend/components/LanguageSwitcher.tsx +108 -0
  83. package/sails/i18n/files/frontend/hooks/useLanguage.ts +31 -0
  84. package/sails/i18n/files/frontend/lib/i18n.ts +32 -0
  85. package/sails/i18n/files/frontend/locales/de/common.json +44 -0
  86. package/sails/i18n/files/frontend/locales/en/common.json +44 -0
  87. package/sails/i18n/install.ts +407 -0
  88. package/sails/push-notifications/README.md +163 -0
  89. package/sails/push-notifications/addon.json +31 -0
  90. package/sails/push-notifications/files/backend/routes/notifications.ts +153 -0
  91. package/sails/push-notifications/files/backend/schema/notifications.ts +31 -0
  92. package/sails/push-notifications/files/backend/services/notifications.ts +117 -0
  93. package/sails/push-notifications/files/frontend/components/PushNotificationInit.tsx +12 -0
  94. package/sails/push-notifications/files/frontend/hooks/usePushNotifications.ts +154 -0
  95. package/sails/push-notifications/install.ts +384 -0
  96. package/sails/r2-storage/README.md +101 -0
  97. package/sails/r2-storage/addon.json +29 -0
  98. package/sails/r2-storage/files/backend/services/storage.ts +71 -0
  99. package/sails/r2-storage/files/frontend/components/ProfilePictureUpload.tsx +167 -0
  100. package/sails/r2-storage/install.ts +412 -0
  101. package/sails/rate-limiting/README.md +145 -0
  102. package/sails/rate-limiting/addon.json +20 -0
  103. package/sails/rate-limiting/files/backend/middleware/rate-limit-store.ts +104 -0
  104. package/sails/rate-limiting/files/backend/middleware/rate-limit.ts +137 -0
  105. package/sails/rate-limiting/install.ts +300 -0
  106. package/sails/registry.json +107 -0
  107. package/sails/stripe/README.md +214 -0
  108. package/sails/stripe/addon.json +24 -0
  109. package/sails/stripe/files/backend/routes/stripe.ts +154 -0
  110. package/sails/stripe/files/backend/schema/stripe.ts +74 -0
  111. package/sails/stripe/files/backend/services/stripe.ts +224 -0
  112. package/sails/stripe/files/frontend/components/SubscriptionStatus.tsx +135 -0
  113. package/sails/stripe/files/frontend/hooks/useSubscription.ts +86 -0
  114. package/sails/stripe/files/frontend/pages/Checkout.tsx +116 -0
  115. package/sails/stripe/files/frontend/pages/Pricing.tsx +226 -0
  116. package/sails/stripe/install.ts +378 -0
@@ -0,0 +1,154 @@
1
+ import { Router, type Request, type Response } from "express";
2
+ import { eq } from "drizzle-orm";
3
+ import { db } from "../db";
4
+ import { customers, subscriptions } from "../db/schema/stripe";
5
+ import {
6
+ createOrGetCustomer,
7
+ createCheckoutSession,
8
+ createPortalSession,
9
+ handleWebhookEvent,
10
+ } from "../services/stripe";
11
+ import { requireAuth } from "../middleware/auth";
12
+
13
+ export const stripeRouter = Router();
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // POST /create-checkout-session
17
+ // ---------------------------------------------------------------------------
18
+
19
+ stripeRouter.post(
20
+ "/create-checkout-session",
21
+ requireAuth,
22
+ async (req: Request, res: Response) => {
23
+ try {
24
+ const { priceId } = req.body;
25
+
26
+ if (!priceId || typeof priceId !== "string") {
27
+ return res.status(400).json({ error: "priceId is required" });
28
+ }
29
+
30
+ const user = req.user!;
31
+ const stripeCustomerId = await createOrGetCustomer(user.id, user.email);
32
+
33
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
34
+ const session = await createCheckoutSession(
35
+ stripeCustomerId,
36
+ priceId,
37
+ `${baseUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
38
+ `${baseUrl}/checkout/cancel`
39
+ );
40
+
41
+ return res.json({ url: session.url });
42
+ } catch (error) {
43
+ console.error("Error creating checkout session:", error);
44
+ return res.status(500).json({ error: "Failed to create checkout session" });
45
+ }
46
+ }
47
+ );
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // POST /create-portal-session
51
+ // ---------------------------------------------------------------------------
52
+
53
+ stripeRouter.post(
54
+ "/create-portal-session",
55
+ requireAuth,
56
+ async (req: Request, res: Response) => {
57
+ try {
58
+ const user = req.user!;
59
+
60
+ const customer = await db.query.customers.findFirst({
61
+ where: eq(customers.userId, user.id),
62
+ });
63
+
64
+ if (!customer) {
65
+ return res.status(404).json({ error: "No subscription found" });
66
+ }
67
+
68
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
69
+ const session = await createPortalSession(
70
+ customer.stripeCustomerId,
71
+ `${baseUrl}/settings`
72
+ );
73
+
74
+ return res.json({ url: session.url });
75
+ } catch (error) {
76
+ console.error("Error creating portal session:", error);
77
+ return res.status(500).json({ error: "Failed to create portal session" });
78
+ }
79
+ }
80
+ );
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // POST /webhook
84
+ // ---------------------------------------------------------------------------
85
+
86
+ /**
87
+ * Stripe webhook endpoint.
88
+ *
89
+ * IMPORTANT: This route must receive the raw request body (not parsed as JSON).
90
+ * The Express app must be configured with:
91
+ *
92
+ * app.use("/api/stripe/webhook", express.raw({ type: "application/json" }));
93
+ *
94
+ * This should be placed BEFORE the general express.json() middleware.
95
+ */
96
+ stripeRouter.post("/webhook", async (req: Request, res: Response) => {
97
+ const signature = req.headers["stripe-signature"];
98
+
99
+ if (!signature || typeof signature !== "string") {
100
+ return res.status(400).json({ error: "Missing stripe-signature header" });
101
+ }
102
+
103
+ try {
104
+ await handleWebhookEvent(req.body, signature);
105
+ return res.json({ received: true });
106
+ } catch (error) {
107
+ console.error("Webhook error:", error);
108
+ return res
109
+ .status(400)
110
+ .json({ error: error instanceof Error ? error.message : "Webhook failed" });
111
+ }
112
+ });
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // GET /subscription
116
+ // ---------------------------------------------------------------------------
117
+
118
+ stripeRouter.get(
119
+ "/subscription",
120
+ requireAuth,
121
+ async (req: Request, res: Response) => {
122
+ try {
123
+ const user = req.user!;
124
+
125
+ const customer = await db.query.customers.findFirst({
126
+ where: eq(customers.userId, user.id),
127
+ });
128
+
129
+ if (!customer) {
130
+ return res.json({ subscription: null });
131
+ }
132
+
133
+ const subscription = await db.query.subscriptions.findFirst({
134
+ where: eq(subscriptions.customerId, customer.id),
135
+ orderBy: (sub, { desc }) => [desc(sub.createdAt)],
136
+ });
137
+
138
+ return res.json({
139
+ subscription: subscription
140
+ ? {
141
+ id: subscription.id,
142
+ status: subscription.status,
143
+ priceId: subscription.stripePriceId,
144
+ currentPeriodEnd: subscription.currentPeriodEnd,
145
+ cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
146
+ }
147
+ : null,
148
+ });
149
+ } catch (error) {
150
+ console.error("Error fetching subscription:", error);
151
+ return res.status(500).json({ error: "Failed to fetch subscription" });
152
+ }
153
+ }
154
+ );
@@ -0,0 +1,74 @@
1
+ import {
2
+ pgTable,
3
+ text,
4
+ timestamp,
5
+ boolean,
6
+ uuid,
7
+ } from "drizzle-orm/pg-core";
8
+ import { relations } from "drizzle-orm";
9
+ import { users } from "./users";
10
+
11
+ /**
12
+ * Stripe customers table.
13
+ *
14
+ * Maps internal user IDs to Stripe customer IDs. Each user has at most one
15
+ * Stripe customer record, created on their first checkout session.
16
+ */
17
+ export const customers = pgTable("stripe_customers", {
18
+ id: uuid("id").defaultRandom().primaryKey(),
19
+ userId: text("user_id")
20
+ .notNull()
21
+ .unique()
22
+ .references(() => users.id, { onDelete: "cascade" }),
23
+ stripeCustomerId: text("stripe_customer_id").notNull().unique(),
24
+ createdAt: timestamp("created_at", { withTimezone: true })
25
+ .notNull()
26
+ .defaultNow(),
27
+ updatedAt: timestamp("updated_at", { withTimezone: true })
28
+ .notNull()
29
+ .defaultNow()
30
+ .$onUpdate(() => new Date()),
31
+ });
32
+
33
+ export const customersRelations = relations(customers, ({ one, many }) => ({
34
+ user: one(users, {
35
+ fields: [customers.userId],
36
+ references: [users.id],
37
+ }),
38
+ subscriptions: many(subscriptions),
39
+ }));
40
+
41
+ /**
42
+ * Stripe subscriptions table.
43
+ *
44
+ * Tracks the state of each Stripe subscription. Updated via webhooks whenever
45
+ * Stripe fires subscription lifecycle events.
46
+ */
47
+ export const subscriptions = pgTable("stripe_subscriptions", {
48
+ id: uuid("id").defaultRandom().primaryKey(),
49
+ customerId: uuid("customer_id")
50
+ .notNull()
51
+ .references(() => customers.id, { onDelete: "cascade" }),
52
+ stripeSubscriptionId: text("stripe_subscription_id").notNull().unique(),
53
+ status: text("status").notNull().default("incomplete"),
54
+ stripePriceId: text("stripe_price_id").notNull(),
55
+ currentPeriodStart: timestamp("current_period_start", {
56
+ withTimezone: true,
57
+ }),
58
+ currentPeriodEnd: timestamp("current_period_end", { withTimezone: true }),
59
+ cancelAtPeriodEnd: boolean("cancel_at_period_end").notNull().default(false),
60
+ createdAt: timestamp("created_at", { withTimezone: true })
61
+ .notNull()
62
+ .defaultNow(),
63
+ updatedAt: timestamp("updated_at", { withTimezone: true })
64
+ .notNull()
65
+ .defaultNow()
66
+ .$onUpdate(() => new Date()),
67
+ });
68
+
69
+ export const subscriptionsRelations = relations(subscriptions, ({ one }) => ({
70
+ customer: one(customers, {
71
+ fields: [subscriptions.customerId],
72
+ references: [customers.id],
73
+ }),
74
+ }));
@@ -0,0 +1,224 @@
1
+ import Stripe from "stripe";
2
+ import { eq } from "drizzle-orm";
3
+ import { db } from "../db";
4
+ import { customers, subscriptions } from "../db/schema/stripe";
5
+ import { env } from "../env";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Stripe SDK instance
9
+ // ---------------------------------------------------------------------------
10
+
11
+ export const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
12
+ apiVersion: "2024-12-18.acacia",
13
+ typescript: true,
14
+ });
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Customer management
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /**
21
+ * Retrieve the existing Stripe customer for a user, or create one.
22
+ */
23
+ export async function createOrGetCustomer(
24
+ userId: string,
25
+ email: string
26
+ ): Promise<string> {
27
+ // Check for existing customer record
28
+ const existing = await db.query.customers.findFirst({
29
+ where: eq(customers.userId, userId),
30
+ });
31
+
32
+ if (existing) {
33
+ return existing.stripeCustomerId;
34
+ }
35
+
36
+ // Create a new Stripe customer
37
+ const stripeCustomer = await stripe.customers.create({
38
+ email,
39
+ metadata: { userId },
40
+ });
41
+
42
+ // Persist the mapping
43
+ await db.insert(customers).values({
44
+ userId,
45
+ stripeCustomerId: stripeCustomer.id,
46
+ });
47
+
48
+ return stripeCustomer.id;
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Checkout session
53
+ // ---------------------------------------------------------------------------
54
+
55
+ /**
56
+ * Create a Stripe Checkout session for a subscription.
57
+ */
58
+ export async function createCheckoutSession(
59
+ customerId: string,
60
+ priceId: string,
61
+ successUrl: string,
62
+ cancelUrl: string
63
+ ): Promise<Stripe.Checkout.Session> {
64
+ return stripe.checkout.sessions.create({
65
+ customer: customerId,
66
+ mode: "subscription",
67
+ payment_method_types: ["card"],
68
+ line_items: [{ price: priceId, quantity: 1 }],
69
+ success_url: successUrl,
70
+ cancel_url: cancelUrl,
71
+ subscription_data: {
72
+ metadata: { customerId },
73
+ },
74
+ });
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Customer portal session
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /**
82
+ * Create a Stripe Customer Portal session so the user can manage their
83
+ * subscription, update payment methods, or cancel.
84
+ */
85
+ export async function createPortalSession(
86
+ customerId: string,
87
+ returnUrl: string
88
+ ): Promise<Stripe.BillingPortal.Session> {
89
+ return stripe.billingPortal.sessions.create({
90
+ customer: customerId,
91
+ return_url: returnUrl,
92
+ });
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Webhook event handler
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /**
100
+ * Verify and process an incoming Stripe webhook event.
101
+ */
102
+ export async function handleWebhookEvent(
103
+ payload: string | Buffer,
104
+ signature: string
105
+ ): Promise<void> {
106
+ const event = stripe.webhooks.constructEvent(
107
+ payload,
108
+ signature,
109
+ env.STRIPE_WEBHOOK_SECRET
110
+ );
111
+
112
+ switch (event.type) {
113
+ case "checkout.session.completed": {
114
+ const session = event.data.object as Stripe.Checkout.Session;
115
+ await handleCheckoutCompleted(session);
116
+ break;
117
+ }
118
+
119
+ case "customer.subscription.updated": {
120
+ const subscription = event.data.object as Stripe.Subscription;
121
+ await handleSubscriptionUpdated(subscription);
122
+ break;
123
+ }
124
+
125
+ case "customer.subscription.deleted": {
126
+ const subscription = event.data.object as Stripe.Subscription;
127
+ await handleSubscriptionDeleted(subscription);
128
+ break;
129
+ }
130
+
131
+ default:
132
+ console.log(`Unhandled Stripe event type: ${event.type}`);
133
+ }
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Internal event handlers
138
+ // ---------------------------------------------------------------------------
139
+
140
+ async function handleCheckoutCompleted(
141
+ session: Stripe.Checkout.Session
142
+ ): Promise<void> {
143
+ if (session.mode !== "subscription" || !session.subscription) return;
144
+
145
+ const stripeSubscriptionId =
146
+ typeof session.subscription === "string"
147
+ ? session.subscription
148
+ : session.subscription.id;
149
+
150
+ const stripeCustomerId =
151
+ typeof session.customer === "string"
152
+ ? session.customer
153
+ : session.customer?.id;
154
+
155
+ if (!stripeCustomerId) return;
156
+
157
+ // Look up the internal customer record
158
+ const customer = await db.query.customers.findFirst({
159
+ where: eq(customers.stripeCustomerId, stripeCustomerId),
160
+ });
161
+
162
+ if (!customer) {
163
+ console.error(
164
+ `No customer record found for Stripe customer ${stripeCustomerId}`
165
+ );
166
+ return;
167
+ }
168
+
169
+ // Fetch the full subscription from Stripe to get price info
170
+ const stripeSub = await stripe.subscriptions.retrieve(stripeSubscriptionId);
171
+ const priceId = stripeSub.items.data[0]?.price.id ?? "";
172
+
173
+ // Upsert the subscription record
174
+ await db
175
+ .insert(subscriptions)
176
+ .values({
177
+ customerId: customer.id,
178
+ stripeSubscriptionId,
179
+ status: stripeSub.status,
180
+ stripePriceId: priceId,
181
+ currentPeriodStart: new Date(stripeSub.current_period_start * 1000),
182
+ currentPeriodEnd: new Date(stripeSub.current_period_end * 1000),
183
+ cancelAtPeriodEnd: stripeSub.cancel_at_period_end,
184
+ })
185
+ .onConflictDoUpdate({
186
+ target: subscriptions.stripeSubscriptionId,
187
+ set: {
188
+ status: stripeSub.status,
189
+ stripePriceId: priceId,
190
+ currentPeriodStart: new Date(stripeSub.current_period_start * 1000),
191
+ currentPeriodEnd: new Date(stripeSub.current_period_end * 1000),
192
+ cancelAtPeriodEnd: stripeSub.cancel_at_period_end,
193
+ },
194
+ });
195
+ }
196
+
197
+ async function handleSubscriptionUpdated(
198
+ subscription: Stripe.Subscription
199
+ ): Promise<void> {
200
+ const priceId = subscription.items.data[0]?.price.id ?? "";
201
+
202
+ await db
203
+ .update(subscriptions)
204
+ .set({
205
+ status: subscription.status,
206
+ stripePriceId: priceId,
207
+ currentPeriodStart: new Date(subscription.current_period_start * 1000),
208
+ currentPeriodEnd: new Date(subscription.current_period_end * 1000),
209
+ cancelAtPeriodEnd: subscription.cancel_at_period_end,
210
+ })
211
+ .where(eq(subscriptions.stripeSubscriptionId, subscription.id));
212
+ }
213
+
214
+ async function handleSubscriptionDeleted(
215
+ subscription: Stripe.Subscription
216
+ ): Promise<void> {
217
+ await db
218
+ .update(subscriptions)
219
+ .set({
220
+ status: "canceled",
221
+ cancelAtPeriodEnd: false,
222
+ })
223
+ .where(eq(subscriptions.stripeSubscriptionId, subscription.id));
224
+ }
@@ -0,0 +1,135 @@
1
+ import { useSubscription } from "@/hooks/useSubscription";
2
+
3
+ /**
4
+ * Displays the current user's subscription status with options to manage,
5
+ * upgrade, or subscribe.
6
+ */
7
+ export function SubscriptionStatus() {
8
+ const { subscription, isLoading, error } = useSubscription();
9
+
10
+ const handleManage = async () => {
11
+ try {
12
+ const response = await fetch("/api/stripe/create-portal-session", {
13
+ method: "POST",
14
+ headers: { "Content-Type": "application/json" },
15
+ credentials: "include",
16
+ });
17
+ const data = await response.json();
18
+ if (data.url) {
19
+ window.location.href = data.url;
20
+ }
21
+ } catch (err) {
22
+ console.error("Failed to open customer portal:", err);
23
+ }
24
+ };
25
+
26
+ if (isLoading) {
27
+ return (
28
+ <div className="animate-pulse rounded-lg border border-gray-200 p-6 dark:border-gray-700">
29
+ <div className="h-4 w-32 rounded bg-gray-200 dark:bg-gray-700" />
30
+ <div className="mt-3 h-3 w-48 rounded bg-gray-200 dark:bg-gray-700" />
31
+ </div>
32
+ );
33
+ }
34
+
35
+ if (error) {
36
+ return (
37
+ <div className="rounded-lg border border-red-200 bg-red-50 p-6 dark:border-red-800 dark:bg-red-900/20">
38
+ <p className="text-sm text-red-600 dark:text-red-400">
39
+ Failed to load subscription status.
40
+ </p>
41
+ </div>
42
+ );
43
+ }
44
+
45
+ // No subscription — show upgrade prompt
46
+ if (!subscription) {
47
+ return (
48
+ <div className="rounded-lg border border-gray-200 p-6 dark:border-gray-700">
49
+ <div className="flex items-center justify-between">
50
+ <div>
51
+ <h3 className="text-sm font-semibold text-gray-900 dark:text-white">
52
+ Free Plan
53
+ </h3>
54
+ <p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
55
+ Upgrade to unlock premium features.
56
+ </p>
57
+ </div>
58
+ <a
59
+ href="/pricing"
60
+ className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700"
61
+ >
62
+ Upgrade
63
+ </a>
64
+ </div>
65
+ </div>
66
+ );
67
+ }
68
+
69
+ // Status badge colors
70
+ const statusColors: Record<string, string> = {
71
+ active: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400",
72
+ trialing: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400",
73
+ past_due: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400",
74
+ canceled: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400",
75
+ incomplete: "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400",
76
+ };
77
+
78
+ const statusLabel: Record<string, string> = {
79
+ active: "Active",
80
+ trialing: "Trial",
81
+ past_due: "Past Due",
82
+ canceled: "Canceled",
83
+ incomplete: "Incomplete",
84
+ };
85
+
86
+ const periodEnd = subscription.currentPeriodEnd
87
+ ? new Date(subscription.currentPeriodEnd).toLocaleDateString(undefined, {
88
+ year: "numeric",
89
+ month: "long",
90
+ day: "numeric",
91
+ })
92
+ : null;
93
+
94
+ return (
95
+ <div className="rounded-lg border border-gray-200 p-6 dark:border-gray-700">
96
+ <div className="flex items-start justify-between">
97
+ <div>
98
+ <div className="flex items-center gap-2">
99
+ <h3 className="text-sm font-semibold text-gray-900 dark:text-white">
100
+ Subscription
101
+ </h3>
102
+ <span
103
+ className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${
104
+ statusColors[subscription.status] ?? statusColors.incomplete
105
+ }`}
106
+ >
107
+ {statusLabel[subscription.status] ?? subscription.status}
108
+ </span>
109
+ </div>
110
+
111
+ {subscription.cancelAtPeriodEnd && (
112
+ <p className="mt-1 text-sm text-yellow-600 dark:text-yellow-400">
113
+ Cancels at end of billing period
114
+ </p>
115
+ )}
116
+
117
+ {periodEnd && (
118
+ <p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
119
+ {subscription.cancelAtPeriodEnd
120
+ ? `Access until ${periodEnd}`
121
+ : `Renews on ${periodEnd}`}
122
+ </p>
123
+ )}
124
+ </div>
125
+
126
+ <button
127
+ onClick={handleManage}
128
+ className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800"
129
+ >
130
+ Manage
131
+ </button>
132
+ </div>
133
+ </div>
134
+ );
135
+ }
@@ -0,0 +1,86 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+
3
+ interface Subscription {
4
+ id: string;
5
+ status: string;
6
+ priceId: string;
7
+ currentPeriodEnd: string | null;
8
+ cancelAtPeriodEnd: boolean;
9
+ }
10
+
11
+ interface UseSubscriptionResult {
12
+ subscription: Subscription | null;
13
+ isLoading: boolean;
14
+ error: Error | null;
15
+ refetch: () => Promise<void>;
16
+ }
17
+
18
+ /**
19
+ * Hook to fetch and manage the current user's subscription state.
20
+ *
21
+ * Automatically fetches on mount. Call `refetch()` to manually refresh
22
+ * (e.g., after returning from Stripe Checkout or the customer portal).
23
+ */
24
+ export function useSubscription(): UseSubscriptionResult {
25
+ const [subscription, setSubscription] = useState<Subscription | null>(null);
26
+ const [isLoading, setIsLoading] = useState(true);
27
+ const [error, setError] = useState<Error | null>(null);
28
+
29
+ const fetchSubscription = useCallback(async () => {
30
+ setIsLoading(true);
31
+ setError(null);
32
+
33
+ try {
34
+ const response = await fetch("/api/stripe/subscription", {
35
+ credentials: "include",
36
+ });
37
+
38
+ if (!response.ok) {
39
+ throw new Error(`Failed to fetch subscription: ${response.statusText}`);
40
+ }
41
+
42
+ const data = await response.json();
43
+ setSubscription(data.subscription ?? null);
44
+ } catch (err) {
45
+ setError(
46
+ err instanceof Error ? err : new Error("Unknown error fetching subscription")
47
+ );
48
+ setSubscription(null);
49
+ } finally {
50
+ setIsLoading(false);
51
+ }
52
+ }, []);
53
+
54
+ useEffect(() => {
55
+ fetchSubscription();
56
+ }, [fetchSubscription]);
57
+
58
+ return {
59
+ subscription,
60
+ isLoading,
61
+ error,
62
+ refetch: fetchSubscription,
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Helper to check if a subscription is in an active/usable state.
68
+ */
69
+ export function isSubscriptionActive(
70
+ subscription: Subscription | null
71
+ ): boolean {
72
+ if (!subscription) return false;
73
+ return ["active", "trialing"].includes(subscription.status);
74
+ }
75
+
76
+ /**
77
+ * Helper to check if the user has a specific plan by price ID.
78
+ */
79
+ export function hasPlans(
80
+ subscription: Subscription | null,
81
+ priceIds: string[]
82
+ ): boolean {
83
+ if (!subscription) return false;
84
+ if (!isSubscriptionActive(subscription)) return false;
85
+ return priceIds.includes(subscription.priceId);
86
+ }