@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.
Files changed (105) hide show
  1. package/package.json +5 -2
  2. package/src/commands/down.ts +11 -1
  3. package/src/commands/init.ts +19 -6
  4. package/src/commands/new.ts +56 -4
  5. package/src/commands/publish.ts +1 -1
  6. package/src/lib/agents.ts +3 -1
  7. package/src/lib/auth/ensure-auth.test.ts +3 -3
  8. package/src/lib/control-plane.ts +15 -1
  9. package/src/lib/deploy-upload.ts +26 -1
  10. package/src/lib/hooks.ts +232 -1
  11. package/src/lib/managed-deploy.ts +13 -6
  12. package/src/lib/managed-down.ts +66 -45
  13. package/src/lib/progress.ts +76 -5
  14. package/src/lib/project-list.ts +6 -1
  15. package/src/lib/project-operations.ts +21 -31
  16. package/src/lib/project-resolver.ts +1 -1
  17. package/src/lib/zip-packager.ts +36 -7
  18. package/src/templates/index.ts +1 -1
  19. package/src/templates/types.ts +16 -0
  20. package/templates/CLAUDE.md +172 -5
  21. package/templates/miniapp/.jack.json +1 -3
  22. package/templates/saas/.jack.json +154 -0
  23. package/templates/saas/AGENTS.md +333 -0
  24. package/templates/saas/bun.lock +925 -0
  25. package/templates/saas/components.json +21 -0
  26. package/templates/saas/index.html +12 -0
  27. package/templates/saas/package.json +75 -0
  28. package/templates/saas/public/icon.png +0 -0
  29. package/templates/saas/public/og.png +0 -0
  30. package/templates/saas/schema.sql +73 -0
  31. package/templates/saas/src/auth.ts +77 -0
  32. package/templates/saas/src/client/App.tsx +63 -0
  33. package/templates/saas/src/client/components/ProtectedRoute.tsx +29 -0
  34. package/templates/saas/src/client/components/ThemeToggle.tsx +32 -0
  35. package/templates/saas/src/client/components/ui/accordion.tsx +62 -0
  36. package/templates/saas/src/client/components/ui/alert-dialog.tsx +133 -0
  37. package/templates/saas/src/client/components/ui/alert.tsx +60 -0
  38. package/templates/saas/src/client/components/ui/aspect-ratio.tsx +9 -0
  39. package/templates/saas/src/client/components/ui/avatar.tsx +39 -0
  40. package/templates/saas/src/client/components/ui/badge.tsx +39 -0
  41. package/templates/saas/src/client/components/ui/breadcrumb.tsx +102 -0
  42. package/templates/saas/src/client/components/ui/button-group.tsx +78 -0
  43. package/templates/saas/src/client/components/ui/button.tsx +60 -0
  44. package/templates/saas/src/client/components/ui/card.tsx +75 -0
  45. package/templates/saas/src/client/components/ui/carousel.tsx +228 -0
  46. package/templates/saas/src/client/components/ui/chart.tsx +326 -0
  47. package/templates/saas/src/client/components/ui/checkbox.tsx +29 -0
  48. package/templates/saas/src/client/components/ui/collapsible.tsx +19 -0
  49. package/templates/saas/src/client/components/ui/command.tsx +159 -0
  50. package/templates/saas/src/client/components/ui/context-menu.tsx +224 -0
  51. package/templates/saas/src/client/components/ui/dialog.tsx +127 -0
  52. package/templates/saas/src/client/components/ui/drawer.tsx +124 -0
  53. package/templates/saas/src/client/components/ui/dropdown-menu.tsx +226 -0
  54. package/templates/saas/src/client/components/ui/empty.tsx +94 -0
  55. package/templates/saas/src/client/components/ui/field.tsx +232 -0
  56. package/templates/saas/src/client/components/ui/form.tsx +152 -0
  57. package/templates/saas/src/client/components/ui/hover-card.tsx +38 -0
  58. package/templates/saas/src/client/components/ui/input-group.tsx +158 -0
  59. package/templates/saas/src/client/components/ui/input-otp.tsx +68 -0
  60. package/templates/saas/src/client/components/ui/input.tsx +21 -0
  61. package/templates/saas/src/client/components/ui/item.tsx +172 -0
  62. package/templates/saas/src/client/components/ui/kbd.tsx +28 -0
  63. package/templates/saas/src/client/components/ui/label.tsx +21 -0
  64. package/templates/saas/src/client/components/ui/menubar.tsx +250 -0
  65. package/templates/saas/src/client/components/ui/navigation-menu.tsx +161 -0
  66. package/templates/saas/src/client/components/ui/pagination.tsx +106 -0
  67. package/templates/saas/src/client/components/ui/popover.tsx +42 -0
  68. package/templates/saas/src/client/components/ui/progress.tsx +26 -0
  69. package/templates/saas/src/client/components/ui/radio-group.tsx +45 -0
  70. package/templates/saas/src/client/components/ui/resizable.tsx +46 -0
  71. package/templates/saas/src/client/components/ui/scroll-area.tsx +56 -0
  72. package/templates/saas/src/client/components/ui/select.tsx +173 -0
  73. package/templates/saas/src/client/components/ui/separator.tsx +28 -0
  74. package/templates/saas/src/client/components/ui/sheet.tsx +128 -0
  75. package/templates/saas/src/client/components/ui/sidebar.tsx +694 -0
  76. package/templates/saas/src/client/components/ui/skeleton.tsx +13 -0
  77. package/templates/saas/src/client/components/ui/slider.tsx +58 -0
  78. package/templates/saas/src/client/components/ui/sonner.tsx +38 -0
  79. package/templates/saas/src/client/components/ui/spinner.tsx +16 -0
  80. package/templates/saas/src/client/components/ui/switch.tsx +28 -0
  81. package/templates/saas/src/client/components/ui/table.tsx +90 -0
  82. package/templates/saas/src/client/components/ui/tabs.tsx +54 -0
  83. package/templates/saas/src/client/components/ui/textarea.tsx +18 -0
  84. package/templates/saas/src/client/components/ui/toggle-group.tsx +80 -0
  85. package/templates/saas/src/client/components/ui/toggle.tsx +44 -0
  86. package/templates/saas/src/client/components/ui/tooltip.tsx +57 -0
  87. package/templates/saas/src/client/hooks/use-mobile.ts +19 -0
  88. package/templates/saas/src/client/hooks/useAuth.ts +14 -0
  89. package/templates/saas/src/client/hooks/useSubscription.ts +86 -0
  90. package/templates/saas/src/client/index.css +165 -0
  91. package/templates/saas/src/client/lib/auth-client.ts +7 -0
  92. package/templates/saas/src/client/lib/plans.ts +82 -0
  93. package/templates/saas/src/client/lib/utils.ts +6 -0
  94. package/templates/saas/src/client/main.tsx +15 -0
  95. package/templates/saas/src/client/pages/DashboardPage.tsx +394 -0
  96. package/templates/saas/src/client/pages/ForgotPasswordPage.tsx +153 -0
  97. package/templates/saas/src/client/pages/HomePage.tsx +285 -0
  98. package/templates/saas/src/client/pages/LoginPage.tsx +169 -0
  99. package/templates/saas/src/client/pages/PricingPage.tsx +467 -0
  100. package/templates/saas/src/client/pages/ResetPasswordPage.tsx +200 -0
  101. package/templates/saas/src/client/pages/SignupPage.tsx +192 -0
  102. package/templates/saas/src/index.ts +208 -0
  103. package/templates/saas/tsconfig.json +18 -0
  104. package/templates/saas/vite.config.ts +14 -0
  105. 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
+ &copy; {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
+ }