Remove mock buttons, wrap some content in collapsibles
+144
-347
Diff
round #0
-158
PDS-INTEGRATION-NOTES.md
-158
PDS-INTEGRATION-NOTES.md
···
1
-
## PDS integration overview
2
-
3
-
### End-to-end flow
4
-
5
-
- **Stripe checkout**
6
-
- User selects a plan on the pricing page.
7
-
- Plan context is passed via URL params (`auto_checkout`, `pds_plan`, `pds_disksize_gb`, etc.) through `/signup` or `/login` to `/dashboard`.
8
-
- `DashboardClient` calls `createSubscriptionCheckout(priceId, options)` with:
9
-
- `username` (optional, normalized)
10
-
- `hostname` (optional, cleaned; falls back to `<username>.eny.k8s.frx.pub`)
11
-
- `disksizeGb` (derived from plan or override)
12
-
- `createSubscriptionCheckout` encodes this into Stripe Checkout metadata:
13
-
- `user_id`, `user_email`
14
-
- `pds_username`
15
-
- `pds_hostname_base`
16
-
- `pds_disksize_gb`
17
-
18
-
- **Stripe webhook → provisioning**
19
-
- Stripe sends `checkout.session.completed` to `/api/webhooks` (Vercel URL, optionally with `x-vercel-protection-bypass` query param).
20
-
- The webhook:
21
-
- Verifies the event using `STRIPE_WEBHOOK_SECRET`.
22
-
- Stores `user_id ↔ stripe_customer_id` in `subscriptions` (minimal mapping).
23
-
- Derives provisioning parameters:
24
-
- `pds_username` (normalized from metadata or `user_email`)
25
-
- `pds_hostname_base` (metadata or `<username>.eny.k8s.frx.pub`)
26
-
- `disksizeGb` (metadata or `"10"`).
27
-
- Calls `provisionPdsForUser` with:
28
-
- `userId`, `userEmail`
29
-
- `pdsUsername`, `pdsHostnameBase`, `disksizeGb`.
30
-
31
-
- **`provisionPdsForUser`**
32
-
- Uses Supabase admin client (`NEXT_PUBLIC_SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`) to read/write `pds_services`.
33
-
- Idempotency:
34
-
- If `pds_services` already has a row with a non-null `pds_service_id`, it skips redeploy.
35
-
- If `status` is **not** in `{ "deploy_failed", "deploy_succeeded_no_id" }`, it also skips redeploy.
36
-
- Otherwise it proceeds to deploy again.
37
-
- Calls `POST https://k8s-pds.frx.pub/api/v1/deploy` with Bearer **`PDS_API_TOKEN`** and JSON body:
38
-
39
-
```json
40
-
{
41
-
"username": "<pdsUsername>",
42
-
"password": "<generated base64url>",
43
-
"email": "<userEmail>",
44
-
"hostname": "<pdsHostnameBase>",
45
-
"disksize": <number in GiB>
46
-
}
47
-
```
48
-
49
-
- Backend currently expects `hostname` to be a bare FQDN (no scheme, no path).
50
-
51
-
- Expects a 2xx response with JSON containing an id somewhere. Current backend returns:
52
-
53
-
```json
54
-
{
55
-
"...": "...",
56
-
"serviceId": 800
57
-
}
58
-
```
59
-
60
-
- The code extracts `maybeServiceId` from (in order):
61
-
- `service_id`
62
-
- `serviceId`
63
-
- `id`
64
-
- `service.id`
65
-
- `data.id`
66
-
- `data.serviceId`
67
-
- It upserts into `pds_services`:
68
-
- `user_id`
69
-
- `pds_service_id` (number or `null`)
70
-
- `hostname`
71
-
- `status`:
72
-
- `"provisioning"` when `pds_service_id` is set.
73
-
- `"deploy_succeeded_no_id"` when deploy succeeded but no id was found.
74
-
- `"deploy_failed"` on deploy error.
75
-
76
-
### Database schema (`pds_services`)
77
-
78
-
```sql
79
-
CREATE TABLE IF NOT EXISTS pds_services (
80
-
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
81
-
pds_service_id BIGINT,
82
-
hostname TEXT,
83
-
status TEXT NOT NULL DEFAULT 'provisioning',
84
-
created_at TIMESTAMPTZ DEFAULT NOW(),
85
-
updated_at TIMESTAMPTZ DEFAULT NOW()
86
-
);
87
-
88
-
ALTER TABLE pds_services ENABLE ROW LEVEL SECURITY;
89
-
90
-
DROP POLICY IF EXISTS "Users can view own pds services" ON pds_services;
91
-
CREATE POLICY "Users can view own pds services"
92
-
ON pds_services
93
-
FOR SELECT
94
-
USING (auth.uid() = user_id);
95
-
96
-
CREATE OR REPLACE FUNCTION update_pds_services_updated_at_column()
97
-
RETURNS TRIGGER AS $$
98
-
BEGIN
99
-
NEW.updated_at = NOW();
100
-
RETURN NEW;
101
-
END;
102
-
$$ language 'plpgsql';
103
-
104
-
DROP TRIGGER IF EXISTS update_pds_services_updated_at ON pds_services;
105
-
CREATE TRIGGER update_pds_services_updated_at
106
-
BEFORE UPDATE ON pds_services
107
-
FOR EACH ROW
108
-
EXECUTE FUNCTION update_pds_services_updated_at_column();
109
-
```
110
-
111
-
- One PDS per `user_id` (PK on `user_id`).
112
-
- `status` values used today: `"provisioning"`, `"deploy_failed"`, `"deploy_succeeded_no_id"`.
113
-
114
-
### PDS service fetch (`app/api/pds/service/route.ts`)
115
-
116
-
- Authenticates the current user with the normal Supabase client.
117
-
- Reads `pds_services` row for `user_id`:
118
-
- If no row or `pds_service_id` is `null`, returns `404` with a simple message.
119
-
- Builds `GET` URL to PDS API:
120
-
121
-
```text
122
-
GET https://k8s-pds.frx.pub/api/v1/service/{pds_service_id}
123
-
```
124
-
125
-
- Sends:
126
-
- `Accept: application/json`
127
-
- `X-Requested-With: XMLHttpRequest`
128
-
- `Authorization: Bearer PDS_API_TOKEN`
129
-
- If `content-type` is JSON:
130
-
- Parses body.
131
-
- Handles double-encoded JSON strings by `JSON.parse` when possible.
132
-
- On non-2xx, returns `502` with `{ error, status, body }` for debugging.
133
-
- On 2xx, returns the JSON as-is to the dashboard.
134
-
- If non-JSON, returns `502` with `{ error, status, contentType, bodyPreview }`.
135
-
136
-
### Dashboard UI
137
-
138
-
- `Usage summary` section uses `ServiceDetailsClient mode="stats"` and is fed from `/api/pds/service` (or mock when `PDS_USE_MOCK=true`).
139
-
- Details section uses `ServiceDetailsClient mode="details"`:
140
-
- Shows `id`, `service`, `namespace`, `state`, `kubeconfig_id`.
141
-
- Shows `encrypted_config` fields (hostname, email settings, storage size) with `adminPassword` masked.
142
-
- Shows `install_cmd` and timestamps.
143
-
144
-
### Env vars (server vs client)
145
-
146
-
- **Server-only (secret)**:
147
-
- `SUPABASE_SERVICE_ROLE_KEY`
148
-
- `STRIPE_SECRET_KEY`
149
-
- `STRIPE_WEBHOOK_SECRET`
150
-
- `PDS_API_TOKEN`
151
-
152
-
- **Client-safe (`NEXT_PUBLIC_*`)**:
153
-
- `NEXT_PUBLIC_SUPABASE_URL`
154
-
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
155
-
- `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`
156
-
- `NEXT_PUBLIC_STRIPE_PRICE_ID`
157
-
158
-
These are configured in Vercel for **all environments** (or at least Preview/Production). Webhook and PDS routes only use the server-only keys.
-63
app/api/server/[endpoint]/route.ts
-63
app/api/server/[endpoint]/route.ts
···
1
-
import { NextResponse } from "next/server";
2
-
import { createClient } from "@/lib/supabase/server";
3
-
import { verifyActiveSubscription } from "@/actions/subscription";
4
-
5
-
export async function POST(
6
-
req: Request,
7
-
{ params }: { params: Promise<{ endpoint: string }> }
8
-
) {
9
-
const supabase = await createClient();
10
-
const {
11
-
data: { user },
12
-
} = await supabase.auth.getUser();
13
-
14
-
if (!user) {
15
-
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
16
-
}
17
-
18
-
// Always verify subscription status directly from Stripe (source of truth)
19
-
const { active } = await verifyActiveSubscription();
20
-
21
-
if (!active) {
22
-
return NextResponse.json(
23
-
{ message: "Active subscription required" },
24
-
{ status: 403 }
25
-
);
26
-
}
27
-
28
-
const { endpoint } = await params;
29
-
30
-
// Here you would make the actual call to your other server
31
-
// For now, this is a placeholder that you can customize
32
-
try {
33
-
// Example: Make a call to your external server
34
-
// const externalServerUrl = process.env.EXTERNAL_SERVER_URL;
35
-
// const response = await fetch(`${externalServerUrl}/${endpoint}`, {
36
-
// method: "POST",
37
-
// headers: {
38
-
// "Authorization": `Bearer ${userToken}`,
39
-
// "Content-Type": "application/json",
40
-
// },
41
-
// body: JSON.stringify({ userId: user.id }),
42
-
// });
43
-
// const data = await response.json();
44
-
45
-
// Placeholder response
46
-
return NextResponse.json({
47
-
success: true,
48
-
endpoint,
49
-
message: `Server call to ${endpoint} successful`,
50
-
userId: user.id,
51
-
timestamp: new Date().toISOString(),
52
-
});
53
-
} catch (error) {
54
-
console.error("Error making server call:", error);
55
-
return NextResponse.json(
56
-
{
57
-
message: "Failed to make server call",
58
-
error: error instanceof Error ? error.message : "Unknown error",
59
-
},
60
-
{ status: 500 }
61
-
);
62
-
}
63
-
}
+33
app/dashboard/collapsible-section.tsx
+33
app/dashboard/collapsible-section.tsx
···
1
+
"use client";
2
+
3
+
import { cn } from "@/actions/lib/utils";
4
+
import { ChevronDownIcon } from "lucide-react";
5
+
6
+
interface CollapsibleSectionProps {
7
+
title: string;
8
+
children: React.ReactNode;
9
+
defaultOpen?: boolean;
10
+
className?: string;
11
+
}
12
+
13
+
export function CollapsibleSection({
14
+
title,
15
+
children,
16
+
defaultOpen = false,
17
+
className,
18
+
}: CollapsibleSectionProps) {
19
+
return (
20
+
<details
21
+
open={defaultOpen}
22
+
className={cn("group rounded-md border border-white/10 bg-white/5 backdrop-blur-xl", className)}
23
+
>
24
+
<summary className="flex cursor-pointer select-none list-none items-center justify-between px-4 py-3 text-sm font-semibold uppercase tracking-wide text-white/80 hover:text-white transition-colors">
25
+
{title}
26
+
<ChevronDownIcon className="size-4 transition-transform group-open:rotate-180" />
27
+
</summary>
28
+
<div className="border-t border-white/10 px-4 py-4">
29
+
{children}
30
+
</div>
31
+
</details>
32
+
);
33
+
}
-47
app/dashboard/dashboard-client.tsx
-47
app/dashboard/dashboard-client.tsx
···
88
88
}
89
89
}, [autoCheckoutFromPlan, subscribed, loading]);
90
90
91
-
const handleServerCall = async (endpoint: string) => {
92
-
try {
93
-
const response = await fetch(`/api/server/${endpoint}`, {
94
-
method: "POST",
95
-
});
96
-
97
-
if (!response.ok) {
98
-
const error = await response.json();
99
-
throw new Error(error.message || "Failed to make server call");
100
-
}
101
-
102
-
const data = await response.json();
103
-
alert(`Success: ${JSON.stringify(data, null, 2)}`);
104
-
} catch (error) {
105
-
console.error("Error making server call:", error);
106
-
alert(
107
-
`Error: ${error instanceof Error ? error.message : "Unknown error"}`,
108
-
);
109
-
}
110
-
};
111
-
112
91
const hasSubscription = !!subscription;
113
92
const isCanceled =
114
93
subscription?.status === "canceled" || subscription?.status === "past_due";
···
294
273
</div>
295
274
</div>
296
275
297
-
<hr className="my-4 border-white/10" />
298
-
299
-
<div className="space-y-2">
300
-
<Heading as="h3" className="text-sm font-semibold text-white">
301
-
Server Actions
302
-
</Heading>
303
-
<Paragraph className="text-sm text-white/80">
304
-
You have access to the following server endpoints:
305
-
</Paragraph>
306
-
<div className="flex flex-wrap gap-2 pt-1">
307
-
<Button
308
-
variant="outline"
309
-
className="rounded-full border-white/60 bg-transparent px-4 text-xs font-medium uppercase tracking-wide text-white hover:bg-white/10"
310
-
onClick={() => handleServerCall("action1")}
311
-
>
312
-
Call Server Action 1
313
-
</Button>
314
-
<Button
315
-
variant="outline"
316
-
className="rounded-full border-white/60 bg-transparent px-4 text-xs font-medium uppercase tracking-wide text-white hover:bg-white/10"
317
-
onClick={() => handleServerCall("action2")}
318
-
>
319
-
Call Server Action 2
320
-
</Button>
321
-
</div>
322
-
</div>
323
276
</div>
324
277
);
325
278
}
+40
-53
app/dashboard/page.tsx
+40
-53
app/dashboard/page.tsx
···
5
5
Card,
6
6
CardContent,
7
7
CardHeader,
8
-
CardTitle,
9
-
CardDescription,
10
8
} from "@/actions/components/ui/card";
11
9
import { ButtonLink } from "@/components/button-link";
12
10
import { Heading } from "@/components/heading";
···
14
12
import DashboardClient from "./dashboard-client";
15
13
import { ServiceDetailsClient } from "./service-details-client";
16
14
import { AtprotoTestClient } from "./atproto-test-client";
15
+
import { CollapsibleSection } from "./collapsible-section";
17
16
import { prelaunch } from "@/lib/prelaunch";
18
17
import { getPriceIdForPlan } from "@/lib/stripe-plans";
19
18
20
19
type DashboardPageProps = {
21
-
searchParams?: {
20
+
searchParams?: Promise<{
22
21
auto_checkout?: string;
23
22
pds_plan?: string;
24
23
pds_username?: string;
25
24
pds_hostname?: string;
26
25
pds_disksize_gb?: string;
27
-
};
26
+
}>;
28
27
};
29
28
30
29
export default async function DashboardPage({ searchParams }: DashboardPageProps) {
30
+
const params = await searchParams;
31
31
const supabase = await createClient();
32
32
const {
33
33
data: { user },
···
39
39
40
40
const { subscribed, subscription } = await getSubscriptionStatus();
41
41
42
-
// During prelaunch we only collect signups and notify them on launch.
43
-
// Prevent non-subscribed users from reaching the dashboard subscribe UI.
44
42
if (prelaunch && !subscribed) {
45
43
redirect("/welcome");
46
44
}
47
45
48
-
// Simple stubbed PDS status derived from subscription state
49
46
const pdsStatus = subscribed ? "active" : "provisioning";
50
47
const pdsHostname =
51
48
user.email?.split("@")[0]?.toLowerCase().replace(/[^a-z0-9-]/g, "-") +
···
53
50
const pdsDashboardUrl = `https://${pdsHostname}`;
54
51
55
52
return (
56
-
<main className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6 sm:px-6">
53
+
<main className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-6 sm:px-6">
54
+
{/* Overview — always visible */}
57
55
<Card>
58
56
<CardHeader>
59
57
<Heading as="h1" className="text-xl font-semibold text-white">
60
58
My PDS
61
59
</Heading>
62
60
<Paragraph className="text-sm text-white/80">
63
-
Authenticated as {user.email}. This is your Personal Data Server
64
-
overview.
61
+
Authenticated as {user.email}.
65
62
</Paragraph>
66
63
</CardHeader>
67
64
<CardContent className="space-y-4">
68
65
<div className="grid gap-4 md:grid-cols-2 text-white">
69
-
<div className="space-y-2">
70
-
<Paragraph className="text-sm font-medium text-white/80">
71
-
Status
72
-
</Paragraph>
73
-
<Paragraph className="text-base font-semibold capitalize">
74
-
{pdsStatus}
75
-
</Paragraph>
66
+
<div className="space-y-1">
67
+
<Paragraph className="text-sm font-medium text-white/60">Status</Paragraph>
68
+
<Paragraph className="text-base font-semibold capitalize">{pdsStatus}</Paragraph>
76
69
</div>
77
-
<div className="space-y-2">
78
-
<Paragraph className="text-sm font-medium text-white/80">
79
-
URL / Hostname
80
-
</Paragraph>
70
+
<div className="space-y-1">
71
+
<Paragraph className="text-sm font-medium text-white/60">Hostname</Paragraph>
81
72
<a
82
73
href={pdsDashboardUrl}
83
74
target="_blank"
···
88
79
</a>
89
80
</div>
90
81
</div>
91
-
92
-
<div className="mt-4 space-y-2 rounded-md border border-white/10 bg-white/5 p-4 text-white backdrop-blur-xl">
93
-
<Paragraph className="text-sm font-medium text-white/80">
94
-
Usage summary
95
-
</Paragraph>
96
-
<ServiceDetailsClient mode="stats" />
97
-
</div>
98
-
99
-
<div className="mt-4 flex flex-wrap gap-3">
82
+
<div className="flex flex-wrap gap-3 pt-1">
100
83
<ButtonLink
101
84
href={pdsDashboardUrl}
102
85
className="border border-white/80 bg-transparent uppercase tracking-wide text-white hover:bg-white/10 hover:border-white focus-visible:ring-white/50"
···
104
87
Open dashboard
105
88
</ButtonLink>
106
89
</div>
90
+
</CardContent>
91
+
</Card>
107
92
108
-
<hr className="my-6" />
93
+
{/* Usage & Stats */}
94
+
<CollapsibleSection title="Usage & Stats">
95
+
<ServiceDetailsClient mode="stats" />
96
+
</CollapsibleSection>
109
97
110
-
<ServiceDetailsClient mode="details" />
98
+
{/* Service Details */}
99
+
<CollapsibleSection title="Service Details">
100
+
<ServiceDetailsClient mode="details" />
101
+
</CollapsibleSection>
111
102
112
-
<AtprotoTestClient />
103
+
{/* AT Protocol */}
104
+
<CollapsibleSection title="AT Protocol">
105
+
<AtprotoTestClient />
106
+
</CollapsibleSection>
113
107
114
-
<section className="space-y-2 text-white">
115
-
<Heading
116
-
as="h2"
117
-
className="text-sm font-semibold uppercase tracking-wide text-white/80"
118
-
>
119
-
Billing & Subscription
120
-
</Heading>
121
-
<DashboardClient
122
-
subscribed={subscribed}
123
-
subscription={subscription}
124
-
priceId={getPriceIdForPlan(searchParams?.pds_plan)}
125
-
autoCheckoutFromPlan={searchParams?.auto_checkout === "1"}
126
-
pdsPlan={searchParams?.pds_plan}
127
-
pdsUsername={searchParams?.pds_username}
128
-
pdsHostname={searchParams?.pds_hostname}
129
-
pdsDisksizeGb={searchParams?.pds_disksize_gb}
130
-
/>
131
-
</section>
132
-
</CardContent>
133
-
</Card>
108
+
{/* Billing & Subscription */}
109
+
<CollapsibleSection title="Billing & Subscription" defaultOpen>
110
+
<DashboardClient
111
+
subscribed={subscribed}
112
+
subscription={subscription}
113
+
priceId={getPriceIdForPlan(params?.pds_plan)}
114
+
autoCheckoutFromPlan={params?.auto_checkout === "1"}
115
+
pdsPlan={params?.pds_plan}
116
+
pdsUsername={params?.pds_username}
117
+
pdsHostname={params?.pds_hostname}
118
+
pdsDisksizeGb={params?.pds_disksize_gb}
119
+
/>
120
+
</CollapsibleSection>
134
121
</main>
135
122
);
136
123
}
+23
-11
app/actions/auth.ts
+23
-11
app/actions/auth.ts
···
13
13
const email = formData.get("email") as string;
14
14
const password = formData.get("password") as string;
15
15
16
-
const { error } = await supabase.auth.signUp({
17
-
email,
18
-
password,
19
-
options: {
20
-
emailRedirectTo: `${
21
-
process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"
22
-
}/auth/callback`,
23
-
},
24
-
});
16
+
try {
17
+
const { error } = await supabase.auth.signUp({
18
+
email,
19
+
password,
20
+
options: {
21
+
emailRedirectTo: `${
22
+
process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"
23
+
}/auth/callback`,
24
+
},
25
+
});
25
26
26
-
if (error) {
27
-
return { error: error.message };
27
+
if (error) {
28
+
return { error: error.message };
29
+
}
30
+
} catch (err) {
31
+
const cause = (err as any)?.cause;
32
+
const isTimeout =
33
+
cause?.code === "UND_ERR_CONNECT_TIMEOUT" ||
34
+
(err as Error)?.message === "fetch failed";
35
+
return {
36
+
error: isTimeout
37
+
? "Could not reach the server. Please check your connection and try again."
38
+
: "An unexpected error occurred. Please try again.",
39
+
};
28
40
}
29
41
30
42
return { success: true };
+18
-13
app/api/pds/service/route.ts
+18
-13
app/api/pds/service/route.ts
···
37
37
const failedRequestsLast24h = 42;
38
38
const successfulRequestsLast24h = requestsPerHourLast24h.reduce(
39
39
(sum, p) => sum + p.count,
40
-
0,
40
+
0
41
41
);
42
42
43
43
return {
···
102
102
error:
103
103
"Missing PDS_API_TOKEN env variable for authenticating with PDS API",
104
104
},
105
-
{ status: 500 },
105
+
{ status: 500 }
106
106
);
107
107
}
108
108
···
121
121
.eq("user_id", user.id)
122
122
.maybeSingle();
123
123
124
-
const forcedServiceIdRaw = process.env.PDS_FORCE_SERVICE_ID === "true"
125
-
? process.env.PDS_TEST_SERVICE_ID
126
-
: undefined;
127
-
const forcedServiceId = forcedServiceIdRaw ? Number(forcedServiceIdRaw) : null;
124
+
const forcedServiceIdRaw =
125
+
process.env.PDS_FORCE_SERVICE_ID === "true"
126
+
? process.env.PDS_TEST_SERVICE_ID
127
+
: undefined;
128
+
const forcedServiceId = forcedServiceIdRaw
129
+
? Number(forcedServiceIdRaw)
130
+
: null;
128
131
129
-
const pdsServiceId = (forcedServiceId !== null && Number.isFinite(forcedServiceId)
130
-
? forcedServiceId
131
-
: pdsServiceRow?.pds_service_id) as number | null | undefined;
132
+
const pdsServiceId = (
133
+
forcedServiceId !== null && Number.isFinite(forcedServiceId)
134
+
? forcedServiceId
135
+
: pdsServiceRow?.pds_service_id
136
+
) as number | null | undefined;
132
137
133
138
if (!pdsServiceId) {
134
139
return NextResponse.json(
135
140
{ message: "No provisioned PDS found for this user yet" },
136
-
{ status: 404 },
141
+
{ status: 404 }
137
142
);
138
143
}
139
144
···
171
176
status: res.status,
172
177
body: data,
173
178
},
174
-
{ status: 502 },
179
+
{ status: 502 }
175
180
);
176
181
}
177
182
···
204
209
contentType,
205
210
bodyPreview: bodyText.slice(0, 500),
206
211
},
207
-
{ status: 502 },
212
+
{ status: 502 }
208
213
);
209
214
} catch (error) {
210
215
console.error("Error proxying PDS service request", error);
···
213
218
error: "Failed to reach PDS service endpoint",
214
219
detail: error instanceof Error ? error.message : String(error),
215
220
},
216
-
{ status: 500 },
221
+
{ status: 500 }
217
222
);
218
223
}
219
224
}
+24
app/not-found.tsx
+24
app/not-found.tsx
···
1
+
import { Heading } from "@/components/heading";
2
+
import { ButtonLink } from "@/components/button-link";
3
+
4
+
export default function NotFound() {
5
+
return (
6
+
<div className="flex flex-col items-center justify-center gap-6 py-32 text-center">
7
+
<Heading className="text-5xl tracking-tight text-white sm:text-6xl md:text-7xl">
8
+
you've drifted past the heliopause.
9
+
</Heading>
10
+
<Heading as="h2" className="text-2xl text-white/50">
11
+
404
12
+
</Heading>
13
+
<p className="text-muted-foreground max-w-sm">
14
+
you've gone too far into the unknown. this page doesn't exist.
15
+
</p>
16
+
<ButtonLink
17
+
href="/"
18
+
className="bg-primary text-primary-foreground hover:bg-primary/90"
19
+
>
20
+
Go home
21
+
</ButtonLink>
22
+
</div>
23
+
);
24
+
}
+6
-2
app/signup/signup-form.tsx
+6
-2
app/signup/signup-form.tsx
···
28
28
e.preventDefault();
29
29
const formData = new FormData(e.currentTarget);
30
30
startTransition(async () => {
31
-
const result = await signUp(null, formData);
32
-
setState(result);
31
+
try {
32
+
const result = await signUp(null, formData);
33
+
setState(result);
34
+
} catch {
35
+
setState({ error: "An unexpected error occurred. Please try again." });
36
+
}
33
37
});
34
38
}
35
39
History
1 round
0 comments
samsour.de
submitted
#0
2 commits
expand
collapse
refactor(dashboard): clean up dashboard
feat: add 404 page and improve signup network error handling
- Add space-themed 404 page with heliopause headline
- Catch network/timeout errors in signUp action and return readable messages instead of letting fetch failures surface raw to the client
- Add try/catch in SignUpForm transition as a safety net
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
merge conflicts detected
expand
collapse
expand
collapse
- app/dashboard/dashboard-client.tsx:88
- app/dashboard/page.tsx:5