@getjack/jack 0.1.19 → 0.1.22
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/package.json +5 -2
- package/src/commands/down.ts +11 -1
- package/src/commands/init.ts +19 -6
- package/src/commands/new.ts +56 -4
- package/src/commands/publish.ts +1 -1
- package/src/lib/agents.ts +3 -1
- package/src/lib/auth/ensure-auth.test.ts +3 -3
- package/src/lib/control-plane.ts +15 -1
- package/src/lib/deploy-upload.ts +26 -1
- package/src/lib/hooks.ts +232 -1
- package/src/lib/managed-deploy.ts +13 -6
- package/src/lib/managed-down.ts +66 -45
- package/src/lib/progress.ts +76 -5
- package/src/lib/project-list.ts +6 -1
- package/src/lib/project-operations.ts +21 -31
- package/src/lib/project-resolver.ts +1 -1
- package/src/lib/zip-packager.ts +36 -7
- package/src/templates/index.ts +1 -1
- package/src/templates/types.ts +16 -0
- package/templates/CLAUDE.md +172 -5
- package/templates/miniapp/.jack.json +1 -3
- package/templates/saas/.jack.json +154 -0
- package/templates/saas/AGENTS.md +333 -0
- package/templates/saas/bun.lock +925 -0
- package/templates/saas/components.json +21 -0
- package/templates/saas/index.html +12 -0
- package/templates/saas/package.json +75 -0
- package/templates/saas/public/icon.png +0 -0
- package/templates/saas/public/og.png +0 -0
- package/templates/saas/schema.sql +73 -0
- package/templates/saas/src/auth.ts +77 -0
- package/templates/saas/src/client/App.tsx +63 -0
- package/templates/saas/src/client/components/ProtectedRoute.tsx +29 -0
- package/templates/saas/src/client/components/ThemeToggle.tsx +32 -0
- package/templates/saas/src/client/components/ui/accordion.tsx +62 -0
- package/templates/saas/src/client/components/ui/alert-dialog.tsx +133 -0
- package/templates/saas/src/client/components/ui/alert.tsx +60 -0
- package/templates/saas/src/client/components/ui/aspect-ratio.tsx +9 -0
- package/templates/saas/src/client/components/ui/avatar.tsx +39 -0
- package/templates/saas/src/client/components/ui/badge.tsx +39 -0
- package/templates/saas/src/client/components/ui/breadcrumb.tsx +102 -0
- package/templates/saas/src/client/components/ui/button-group.tsx +78 -0
- package/templates/saas/src/client/components/ui/button.tsx +60 -0
- package/templates/saas/src/client/components/ui/card.tsx +75 -0
- package/templates/saas/src/client/components/ui/carousel.tsx +228 -0
- package/templates/saas/src/client/components/ui/chart.tsx +326 -0
- package/templates/saas/src/client/components/ui/checkbox.tsx +29 -0
- package/templates/saas/src/client/components/ui/collapsible.tsx +19 -0
- package/templates/saas/src/client/components/ui/command.tsx +159 -0
- package/templates/saas/src/client/components/ui/context-menu.tsx +224 -0
- package/templates/saas/src/client/components/ui/dialog.tsx +127 -0
- package/templates/saas/src/client/components/ui/drawer.tsx +124 -0
- package/templates/saas/src/client/components/ui/dropdown-menu.tsx +226 -0
- package/templates/saas/src/client/components/ui/empty.tsx +94 -0
- package/templates/saas/src/client/components/ui/field.tsx +232 -0
- package/templates/saas/src/client/components/ui/form.tsx +152 -0
- package/templates/saas/src/client/components/ui/hover-card.tsx +38 -0
- package/templates/saas/src/client/components/ui/input-group.tsx +158 -0
- package/templates/saas/src/client/components/ui/input-otp.tsx +68 -0
- package/templates/saas/src/client/components/ui/input.tsx +21 -0
- package/templates/saas/src/client/components/ui/item.tsx +172 -0
- package/templates/saas/src/client/components/ui/kbd.tsx +28 -0
- package/templates/saas/src/client/components/ui/label.tsx +21 -0
- package/templates/saas/src/client/components/ui/menubar.tsx +250 -0
- package/templates/saas/src/client/components/ui/navigation-menu.tsx +161 -0
- package/templates/saas/src/client/components/ui/pagination.tsx +106 -0
- package/templates/saas/src/client/components/ui/popover.tsx +42 -0
- package/templates/saas/src/client/components/ui/progress.tsx +26 -0
- package/templates/saas/src/client/components/ui/radio-group.tsx +45 -0
- package/templates/saas/src/client/components/ui/resizable.tsx +46 -0
- package/templates/saas/src/client/components/ui/scroll-area.tsx +56 -0
- package/templates/saas/src/client/components/ui/select.tsx +173 -0
- package/templates/saas/src/client/components/ui/separator.tsx +28 -0
- package/templates/saas/src/client/components/ui/sheet.tsx +128 -0
- package/templates/saas/src/client/components/ui/sidebar.tsx +694 -0
- package/templates/saas/src/client/components/ui/skeleton.tsx +13 -0
- package/templates/saas/src/client/components/ui/slider.tsx +58 -0
- package/templates/saas/src/client/components/ui/sonner.tsx +38 -0
- package/templates/saas/src/client/components/ui/spinner.tsx +16 -0
- package/templates/saas/src/client/components/ui/switch.tsx +28 -0
- package/templates/saas/src/client/components/ui/table.tsx +90 -0
- package/templates/saas/src/client/components/ui/tabs.tsx +54 -0
- package/templates/saas/src/client/components/ui/textarea.tsx +18 -0
- package/templates/saas/src/client/components/ui/toggle-group.tsx +80 -0
- package/templates/saas/src/client/components/ui/toggle.tsx +44 -0
- package/templates/saas/src/client/components/ui/tooltip.tsx +57 -0
- package/templates/saas/src/client/hooks/use-mobile.ts +19 -0
- package/templates/saas/src/client/hooks/useAuth.ts +14 -0
- package/templates/saas/src/client/hooks/useSubscription.ts +86 -0
- package/templates/saas/src/client/index.css +165 -0
- package/templates/saas/src/client/lib/auth-client.ts +7 -0
- package/templates/saas/src/client/lib/plans.ts +82 -0
- package/templates/saas/src/client/lib/utils.ts +6 -0
- package/templates/saas/src/client/main.tsx +15 -0
- package/templates/saas/src/client/pages/DashboardPage.tsx +394 -0
- package/templates/saas/src/client/pages/ForgotPasswordPage.tsx +153 -0
- package/templates/saas/src/client/pages/HomePage.tsx +285 -0
- package/templates/saas/src/client/pages/LoginPage.tsx +169 -0
- package/templates/saas/src/client/pages/PricingPage.tsx +467 -0
- package/templates/saas/src/client/pages/ResetPasswordPage.tsx +200 -0
- package/templates/saas/src/client/pages/SignupPage.tsx +192 -0
- package/templates/saas/src/index.ts +208 -0
- package/templates/saas/tsconfig.json +18 -0
- package/templates/saas/vite.config.ts +14 -0
- package/templates/saas/wrangler.jsonc +20 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { authClient } from "../lib/auth-client";
|
|
3
|
+
import { ThemeToggle } from "../components/ThemeToggle";
|
|
4
|
+
|
|
5
|
+
interface SignupPageProps {
|
|
6
|
+
navigate: (route: "/" | "/login" | "/signup" | "/pricing" | "/dashboard" | "/forgot-password" | "/reset-password") => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export default function SignupPage({ navigate }: SignupPageProps) {
|
|
10
|
+
const [name, setName] = useState("");
|
|
11
|
+
const [email, setEmail] = useState("");
|
|
12
|
+
const [password, setPassword] = useState("");
|
|
13
|
+
const [error, setError] = useState<string | null>(null);
|
|
14
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
15
|
+
|
|
16
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
17
|
+
e.preventDefault();
|
|
18
|
+
setError(null);
|
|
19
|
+
setIsLoading(true);
|
|
20
|
+
|
|
21
|
+
// Basic password validation
|
|
22
|
+
if (password.length < 8) {
|
|
23
|
+
setError("Password must be at least 8 characters long.");
|
|
24
|
+
setIsLoading(false);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const result = await authClient.signUp.email({
|
|
30
|
+
email,
|
|
31
|
+
password,
|
|
32
|
+
name,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (result.error) {
|
|
36
|
+
setError(result.error.message || "Failed to create account. Please try again.");
|
|
37
|
+
setIsLoading(false);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
navigate("/dashboard");
|
|
42
|
+
} catch (err) {
|
|
43
|
+
setError("An unexpected error occurred. Please try again.");
|
|
44
|
+
setIsLoading(false);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className="min-h-screen bg-background flex flex-col">
|
|
50
|
+
{/* Navigation */}
|
|
51
|
+
<nav className="border-b border-border">
|
|
52
|
+
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
53
|
+
<div className="flex justify-between items-center h-16">
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
onClick={() => navigate("/")}
|
|
57
|
+
className="text-xl font-bold hover:opacity-80 transition-opacity"
|
|
58
|
+
>
|
|
59
|
+
jack-template
|
|
60
|
+
</button>
|
|
61
|
+
<ThemeToggle />
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
</nav>
|
|
65
|
+
|
|
66
|
+
{/* Signup Form */}
|
|
67
|
+
<div className="flex-1 flex items-center justify-center px-4 py-12">
|
|
68
|
+
<div className="w-full max-w-md">
|
|
69
|
+
<div className="text-center mb-8">
|
|
70
|
+
<h1 className="text-2xl font-bold mb-2">Create your account</h1>
|
|
71
|
+
<p className="text-muted-foreground">Get started for free. No credit card required.</p>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
75
|
+
{error && (
|
|
76
|
+
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
|
77
|
+
<p className="text-sm text-destructive">{error}</p>
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
|
|
81
|
+
<div>
|
|
82
|
+
<label htmlFor="name" className="block text-sm font-medium mb-2">
|
|
83
|
+
Name
|
|
84
|
+
</label>
|
|
85
|
+
<input
|
|
86
|
+
id="name"
|
|
87
|
+
type="text"
|
|
88
|
+
value={name}
|
|
89
|
+
onChange={(e) => setName(e.target.value)}
|
|
90
|
+
placeholder="Your name"
|
|
91
|
+
required
|
|
92
|
+
disabled={isLoading}
|
|
93
|
+
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<div>
|
|
98
|
+
<label htmlFor="email" className="block text-sm font-medium mb-2">
|
|
99
|
+
Email
|
|
100
|
+
</label>
|
|
101
|
+
<input
|
|
102
|
+
id="email"
|
|
103
|
+
type="email"
|
|
104
|
+
value={email}
|
|
105
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
106
|
+
placeholder="you@example.com"
|
|
107
|
+
required
|
|
108
|
+
disabled={isLoading}
|
|
109
|
+
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
|
|
110
|
+
/>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<div>
|
|
114
|
+
<label htmlFor="password" className="block text-sm font-medium mb-2">
|
|
115
|
+
Password
|
|
116
|
+
</label>
|
|
117
|
+
<input
|
|
118
|
+
id="password"
|
|
119
|
+
type="password"
|
|
120
|
+
value={password}
|
|
121
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
122
|
+
placeholder="At least 8 characters"
|
|
123
|
+
required
|
|
124
|
+
disabled={isLoading}
|
|
125
|
+
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
|
|
126
|
+
/>
|
|
127
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
128
|
+
Must be at least 8 characters long
|
|
129
|
+
</p>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<button
|
|
133
|
+
type="submit"
|
|
134
|
+
disabled={isLoading}
|
|
135
|
+
className="w-full px-4 py-2 bg-primary text-primary-foreground rounded-md hover:opacity-90 transition-opacity font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
|
|
136
|
+
>
|
|
137
|
+
{isLoading ? (
|
|
138
|
+
<>
|
|
139
|
+
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
|
140
|
+
<circle
|
|
141
|
+
className="opacity-25"
|
|
142
|
+
cx="12"
|
|
143
|
+
cy="12"
|
|
144
|
+
r="10"
|
|
145
|
+
stroke="currentColor"
|
|
146
|
+
strokeWidth="4"
|
|
147
|
+
/>
|
|
148
|
+
<path
|
|
149
|
+
className="opacity-75"
|
|
150
|
+
fill="currentColor"
|
|
151
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
152
|
+
/>
|
|
153
|
+
</svg>
|
|
154
|
+
Creating account...
|
|
155
|
+
</>
|
|
156
|
+
) : (
|
|
157
|
+
"Create account"
|
|
158
|
+
)}
|
|
159
|
+
</button>
|
|
160
|
+
</form>
|
|
161
|
+
|
|
162
|
+
<p className="mt-6 text-xs text-center text-muted-foreground">
|
|
163
|
+
By creating an account, you agree to our Terms of Service and Privacy Policy.
|
|
164
|
+
</p>
|
|
165
|
+
|
|
166
|
+
<div className="mt-6 text-center">
|
|
167
|
+
<p className="text-sm text-muted-foreground">
|
|
168
|
+
Already have an account?{" "}
|
|
169
|
+
<button
|
|
170
|
+
type="button"
|
|
171
|
+
onClick={() => navigate("/login")}
|
|
172
|
+
className="text-primary hover:underline font-medium"
|
|
173
|
+
>
|
|
174
|
+
Sign in
|
|
175
|
+
</button>
|
|
176
|
+
</p>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<div className="mt-4 text-center">
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
onClick={() => navigate("/")}
|
|
183
|
+
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
184
|
+
>
|
|
185
|
+
Back to home
|
|
186
|
+
</button>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Server-side Worker - handles API routes, keeps secrets secure
|
|
2
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
3
|
+
|
|
4
|
+
import { Hono } from "hono";
|
|
5
|
+
import { cors } from "hono/cors";
|
|
6
|
+
import Stripe from "stripe";
|
|
7
|
+
import { createAuth } from "./auth";
|
|
8
|
+
|
|
9
|
+
// Environment bindings and secrets
|
|
10
|
+
type Env = {
|
|
11
|
+
// Bindings
|
|
12
|
+
DB: D1Database;
|
|
13
|
+
ASSETS: Fetcher;
|
|
14
|
+
|
|
15
|
+
// Secrets (set via `jack secrets set`)
|
|
16
|
+
BETTER_AUTH_SECRET: string;
|
|
17
|
+
BETTER_AUTH_URL?: string;
|
|
18
|
+
STRIPE_SECRET_KEY: string;
|
|
19
|
+
STRIPE_WEBHOOK_SECRET: string;
|
|
20
|
+
STRIPE_PRO_PRICE_ID?: string;
|
|
21
|
+
STRIPE_ENTERPRISE_PRICE_ID?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const app = new Hono<{ Bindings: Env }>();
|
|
25
|
+
|
|
26
|
+
// CORS for API routes
|
|
27
|
+
app.use("/api/*", cors());
|
|
28
|
+
|
|
29
|
+
// Mount Better Auth handler - handles all auth routes including Stripe webhooks
|
|
30
|
+
// Routes: /api/auth/signup, /api/auth/signin, /api/auth/session, /api/auth/stripe/webhook, etc.
|
|
31
|
+
app.all("/api/auth/*", async (c) => {
|
|
32
|
+
const auth = createAuth(c.env);
|
|
33
|
+
return auth.handler(c.req.raw);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Health check endpoint
|
|
37
|
+
app.get("/api/health", (c) => {
|
|
38
|
+
return c.json({
|
|
39
|
+
status: "ok",
|
|
40
|
+
timestamp: new Date().toISOString(),
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Config endpoint - exposes non-sensitive configuration to frontend
|
|
45
|
+
app.get("/api/config", (c) => {
|
|
46
|
+
const isStripeTestMode = c.env.STRIPE_SECRET_KEY?.startsWith("sk_test_") ?? true;
|
|
47
|
+
return c.json({
|
|
48
|
+
stripeTestMode: isStripeTestMode,
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Get subscription status with real-time Stripe data
|
|
53
|
+
app.get("/api/subscription-status", async (c) => {
|
|
54
|
+
try {
|
|
55
|
+
const auth = createAuth(c.env);
|
|
56
|
+
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
|
57
|
+
|
|
58
|
+
if (!session?.user) {
|
|
59
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const stripeClient = new Stripe(c.env.STRIPE_SECRET_KEY);
|
|
63
|
+
|
|
64
|
+
// Find customer by email
|
|
65
|
+
const customers = await stripeClient.customers.list({
|
|
66
|
+
email: session.user.email,
|
|
67
|
+
limit: 1,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (customers.data.length === 0) {
|
|
71
|
+
console.log(`[subscription-status] No Stripe customer for: ${session.user.email}`);
|
|
72
|
+
return c.json({ subscription: null });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Get subscriptions for this customer (active or scheduled to cancel)
|
|
76
|
+
const subscriptions = await stripeClient.subscriptions.list({
|
|
77
|
+
customer: customers.data[0].id,
|
|
78
|
+
limit: 10, // Get more to find the right one
|
|
79
|
+
expand: ["data.default_payment_method"], // Force fresh data
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Find active or trialing subscription (even if set to cancel at period end)
|
|
83
|
+
const sub = subscriptions.data.find(
|
|
84
|
+
(s) => s.status === "active" || s.status === "trialing"
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
if (!sub) {
|
|
88
|
+
console.log(`[subscription-status] No active subscriptions for customer: ${customers.data[0].id}`);
|
|
89
|
+
return c.json({ subscription: null });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log(`[subscription-status] Found: id=${sub.id}, status=${sub.status}, cancel_at_period_end=${sub.cancel_at_period_end}`);
|
|
93
|
+
|
|
94
|
+
return c.json({
|
|
95
|
+
subscription: {
|
|
96
|
+
id: sub.id,
|
|
97
|
+
status: sub.status,
|
|
98
|
+
cancelAtPeriodEnd: sub.cancel_at_period_end,
|
|
99
|
+
periodEnd: sub.current_period_end
|
|
100
|
+
? new Date(sub.current_period_end * 1000).toISOString()
|
|
101
|
+
: null,
|
|
102
|
+
plan: sub.items.data[0]?.price?.lookup_key || sub.items.data[0]?.price?.id,
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
} catch (err) {
|
|
106
|
+
console.error("[subscription-status] Error:", err);
|
|
107
|
+
return c.json({
|
|
108
|
+
error: "Failed to fetch subscription status",
|
|
109
|
+
details: err instanceof Error ? err.message : String(err)
|
|
110
|
+
}, 500);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Resubscribe - undo a pending cancellation
|
|
115
|
+
app.post("/api/resubscribe", async (c) => {
|
|
116
|
+
try {
|
|
117
|
+
const auth = createAuth(c.env);
|
|
118
|
+
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
|
119
|
+
|
|
120
|
+
if (!session?.user) {
|
|
121
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const stripeClient = new Stripe(c.env.STRIPE_SECRET_KEY);
|
|
125
|
+
|
|
126
|
+
// Find customer
|
|
127
|
+
const customers = await stripeClient.customers.list({
|
|
128
|
+
email: session.user.email,
|
|
129
|
+
limit: 1,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (customers.data.length === 0) {
|
|
133
|
+
return c.json({ error: "No billing account found" }, 404);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Find active subscription set to cancel
|
|
137
|
+
const subscriptions = await stripeClient.subscriptions.list({
|
|
138
|
+
customer: customers.data[0].id,
|
|
139
|
+
status: "active",
|
|
140
|
+
limit: 1,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
if (subscriptions.data.length === 0) {
|
|
144
|
+
return c.json({ error: "No active subscription found" }, 404);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const sub = subscriptions.data[0];
|
|
148
|
+
|
|
149
|
+
// Undo the cancellation in Stripe (if set)
|
|
150
|
+
if (sub.cancel_at_period_end) {
|
|
151
|
+
await stripeClient.subscriptions.update(sub.id, {
|
|
152
|
+
cancel_at_period_end: false,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Also clear the local cancelAt in Better Auth's database
|
|
157
|
+
await c.env.DB.prepare(
|
|
158
|
+
"UPDATE subscription SET cancelAt = NULL WHERE stripeSubscriptionId = ?"
|
|
159
|
+
).bind(sub.id).run();
|
|
160
|
+
|
|
161
|
+
return c.json({ success: true });
|
|
162
|
+
} catch (err) {
|
|
163
|
+
console.error("[resubscribe] Error:", err);
|
|
164
|
+
return c.json({
|
|
165
|
+
error: "Failed to resubscribe",
|
|
166
|
+
details: err instanceof Error ? err.message : String(err),
|
|
167
|
+
}, 500);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Billing portal - redirects authenticated user to Stripe Customer Portal
|
|
172
|
+
app.get("/api/billing-portal", async (c) => {
|
|
173
|
+
const auth = createAuth(c.env);
|
|
174
|
+
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
|
175
|
+
|
|
176
|
+
if (!session?.user) {
|
|
177
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Get the Stripe customer ID from the user's subscription
|
|
181
|
+
const stripeClient = new Stripe(c.env.STRIPE_SECRET_KEY);
|
|
182
|
+
|
|
183
|
+
// Find the customer by email (Better Auth creates customer on signup)
|
|
184
|
+
const customers = await stripeClient.customers.list({
|
|
185
|
+
email: session.user.email,
|
|
186
|
+
limit: 1,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
if (customers.data.length === 0) {
|
|
190
|
+
return c.json({ error: "No billing account found" }, 404);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const returnUrl = c.req.header("origin") || c.req.header("referer") || c.env.BETTER_AUTH_URL || "/";
|
|
194
|
+
|
|
195
|
+
const portalSession = await stripeClient.billingPortal.sessions.create({
|
|
196
|
+
customer: customers.data[0].id,
|
|
197
|
+
return_url: `${returnUrl}/#/dashboard`,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
return c.redirect(portalSession.url);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Serve React app for all other routes
|
|
204
|
+
app.get("*", (c) => c.env.ASSETS.fetch(c.req.raw));
|
|
205
|
+
|
|
206
|
+
// Export type for client-side usage with hono/client
|
|
207
|
+
export type AppType = typeof app;
|
|
208
|
+
export default app;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"noEmit": true,
|
|
11
|
+
"types": ["@cloudflare/workers-types"],
|
|
12
|
+
"baseUrl": ".",
|
|
13
|
+
"paths": {
|
|
14
|
+
"@/*": ["./src/client/*"]
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"include": ["src"]
|
|
18
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { cloudflare } from "@cloudflare/vite-plugin";
|
|
2
|
+
import tailwindcss from "@tailwindcss/vite";
|
|
3
|
+
import react from "@vitejs/plugin-react";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { defineConfig } from "vite";
|
|
6
|
+
|
|
7
|
+
export default defineConfig({
|
|
8
|
+
plugins: [react(), tailwindcss(), cloudflare()],
|
|
9
|
+
resolve: {
|
|
10
|
+
alias: {
|
|
11
|
+
"@": resolve(__dirname, "./src/client"),
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "jack-template",
|
|
3
|
+
"main": "src/index.ts",
|
|
4
|
+
"compatibility_date": "2024-12-01",
|
|
5
|
+
"compatibility_flags": ["nodejs_compat"],
|
|
6
|
+
"assets": {
|
|
7
|
+
"binding": "ASSETS",
|
|
8
|
+
"directory": "dist/client",
|
|
9
|
+
"not_found_handling": "single-page-application",
|
|
10
|
+
// Required for API routes (/api/*) to work alongside static assets
|
|
11
|
+
// Without this, Cloudflare serves static files directly, bypassing the worker
|
|
12
|
+
"run_worker_first": true
|
|
13
|
+
},
|
|
14
|
+
"d1_databases": [
|
|
15
|
+
{
|
|
16
|
+
"binding": "DB",
|
|
17
|
+
"database_name": "jack-template-db"
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|