eny.space Landingpage
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Clean up dashboard page #3

open opened by samsour.de targeting develop from feature/dashboard-cleanup

Remove mock buttons, wrap some content in collapsibles

Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:vmqt4a4pf5jxvtalzjz2zsqk/sh.tangled.repo.pull/3mkxdr2as7m22
+144 -347
Diff #0
-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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
sign up or login to add to the discussion
samsour.de submitted #0
2 commits
expand
refactor(dashboard): clean up dashboard
feat: add 404 page and improve signup network error handling
merge conflicts detected
expand
  • app/dashboard/dashboard-client.tsx:88
  • app/dashboard/page.tsx:5
expand 0 comments