@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,467 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { authClient } from "../lib/auth-client";
|
|
3
|
+
import { toast } from "sonner";
|
|
4
|
+
import {
|
|
5
|
+
Card,
|
|
6
|
+
CardContent,
|
|
7
|
+
CardDescription,
|
|
8
|
+
CardFooter,
|
|
9
|
+
CardHeader,
|
|
10
|
+
CardTitle,
|
|
11
|
+
} from "../components/ui/card";
|
|
12
|
+
import { Button } from "../components/ui/button";
|
|
13
|
+
import { ThemeToggle } from "../components/ThemeToggle";
|
|
14
|
+
import { plans, getPlanName, isPaidPlan, type PlanId, type PlanConfig } from "../lib/plans";
|
|
15
|
+
|
|
16
|
+
interface PricingPageProps {
|
|
17
|
+
navigate: (route: "/" | "/login" | "/signup" | "/pricing" | "/dashboard" | "/forgot-password" | "/reset-password") => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default function PricingPage({ navigate }: PricingPageProps) {
|
|
21
|
+
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
|
22
|
+
const [currentPlan, setCurrentPlan] = useState<PlanId | null>(null);
|
|
23
|
+
const [isCancelling, setIsCancelling] = useState(false);
|
|
24
|
+
const [periodEnd, setPeriodEnd] = useState<string | null>(null);
|
|
25
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
26
|
+
const [upgradeLoading, setUpgradeLoading] = useState<PlanId | null>(null);
|
|
27
|
+
const [error, setError] = useState<string | null>(null);
|
|
28
|
+
const [stripeTestMode, setStripeTestMode] = useState(true);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
// Fetch config to check if Stripe is in test mode
|
|
32
|
+
fetch("/api/config")
|
|
33
|
+
.then((res) => res.json())
|
|
34
|
+
.then((data) => setStripeTestMode(data.stripeTestMode ?? true))
|
|
35
|
+
.catch(() => setStripeTestMode(true)); // Default to showing test banner on error
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const checkSession = async () => {
|
|
40
|
+
try {
|
|
41
|
+
const result = await authClient.getSession();
|
|
42
|
+
if (result.data?.user) {
|
|
43
|
+
setIsLoggedIn(true);
|
|
44
|
+
// Check subscription status from Better Auth
|
|
45
|
+
try {
|
|
46
|
+
const subscription = await authClient.subscription.list();
|
|
47
|
+
const activeSub = subscription?.data?.find(
|
|
48
|
+
(s: { status: string }) => s.status === "active" || s.status === "trialing"
|
|
49
|
+
);
|
|
50
|
+
if (activeSub?.plan) {
|
|
51
|
+
setCurrentPlan(activeSub.plan as PlanId);
|
|
52
|
+
// Better Auth uses cancelAt (date) to indicate pending cancellation
|
|
53
|
+
if (activeSub.cancelAt) {
|
|
54
|
+
setIsCancelling(true);
|
|
55
|
+
setPeriodEnd(String(activeSub.periodEnd || activeSub.cancelAt));
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
setCurrentPlan("free");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Also check Stripe for real-time status
|
|
62
|
+
const stripeRes = await fetch("/api/subscription-status");
|
|
63
|
+
if (stripeRes.ok) {
|
|
64
|
+
const data = await stripeRes.json();
|
|
65
|
+
if (data.subscription?.cancelAtPeriodEnd) {
|
|
66
|
+
setIsCancelling(true);
|
|
67
|
+
setPeriodEnd(data.subscription.periodEnd ?? null);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} catch (subErr) {
|
|
71
|
+
console.error("Failed to load subscription:", subErr);
|
|
72
|
+
setCurrentPlan("free");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} catch (err) {
|
|
76
|
+
// Not logged in
|
|
77
|
+
}
|
|
78
|
+
setIsLoading(false);
|
|
79
|
+
};
|
|
80
|
+
checkSession();
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
const handleUpgrade = async (plan: PlanId) => {
|
|
84
|
+
setError(null);
|
|
85
|
+
|
|
86
|
+
if (!isLoggedIn) {
|
|
87
|
+
navigate("/signup");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (plan === "free") {
|
|
92
|
+
// Downgrade = cancel subscription
|
|
93
|
+
setUpgradeLoading("free");
|
|
94
|
+
try {
|
|
95
|
+
const result = await authClient.subscription.cancel({
|
|
96
|
+
returnUrl: `${window.location.origin}/#/pricing?downgraded=true`,
|
|
97
|
+
});
|
|
98
|
+
if (result?.error) {
|
|
99
|
+
const errorMsg = result.error.message || "";
|
|
100
|
+
// If already cancelling, treat as success
|
|
101
|
+
if (errorMsg.includes("already set to be canceled")) {
|
|
102
|
+
setIsCancelling(true);
|
|
103
|
+
toast.success("Subscription is set to cancel", {
|
|
104
|
+
description: "You'll have access until the end of your billing period.",
|
|
105
|
+
});
|
|
106
|
+
} else {
|
|
107
|
+
setError(errorMsg || "Failed to cancel subscription.");
|
|
108
|
+
}
|
|
109
|
+
} else if (result?.data?.url) {
|
|
110
|
+
window.location.href = result.data.url;
|
|
111
|
+
} else {
|
|
112
|
+
// Cancelled immediately
|
|
113
|
+
setIsCancelling(true);
|
|
114
|
+
setError(null);
|
|
115
|
+
toast.success("Subscription cancelled", {
|
|
116
|
+
description: "You'll have access until the end of your billing period.",
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
} catch (err) {
|
|
120
|
+
console.error("Cancel error:", err);
|
|
121
|
+
setError("Failed to cancel subscription. Please try again or contact support.");
|
|
122
|
+
}
|
|
123
|
+
setUpgradeLoading(null);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// If cancelling and clicking current plan = resubscribe (undo cancellation)
|
|
128
|
+
if (isCancelling && plan === currentPlan) {
|
|
129
|
+
setUpgradeLoading(plan);
|
|
130
|
+
try {
|
|
131
|
+
const res = await fetch("/api/resubscribe", { method: "POST" });
|
|
132
|
+
const data = await res.json();
|
|
133
|
+
if (res.ok && data.success) {
|
|
134
|
+
setIsCancelling(false);
|
|
135
|
+
toast.success("Resubscribed successfully", {
|
|
136
|
+
description: "Your subscription will continue as normal.",
|
|
137
|
+
});
|
|
138
|
+
} else {
|
|
139
|
+
setError(data.error || "Failed to resubscribe.");
|
|
140
|
+
}
|
|
141
|
+
} catch (err) {
|
|
142
|
+
console.error("Resubscribe error:", err);
|
|
143
|
+
setError("Failed to resubscribe. Please try again.");
|
|
144
|
+
}
|
|
145
|
+
setUpgradeLoading(null);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
setUpgradeLoading(plan);
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const result = await authClient.subscription.upgrade({
|
|
153
|
+
plan,
|
|
154
|
+
successUrl: `${window.location.origin}/#/dashboard?upgraded=true`,
|
|
155
|
+
cancelUrl: `${window.location.origin}/#/pricing`,
|
|
156
|
+
});
|
|
157
|
+
if (result?.error) {
|
|
158
|
+
setError(result.error.message || "Failed to upgrade. Please try again.");
|
|
159
|
+
} else if (result?.data?.url) {
|
|
160
|
+
// Redirect to Stripe checkout
|
|
161
|
+
window.location.href = result.data.url;
|
|
162
|
+
}
|
|
163
|
+
} catch (err) {
|
|
164
|
+
console.error("Upgrade error:", err);
|
|
165
|
+
setError("An unexpected error occurred. Please try again.");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
setUpgradeLoading(null);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const getButtonText = (plan: PlanConfig) => {
|
|
172
|
+
if (!isLoggedIn) {
|
|
173
|
+
return plan.id === "free" ? "Get started" : "Start free trial";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// When user is cancelling their current paid plan
|
|
177
|
+
if (isCancelling && isPaidPlan(currentPlan || "free")) {
|
|
178
|
+
if (plan.id === currentPlan) {
|
|
179
|
+
// Their current plan - offer to undo cancellation
|
|
180
|
+
return "Resubscribe";
|
|
181
|
+
}
|
|
182
|
+
if (plan.id === "free") {
|
|
183
|
+
// Free plan - this is where they're heading after cancellation
|
|
184
|
+
return "After period ends";
|
|
185
|
+
}
|
|
186
|
+
// Other paid plans - can still switch
|
|
187
|
+
return "Switch plan";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Normal flow (not cancelling)
|
|
191
|
+
if (currentPlan === plan.id) {
|
|
192
|
+
return "Current plan";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (plan.id === "free") {
|
|
196
|
+
return "Downgrade";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return "Upgrade";
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const isButtonDisabled = (plan: PlanConfig) => {
|
|
203
|
+
if (isLoading || upgradeLoading !== null) return true;
|
|
204
|
+
|
|
205
|
+
// When cancelling: only disable Free (they're already heading there)
|
|
206
|
+
if (isCancelling && isPaidPlan(currentPlan || "free")) {
|
|
207
|
+
if (plan.id === "free") return true;
|
|
208
|
+
return false; // Allow resubscribe or switch
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Normal flow: disable current plan
|
|
212
|
+
if (currentPlan === plan.id) return true;
|
|
213
|
+
return false;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<div className="min-h-screen bg-background">
|
|
218
|
+
{/* Navigation */}
|
|
219
|
+
<nav className="border-b border-border">
|
|
220
|
+
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
221
|
+
<div className="flex justify-between items-center h-16">
|
|
222
|
+
<button
|
|
223
|
+
type="button"
|
|
224
|
+
onClick={() => navigate("/")}
|
|
225
|
+
className="text-xl font-bold hover:opacity-80 transition-opacity"
|
|
226
|
+
>
|
|
227
|
+
jack-template
|
|
228
|
+
</button>
|
|
229
|
+
<div className="flex items-center gap-4">
|
|
230
|
+
<ThemeToggle />
|
|
231
|
+
{isLoggedIn ? (
|
|
232
|
+
<Button onClick={() => navigate("/dashboard")}>Dashboard</Button>
|
|
233
|
+
) : (
|
|
234
|
+
<>
|
|
235
|
+
<Button variant="ghost" onClick={() => navigate("/login")}>
|
|
236
|
+
Log in
|
|
237
|
+
</Button>
|
|
238
|
+
<Button onClick={() => navigate("/signup")}>Get started</Button>
|
|
239
|
+
</>
|
|
240
|
+
)}
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
</nav>
|
|
245
|
+
|
|
246
|
+
{/* Pricing Header */}
|
|
247
|
+
<section className="py-16 px-4 sm:px-6 lg:px-8">
|
|
248
|
+
<div className="max-w-4xl mx-auto text-center">
|
|
249
|
+
<h1 className="text-4xl font-bold mb-4">Simple, transparent pricing</h1>
|
|
250
|
+
<p className="text-lg text-muted-foreground">
|
|
251
|
+
Choose the plan that's right for you. All plans include a 14-day free trial.
|
|
252
|
+
</p>
|
|
253
|
+
{isLoggedIn && currentPlan !== "free" && (
|
|
254
|
+
<div className="mt-4">
|
|
255
|
+
<Button variant="outline" size="sm" asChild>
|
|
256
|
+
<a href="/api/billing-portal">Manage Billing in Stripe</a>
|
|
257
|
+
</Button>
|
|
258
|
+
</div>
|
|
259
|
+
)}
|
|
260
|
+
</div>
|
|
261
|
+
</section>
|
|
262
|
+
|
|
263
|
+
{/* Test Mode Banner - only shown when Stripe is in test mode */}
|
|
264
|
+
{stripeTestMode && (
|
|
265
|
+
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 mb-8">
|
|
266
|
+
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
|
267
|
+
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
|
268
|
+
<strong>Test Mode:</strong> Use card{" "}
|
|
269
|
+
<code className="bg-yellow-100 dark:bg-yellow-800 px-1 rounded">
|
|
270
|
+
4242 4242 4242 4242
|
|
271
|
+
</code>{" "}
|
|
272
|
+
with any future expiry and CVC.
|
|
273
|
+
</p>
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
)}
|
|
277
|
+
|
|
278
|
+
{/* Cancellation Pending Notice */}
|
|
279
|
+
{isCancelling && (
|
|
280
|
+
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 mb-8">
|
|
281
|
+
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
|
282
|
+
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
|
283
|
+
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
|
284
|
+
<strong>Your subscription is set to cancel.</strong> You'll have access to{" "}
|
|
285
|
+
{getPlanName(currentPlan || "free")} features until{" "}
|
|
286
|
+
{periodEnd ? new Date(periodEnd).toLocaleDateString() : "the end of your billing period"}.
|
|
287
|
+
</p>
|
|
288
|
+
<Button variant="outline" size="sm" asChild className="shrink-0">
|
|
289
|
+
<a href="/api/billing-portal">Manage in Stripe</a>
|
|
290
|
+
</Button>
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
</div>
|
|
294
|
+
)}
|
|
295
|
+
|
|
296
|
+
{/* Error Message */}
|
|
297
|
+
{error && (
|
|
298
|
+
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 mb-8">
|
|
299
|
+
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-md">
|
|
300
|
+
<p className="text-sm text-destructive text-center">{error}</p>
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
)}
|
|
304
|
+
|
|
305
|
+
{/* Pricing Cards */}
|
|
306
|
+
<section className="pb-20 px-4 sm:px-6 lg:px-8">
|
|
307
|
+
<div className="max-w-6xl mx-auto">
|
|
308
|
+
<div className="grid md:grid-cols-3 gap-8">
|
|
309
|
+
{plans.map((plan) => (
|
|
310
|
+
<Card
|
|
311
|
+
key={plan.id}
|
|
312
|
+
className={plan.highlighted ? "border-2 border-primary shadow-lg" : ""}
|
|
313
|
+
>
|
|
314
|
+
<CardHeader>
|
|
315
|
+
{plan.highlighted && (
|
|
316
|
+
<div className="text-xs font-semibold text-primary uppercase tracking-wide mb-2">
|
|
317
|
+
Most popular
|
|
318
|
+
</div>
|
|
319
|
+
)}
|
|
320
|
+
<CardTitle className="text-2xl">{plan.name}</CardTitle>
|
|
321
|
+
<div className="mt-2">
|
|
322
|
+
<span className="text-4xl font-bold">{plan.price}</span>
|
|
323
|
+
{plan.id !== "free" && (
|
|
324
|
+
<span className="text-muted-foreground">/month</span>
|
|
325
|
+
)}
|
|
326
|
+
</div>
|
|
327
|
+
<CardDescription className="mt-2">{plan.description}</CardDescription>
|
|
328
|
+
</CardHeader>
|
|
329
|
+
<CardContent>
|
|
330
|
+
<ul className="space-y-3">
|
|
331
|
+
{plan.features.map((feature) => (
|
|
332
|
+
<li key={feature} className="flex items-start gap-3">
|
|
333
|
+
<svg
|
|
334
|
+
className="w-5 h-5 text-primary flex-shrink-0 mt-0.5"
|
|
335
|
+
fill="none"
|
|
336
|
+
stroke="currentColor"
|
|
337
|
+
viewBox="0 0 24 24"
|
|
338
|
+
>
|
|
339
|
+
<path
|
|
340
|
+
strokeLinecap="round"
|
|
341
|
+
strokeLinejoin="round"
|
|
342
|
+
strokeWidth={2}
|
|
343
|
+
d="M5 13l4 4L19 7"
|
|
344
|
+
/>
|
|
345
|
+
</svg>
|
|
346
|
+
<span className="text-sm">{feature}</span>
|
|
347
|
+
</li>
|
|
348
|
+
))}
|
|
349
|
+
</ul>
|
|
350
|
+
</CardContent>
|
|
351
|
+
<CardFooter>
|
|
352
|
+
<Button
|
|
353
|
+
className="w-full"
|
|
354
|
+
variant={plan.highlighted ? "default" : "outline"}
|
|
355
|
+
onClick={() => handleUpgrade(plan.id)}
|
|
356
|
+
disabled={isButtonDisabled(plan)}
|
|
357
|
+
>
|
|
358
|
+
{upgradeLoading === plan.id ? (
|
|
359
|
+
<>
|
|
360
|
+
<svg
|
|
361
|
+
className="animate-spin -ml-1 mr-2 h-4 w-4"
|
|
362
|
+
fill="none"
|
|
363
|
+
viewBox="0 0 24 24"
|
|
364
|
+
>
|
|
365
|
+
<circle
|
|
366
|
+
className="opacity-25"
|
|
367
|
+
cx="12"
|
|
368
|
+
cy="12"
|
|
369
|
+
r="10"
|
|
370
|
+
stroke="currentColor"
|
|
371
|
+
strokeWidth="4"
|
|
372
|
+
/>
|
|
373
|
+
<path
|
|
374
|
+
className="opacity-75"
|
|
375
|
+
fill="currentColor"
|
|
376
|
+
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"
|
|
377
|
+
/>
|
|
378
|
+
</svg>
|
|
379
|
+
Processing...
|
|
380
|
+
</>
|
|
381
|
+
) : isLoading ? (
|
|
382
|
+
<div className="h-5 w-20 bg-muted animate-pulse rounded" />
|
|
383
|
+
) : (
|
|
384
|
+
getButtonText(plan)
|
|
385
|
+
)}
|
|
386
|
+
</Button>
|
|
387
|
+
</CardFooter>
|
|
388
|
+
</Card>
|
|
389
|
+
))}
|
|
390
|
+
</div>
|
|
391
|
+
</div>
|
|
392
|
+
</section>
|
|
393
|
+
|
|
394
|
+
{/* FAQ Section */}
|
|
395
|
+
<section className="py-16 px-4 sm:px-6 lg:px-8 bg-muted/50">
|
|
396
|
+
<div className="max-w-4xl mx-auto">
|
|
397
|
+
<h2 className="text-2xl font-bold text-center mb-12">Frequently asked questions</h2>
|
|
398
|
+
<div className="grid md:grid-cols-2 gap-8">
|
|
399
|
+
<div>
|
|
400
|
+
<h3 className="font-semibold mb-2">Can I change plans later?</h3>
|
|
401
|
+
<p className="text-sm text-muted-foreground">
|
|
402
|
+
Yes, you can upgrade or downgrade your plan at any time. Changes take effect
|
|
403
|
+
immediately.
|
|
404
|
+
</p>
|
|
405
|
+
</div>
|
|
406
|
+
<div>
|
|
407
|
+
<h3 className="font-semibold mb-2">What payment methods do you accept?</h3>
|
|
408
|
+
<p className="text-sm text-muted-foreground">
|
|
409
|
+
We accept all major credit cards through our secure Stripe integration.
|
|
410
|
+
</p>
|
|
411
|
+
</div>
|
|
412
|
+
<div>
|
|
413
|
+
<h3 className="font-semibold mb-2">Is there a free trial?</h3>
|
|
414
|
+
<p className="text-sm text-muted-foreground">
|
|
415
|
+
Yes, all paid plans come with a 14-day free trial. No credit card required to start.
|
|
416
|
+
</p>
|
|
417
|
+
</div>
|
|
418
|
+
<div>
|
|
419
|
+
<h3 className="font-semibold mb-2">Can I cancel anytime?</h3>
|
|
420
|
+
<p className="text-sm text-muted-foreground">
|
|
421
|
+
Absolutely. You can cancel your subscription at any time with no questions asked.
|
|
422
|
+
</p>
|
|
423
|
+
</div>
|
|
424
|
+
</div>
|
|
425
|
+
</div>
|
|
426
|
+
</section>
|
|
427
|
+
|
|
428
|
+
{/* Footer */}
|
|
429
|
+
<footer className="border-t border-border py-12 px-4 sm:px-6 lg:px-8">
|
|
430
|
+
<div className="max-w-6xl mx-auto">
|
|
431
|
+
<div className="flex flex-col md:flex-row justify-between items-center gap-6">
|
|
432
|
+
<div className="flex items-center gap-2">
|
|
433
|
+
<span className="font-bold">jack-template</span>
|
|
434
|
+
<span className="text-muted-foreground text-sm">Built with Jack</span>
|
|
435
|
+
</div>
|
|
436
|
+
<div className="flex gap-6">
|
|
437
|
+
<button
|
|
438
|
+
type="button"
|
|
439
|
+
onClick={() => navigate("/")}
|
|
440
|
+
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
441
|
+
>
|
|
442
|
+
Home
|
|
443
|
+
</button>
|
|
444
|
+
<button
|
|
445
|
+
type="button"
|
|
446
|
+
onClick={() => navigate("/login")}
|
|
447
|
+
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
448
|
+
>
|
|
449
|
+
Log in
|
|
450
|
+
</button>
|
|
451
|
+
<button
|
|
452
|
+
type="button"
|
|
453
|
+
onClick={() => navigate("/signup")}
|
|
454
|
+
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
455
|
+
>
|
|
456
|
+
Sign up
|
|
457
|
+
</button>
|
|
458
|
+
</div>
|
|
459
|
+
</div>
|
|
460
|
+
<div className="mt-8 pt-8 border-t border-border text-center text-sm text-muted-foreground">
|
|
461
|
+
© {new Date().getFullYear()} jack-template. All rights reserved.
|
|
462
|
+
</div>
|
|
463
|
+
</div>
|
|
464
|
+
</footer>
|
|
465
|
+
</div>
|
|
466
|
+
);
|
|
467
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import { authClient } from "../lib/auth-client";
|
|
3
|
+
import { toast } from "sonner";
|
|
4
|
+
import { ThemeToggle } from "../components/ThemeToggle";
|
|
5
|
+
import { Button } from "../components/ui/button";
|
|
6
|
+
|
|
7
|
+
interface ResetPasswordPageProps {
|
|
8
|
+
navigate: (route: "/" | "/login" | "/signup" | "/pricing" | "/dashboard" | "/forgot-password" | "/reset-password") => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default function ResetPasswordPage({ navigate }: ResetPasswordPageProps) {
|
|
12
|
+
const [password, setPassword] = useState("");
|
|
13
|
+
const [confirmPassword, setConfirmPassword] = useState("");
|
|
14
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
15
|
+
const [error, setError] = useState<string | null>(null);
|
|
16
|
+
const [token, setToken] = useState<string | null>(null);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
// Extract token from URL - Better Auth adds it as a query param
|
|
20
|
+
const hash = window.location.hash;
|
|
21
|
+
const searchParams = new URLSearchParams(hash.split("?")[1] || "");
|
|
22
|
+
const tokenParam = searchParams.get("token");
|
|
23
|
+
setToken(tokenParam);
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
27
|
+
e.preventDefault();
|
|
28
|
+
setError(null);
|
|
29
|
+
|
|
30
|
+
if (password.length < 8) {
|
|
31
|
+
setError("Password must be at least 8 characters long.");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (password !== confirmPassword) {
|
|
36
|
+
setError("Passwords do not match.");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!token) {
|
|
41
|
+
setError("Invalid or missing reset token. Please request a new reset link.");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
setIsLoading(true);
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const result = await authClient.resetPassword({
|
|
49
|
+
newPassword: password,
|
|
50
|
+
token,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
if (result.error) {
|
|
54
|
+
setError(result.error.message || "Failed to reset password. The link may have expired.");
|
|
55
|
+
setIsLoading(false);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
toast.success("Password reset successfully", {
|
|
60
|
+
description: "You can now sign in with your new password.",
|
|
61
|
+
});
|
|
62
|
+
navigate("/login");
|
|
63
|
+
} catch (err) {
|
|
64
|
+
setError("An unexpected error occurred. Please try again.");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
setIsLoading(false);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if (!token) {
|
|
71
|
+
return (
|
|
72
|
+
<div className="min-h-screen bg-background flex flex-col">
|
|
73
|
+
{/* Navigation */}
|
|
74
|
+
<nav className="border-b border-border">
|
|
75
|
+
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
76
|
+
<div className="flex justify-between items-center h-16">
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
onClick={() => navigate("/")}
|
|
80
|
+
className="text-xl font-bold hover:opacity-80 transition-opacity"
|
|
81
|
+
>
|
|
82
|
+
jack-template
|
|
83
|
+
</button>
|
|
84
|
+
<ThemeToggle />
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</nav>
|
|
88
|
+
|
|
89
|
+
<div className="flex-1 flex items-center justify-center px-4 py-12">
|
|
90
|
+
<div className="w-full max-w-md text-center">
|
|
91
|
+
<h1 className="text-2xl font-bold mb-4">Invalid Reset Link</h1>
|
|
92
|
+
<p className="text-muted-foreground mb-6">
|
|
93
|
+
This password reset link is invalid or has expired. Please request a new one.
|
|
94
|
+
</p>
|
|
95
|
+
<Button onClick={() => navigate("/forgot-password")}>
|
|
96
|
+
Request new reset link
|
|
97
|
+
</Button>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div className="min-h-screen bg-background flex flex-col">
|
|
106
|
+
{/* Navigation */}
|
|
107
|
+
<nav className="border-b border-border">
|
|
108
|
+
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
109
|
+
<div className="flex justify-between items-center h-16">
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
onClick={() => navigate("/")}
|
|
113
|
+
className="text-xl font-bold hover:opacity-80 transition-opacity"
|
|
114
|
+
>
|
|
115
|
+
jack-template
|
|
116
|
+
</button>
|
|
117
|
+
<ThemeToggle />
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
</nav>
|
|
121
|
+
|
|
122
|
+
{/* Form */}
|
|
123
|
+
<div className="flex-1 flex items-center justify-center px-4 py-12">
|
|
124
|
+
<div className="w-full max-w-md">
|
|
125
|
+
<div className="text-center mb-8">
|
|
126
|
+
<h1 className="text-2xl font-bold mb-2">Set new password</h1>
|
|
127
|
+
<p className="text-muted-foreground">Enter your new password below</p>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
131
|
+
{error && (
|
|
132
|
+
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
|
133
|
+
<p className="text-sm text-destructive">{error}</p>
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
|
|
137
|
+
<div>
|
|
138
|
+
<label htmlFor="password" className="block text-sm font-medium mb-2">
|
|
139
|
+
New Password
|
|
140
|
+
</label>
|
|
141
|
+
<input
|
|
142
|
+
id="password"
|
|
143
|
+
type="password"
|
|
144
|
+
value={password}
|
|
145
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
146
|
+
placeholder="Enter new password"
|
|
147
|
+
required
|
|
148
|
+
className="w-full px-3 py-2 border border-border rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
|
149
|
+
/>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<div>
|
|
153
|
+
<label htmlFor="confirmPassword" className="block text-sm font-medium mb-2">
|
|
154
|
+
Confirm Password
|
|
155
|
+
</label>
|
|
156
|
+
<input
|
|
157
|
+
id="confirmPassword"
|
|
158
|
+
type="password"
|
|
159
|
+
value={confirmPassword}
|
|
160
|
+
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
161
|
+
placeholder="Confirm new password"
|
|
162
|
+
required
|
|
163
|
+
className="w-full px-3 py-2 border border-border rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
|
164
|
+
/>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<Button type="submit" className="w-full" disabled={isLoading}>
|
|
168
|
+
{isLoading ? (
|
|
169
|
+
<>
|
|
170
|
+
<svg
|
|
171
|
+
className="animate-spin -ml-1 mr-2 h-4 w-4"
|
|
172
|
+
fill="none"
|
|
173
|
+
viewBox="0 0 24 24"
|
|
174
|
+
>
|
|
175
|
+
<circle
|
|
176
|
+
className="opacity-25"
|
|
177
|
+
cx="12"
|
|
178
|
+
cy="12"
|
|
179
|
+
r="10"
|
|
180
|
+
stroke="currentColor"
|
|
181
|
+
strokeWidth="4"
|
|
182
|
+
/>
|
|
183
|
+
<path
|
|
184
|
+
className="opacity-75"
|
|
185
|
+
fill="currentColor"
|
|
186
|
+
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"
|
|
187
|
+
/>
|
|
188
|
+
</svg>
|
|
189
|
+
Resetting...
|
|
190
|
+
</>
|
|
191
|
+
) : (
|
|
192
|
+
"Reset password"
|
|
193
|
+
)}
|
|
194
|
+
</Button>
|
|
195
|
+
</form>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
}
|