Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

playground.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

playground.tsxBlame715 lines · 2 contributors
cd4f63bTest User1/**
2 * Block Q3 — Anonymous playground routes.
3 *
4 * GET /play — landing page; one-button start.
5 * POST /play — mint a playground account, set the cookie,
6 * redirect to the sandbox.
7 * GET /play/claim — render the "Save your work" form. requireAuth.
8 * POST /play/claim — call claimPlaygroundAccount, redirect to dashboard.
9 *
10 * POST /play is rate-limited at 3/min/IP via the shared rate-limit
11 * middleware so a bot can't hammer the endpoint and mint accounts +
12 * repos by the thousand.
398a10cClaude13 *
14 * 2026 polish: gradient-hairline hero + orb, eyebrow + display headline,
15 * three explanation cards (what / try / examples) before the start
16 * button, polished claim form card with focus rings + gradient submit.
17 * All CSS scoped under `.play-*`.
cd4f63bTest User18 */
19
20import { Hono } from "hono";
21import { setCookie } from "hono/cookie";
22import { Layout } from "../views/layout";
23import { softAuth, requireAuth } from "../middleware/auth";
24import type { AuthEnv } from "../middleware/auth";
25import {
26 Form,
27} from "../views/ui";
28import { rateLimit } from "../middleware/rate-limit";
29import {
30 createPlaygroundAccount,
31 claimPlaygroundAccount,
32 PLAYGROUND_TTL_MS,
33} from "../lib/playground";
34import { sessionCookieOptions } from "../lib/auth";
35
36const playgroundRoutes = new Hono<AuthEnv>();
37
38// ── 3 req / min / IP cap on POST /play. The shared rate-limit
39// middleware no-ops in test env (so the 1756-test suite isn't fragile)
40// but enforces in prod / dev.
41const playgroundCreateRateLimit = rateLimit(3, 60_000, "playground-create");
42
398a10cClaude43// ─── Scoped CSS — all classes prefixed `.play-*` ───────────────────────────
44const playStyles = `
eed4684Claude45 .play-wrap { max-width: 1320px; margin: 0 auto; padding: var(--space-6, 32px) var(--space-4, 24px); }
398a10cClaude46
47 /* ─── Hero ─── */
48 .play-hero {
49 position: relative;
50 margin-bottom: var(--space-5);
51 padding: clamp(28px, 4vw, 48px) clamp(24px, 4vw, 48px);
52 background: var(--bg-elevated);
53 border: 1px solid var(--border);
54 border-radius: 18px;
55 overflow: hidden;
56 }
57 .play-hero::before {
58 content: '';
59 position: absolute;
60 top: 0; left: 0; right: 0;
61 height: 2px;
6fd5915Claude62 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude63 opacity: 0.75;
64 pointer-events: none;
65 }
66 .play-hero-orb {
67 position: absolute;
68 inset: -30% -10% auto auto;
69 width: 460px; height: 460px;
6fd5915Claude70 background: radial-gradient(circle, rgba(91,110,232,0.22), rgba(95,143,160,0.10) 45%, transparent 70%);
398a10cClaude71 filter: blur(80px);
72 opacity: 0.7;
73 pointer-events: none;
74 animation: playHeroOrb 14s ease-in-out infinite;
75 z-index: 0;
76 }
77 @keyframes playHeroOrb {
78 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
79 50% { transform: scale(1.08) translate(-12px, 8px); opacity: 0.85; }
80 }
81 @media (prefers-reduced-motion: reduce) {
82 .play-hero-orb { animation: none; }
83 }
84 .play-hero-inner { position: relative; z-index: 1; max-width: 720px; }
85 .play-eyebrow {
86 display: inline-flex;
87 align-items: center;
88 gap: 8px;
89 font-family: var(--font-mono);
90 font-size: 11px;
91 letter-spacing: 0.18em;
92 text-transform: uppercase;
93 color: var(--text-muted);
94 font-weight: 600;
95 margin-bottom: 16px;
96 }
97 .play-eyebrow-dot {
98 width: 8px; height: 8px;
99 border-radius: 9999px;
6fd5915Claude100 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
101 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
398a10cClaude102 }
103 .play-eyebrow strong { color: var(--accent); font-weight: 600; letter-spacing: 0.04em; }
104 .play-title {
105 font-family: var(--font-display);
106 font-size: clamp(32px, 5vw, 48px);
107 font-weight: 800;
108 letter-spacing: -0.030em;
109 line-height: 1.05;
110 margin: 0 0 var(--space-3);
111 color: var(--text-strong);
112 }
113 .play-title-grad {
6fd5915Claude114 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
398a10cClaude115 -webkit-background-clip: text;
116 background-clip: text;
117 -webkit-text-fill-color: transparent;
118 color: transparent;
119 }
120 .play-sub {
121 font-size: 16px;
122 color: var(--text-muted);
123 margin: 0;
124 line-height: 1.55;
125 max-width: 600px;
126 }
127
128 /* ─── Error banner ─── */
129 .play-error {
130 position: relative;
131 padding: 14px 16px 14px 44px;
132 margin-bottom: var(--space-5);
133 border-radius: 12px;
134 border: 1px solid rgba(248, 81, 73, 0.32);
135 background: linear-gradient(180deg, rgba(248,81,73,0.06) 0%, var(--bg-elevated) 100%);
136 color: var(--text);
137 font-size: 14px;
138 }
139 .play-error::before {
140 content: '';
141 position: absolute;
142 left: 14px; top: 18px;
143 width: 14px; height: 14px;
144 border-radius: 50%;
145 background: radial-gradient(circle, #f85149 30%, transparent 70%);
146 box-shadow: 0 0 10px rgba(248,81,73,0.5);
147 }
148
149 /* ─── Explanation cards (what / try / examples) ─── */
150 .play-cards {
151 display: grid;
152 grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
153 gap: var(--space-3);
154 margin-bottom: var(--space-5);
155 }
156 .play-card {
157 position: relative;
158 padding: var(--space-4);
159 background: var(--bg-elevated);
160 border: 1px solid var(--border);
161 border-radius: 14px;
162 display: flex;
163 flex-direction: column;
164 gap: 8px;
165 transition: border-color 150ms ease, transform 150ms ease, box-shadow 150ms ease;
166 }
167 .play-card:hover {
6fd5915Claude168 border-color: rgba(91,110,232,0.45);
398a10cClaude169 transform: translateY(-2px);
6fd5915Claude170 box-shadow: 0 10px 28px -10px rgba(91,110,232,0.30);
398a10cClaude171 }
172 .play-card-badge {
173 display: inline-flex;
174 align-items: center;
175 justify-content: center;
176 width: 28px; height: 28px;
177 border-radius: 8px;
6fd5915Claude178 background: linear-gradient(135deg, rgba(91,110,232,0.20), rgba(95,143,160,0.14));
398a10cClaude179 color: #c5b3ff;
6fd5915Claude180 border: 1px solid rgba(91,110,232,0.40);
398a10cClaude181 font-family: var(--font-display);
182 font-weight: 700;
183 font-size: 13px;
184 }
185 .play-card-title {
186 font-family: var(--font-display);
187 font-size: 16px;
188 font-weight: 700;
189 letter-spacing: -0.014em;
190 color: var(--text-strong);
191 margin: 0;
192 }
193 .play-card-body {
194 font-size: 13.5px;
195 color: var(--text-muted);
196 line-height: 1.55;
197 margin: 0;
198 }
199 .play-card-body code {
200 font-family: var(--font-mono);
201 font-size: 12px;
6fd5915Claude202 background: rgba(91,110,232,0.10);
398a10cClaude203 color: #c8b6ff;
204 padding: 1px 6px;
205 border-radius: 5px;
206 }
207
208 /* ─── Start panel (big CTA + bullets) ─── */
209 .play-start {
210 position: relative;
211 padding: clamp(24px, 3.5vw, 36px);
212 background: var(--bg-elevated);
213 border: 1px solid var(--border);
214 border-radius: 16px;
215 overflow: hidden;
216 margin-bottom: var(--space-5);
217 }
218 .play-start::before {
219 content: '';
220 position: absolute;
221 top: 0; left: 0; right: 0;
222 height: 2px;
6fd5915Claude223 background: linear-gradient(90deg, #5b6ee8 0%, #5f8fa0 50%, #5b6ee8 100%);
398a10cClaude224 opacity: 0.65;
225 pointer-events: none;
226 }
227 .play-start-inner {
228 display: flex;
229 gap: var(--space-5);
230 align-items: center;
231 flex-wrap: wrap;
232 }
233 .play-start-text { flex: 1; min-width: 260px; }
234 .play-start-eyebrow {
235 font-size: 11px;
236 font-family: var(--font-mono);
237 text-transform: uppercase;
238 letter-spacing: 0.18em;
239 color: var(--accent);
240 font-weight: 700;
241 margin-bottom: 6px;
242 }
243 .play-start-headline {
244 font-family: var(--font-display);
245 font-size: clamp(20px, 2.4vw, 26px);
246 font-weight: 700;
247 letter-spacing: -0.018em;
248 line-height: 1.18;
249 color: var(--text-strong);
250 margin: 0 0 8px;
251 }
252 .play-start-desc {
253 font-size: 14px;
254 color: var(--text-muted);
255 line-height: 1.55;
256 margin: 0;
257 }
258 .play-start-cta { flex-shrink: 0; }
259 .play-submit {
260 appearance: none;
6fd5915Claude261 border: 1px solid rgba(91,110,232,0.45);
262 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
398a10cClaude263 color: #fff;
264 padding: 14px 24px;
265 border-radius: 12px;
266 font-family: var(--font-display);
267 font-weight: 700;
268 font-size: 15px;
269 letter-spacing: -0.005em;
270 cursor: pointer;
271 text-decoration: none;
272 display: inline-flex; align-items: center; gap: 8px;
6fd5915Claude273 box-shadow: 0 10px 24px -10px rgba(91,110,232,0.55);
398a10cClaude274 transition: transform 140ms ease, box-shadow 140ms ease, filter 140ms ease;
275 }
276 .play-submit:hover {
277 transform: translateY(-1px);
6fd5915Claude278 box-shadow: 0 14px 28px -10px rgba(91,110,232,0.7);
398a10cClaude279 filter: brightness(1.06);
280 }
281 .play-submit:focus-visible {
6fd5915Claude282 outline: 3px solid rgba(91,110,232,0.45);
398a10cClaude283 outline-offset: 2px;
284 }
285 .play-bullets {
286 margin: var(--space-4) 0 0;
287 padding-left: 20px;
288 font-size: 13.5px;
289 line-height: 1.7;
290 color: var(--text);
291 }
292 .play-bullets li { margin-bottom: 4px; }
293 .play-bullets li strong { color: var(--text-strong); }
294 .play-bullets code {
295 font-family: var(--font-mono);
296 font-size: 12px;
6fd5915Claude297 background: rgba(91,110,232,0.10);
398a10cClaude298 color: #c8b6ff;
299 padding: 1px 6px;
300 border-radius: 5px;
301 }
302
303 /* ─── Footnote ─── */
304 .play-footnote {
305 text-align: center;
306 font-size: 13px;
307 color: var(--text-muted);
308 margin: var(--space-2) 0 0;
309 }
310 .play-footnote a { color: var(--accent); text-decoration: none; }
311 .play-footnote a:hover { text-decoration: underline; }
312
313 /* ─── Claim form card ─── */
314 .play-claim-wrap { max-width: 560px; margin: 0 auto; padding: var(--space-6, 32px) var(--space-4, 24px); }
315 .play-claim-card {
316 position: relative;
317 padding: clamp(24px, 3vw, 32px);
318 background: var(--bg-elevated);
319 border: 1px solid var(--border);
320 border-radius: 16px;
321 overflow: hidden;
322 }
323 .play-claim-card::before {
324 content: '';
325 position: absolute;
326 top: 0; left: 0; right: 0;
327 height: 2px;
6fd5915Claude328 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude329 opacity: 0.75;
330 pointer-events: none;
331 }
332 .play-claim-eyebrow {
333 font-size: 11px;
334 font-family: var(--font-mono);
335 text-transform: uppercase;
336 letter-spacing: 0.18em;
337 color: var(--accent);
338 font-weight: 700;
339 margin-bottom: 6px;
340 }
341 .play-claim-title {
342 font-family: var(--font-display);
343 font-size: clamp(22px, 3vw, 28px);
344 font-weight: 800;
345 letter-spacing: -0.020em;
346 line-height: 1.15;
347 color: var(--text-strong);
348 margin: 0 0 8px;
349 }
350 .play-claim-desc {
351 font-size: 14px;
352 color: var(--text-muted);
353 line-height: 1.55;
354 margin: 0 0 var(--space-4);
355 }
356 .play-claim-desc code {
357 font-family: var(--font-mono);
358 font-size: 12.5px;
359 background: var(--bg-secondary);
360 border: 1px solid var(--border);
361 border-radius: 5px;
362 padding: 1px 6px;
363 color: var(--text-strong);
364 }
365 .play-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: var(--space-3); }
366 .play-field-label {
367 font-size: 13px;
368 color: var(--text-strong);
369 font-weight: 600;
370 }
371 .play-field-input {
372 appearance: none;
373 width: 100%;
374 background: var(--bg);
375 border: 1px solid var(--border);
376 color: var(--text-strong);
377 border-radius: 10px;
378 padding: 10px 12px;
379 font-size: 14px;
380 font-family: inherit;
381 transition: border-color 140ms ease, box-shadow 140ms ease;
382 }
383 .play-field-input:focus {
384 outline: none;
385 border-color: var(--accent);
6fd5915Claude386 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
398a10cClaude387 }
388 .play-claim-actions {
389 display: flex;
390 gap: var(--space-2);
391 margin-top: var(--space-4);
392 align-items: center;
393 flex-wrap: wrap;
394 }
395 .play-claim-cancel {
396 font-size: 13px;
397 color: var(--text-muted);
398 text-decoration: none;
399 }
400 .play-claim-cancel:hover { color: var(--text-strong); }
401`;
402
cd4f63bTest User403// ---------------------------------------------------------------------------
404// GET /play — landing page
405// ---------------------------------------------------------------------------
406
407playgroundRoutes.get("/play", softAuth, (c) => {
408 const user = c.get("user");
409 const csrf = c.get("csrfToken") as string | undefined;
410 const err = c.req.query("error");
411 const hours = Math.round(PLAYGROUND_TTL_MS / (60 * 60 * 1000));
412 return c.html(
413 <Layout
414 title="Try Gluecron — no signup"
415 user={user ?? null}
416 description={`Try Gluecron for ${hours} hours, no signup. Get a sandbox repo and watch Claude work.`}
417 ogTitle="Try Gluecron — no signup"
418 ogDescription="A 24-hour public sandbox. Push, open issues, watch Claude work."
419 >
398a10cClaude420 <style dangerouslySetInnerHTML={{ __html: playStyles }} />
421 <div class="play-wrap">
422 {/* ─── Hero ─── */}
423 <div class="play-hero">
424 <div class="play-hero-orb" aria-hidden="true" />
425 <div class="play-hero-inner">
426 <div class="play-eyebrow">
427 <span class="play-eyebrow-dot" aria-hidden="true" />
428 <strong>Playground</strong> · no signup
cd4f63bTest User429 </div>
398a10cClaude430 <h1 class="play-title">
431 Try Gluecron for {hours} hours.{" "}
432 <span class="play-title-grad">No signup.</span>
433 </h1>
434 <p class="play-sub">
435 One click and you're inside the product — a public sandbox
436 repo, real git, real issues, Claude already working on the
437 first one. Decide later whether to keep it.
438 </p>
439 </div>
440 </div>
cd4f63bTest User441
398a10cClaude442 {err && (
443 <div class="play-error" role="alert">
444 {decodeURIComponent(err)}
445 </div>
446 )}
447
448 {/* ─── Explanation cards ─── */}
449 <div class="play-cards">
450 <div class="play-card">
451 <span class="play-card-badge" aria-hidden="true">1</span>
452 <h3 class="play-card-title">What you get</h3>
453 <p class="play-card-body">
454 A fresh public sandbox repo under a temporary{" "}
455 <code>guest-*</code> account. Real git, real issues, real
456 push — gone after {hours} hours unless you claim it.
457 </p>
458 </div>
459 <div class="play-card">
460 <span class="play-card-badge" aria-hidden="true">2</span>
461 <h3 class="play-card-title">Try this first</h3>
462 <p class="play-card-body">
463 Open an issue, label it <code>ai:build</code>, watch the
464 autopilot pick it up and open a PR within minutes. Then
465 review it like you would any other.
466 </p>
467 </div>
468 <div class="play-card">
469 <span class="play-card-badge" aria-hidden="true">3</span>
470 <h3 class="play-card-title">Push from your laptop</h3>
471 <p class="play-card-body">
472 Clone via HTTPS, push commits, watch the gate pipeline run.
473 Branch protection, AI review, auto-merge — all live in your
474 sandbox.
475 </p>
476 </div>
477 </div>
478
479 {/* ─── Start CTA ─── */}
480 <div class="play-start">
481 <div class="play-start-inner">
482 <div class="play-start-text">
483 <div class="play-start-eyebrow">Start the clock</div>
484 <h2 class="play-start-headline">
485 One click and you're in. No email, no card.
486 </h2>
487 <p class="play-start-desc">
488 We mint a temporary account, spin up your sandbox repo,
489 and seed the first issue. Took us about 800ms last test.
490 </p>
491 </div>
492 <div class="play-start-cta">
493 <Form
494 method="post"
495 action="/play"
496 csrfToken={csrf}
497 >
498 <button type="submit" class="play-submit">
499 Start playing &rarr;
500 </button>
501 </Form>
502 </div>
503 </div>
cd4f63bTest User504
505 <ul class="play-bullets" aria-label="What you get">
506 <li>
507 <strong>A sandbox repo</strong> &mdash; public, real git, real
508 push, real issues.
509 </li>
510 <li>
511 <strong>Claude is already working</strong> &mdash; one issue
512 is labelled <code>ai:build</code> so the autopilot picks it
513 up within minutes.
514 </li>
515 <li>
516 <strong>{hours} hours to try every feature</strong> &mdash;
517 gates, branch protection, AI review, the lot.
518 </li>
519 <li>
520 <strong>Save your work</strong> &mdash; one button converts
521 the playground account into a real one. Otherwise: poof.
522 </li>
523 </ul>
524 </div>
525
398a10cClaude526 <p class="play-footnote">
527 Already have an account?{" "}
528 <a href="/login">Sign in</a> or{" "}
529 <a href="/register">create one</a> the normal way.
530 </p>
531 </div>
cd4f63bTest User532 </Layout>
533 );
534});
535
536// ---------------------------------------------------------------------------
537// POST /play — mint
538// ---------------------------------------------------------------------------
539
540playgroundRoutes.post("/play", playgroundCreateRateLimit, async (c) => {
541 const ip =
542 c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
543 c.req.header("x-real-ip") ||
544 undefined;
545 let result;
546 try {
547 result = await createPlaygroundAccount({ requestIp: ip });
548 } catch (err) {
549 // createPlaygroundAccount is supposed to never throw, but if a
550 // freshly-deployed migration is missing or anything else goes
551 // sideways, fall back to a graceful redirect.
552 console.error("[playground] /play POST threw:", err);
553 return c.redirect(
554 "/play?error=Could+not+create+playground+account.+Try+again."
555 );
556 }
557
558 if (!result.sessionToken || !result.user.id) {
559 return c.redirect(
560 "/play?error=Could+not+create+playground+account.+Try+again."
561 );
562 }
563
564 // 24h cookie matches the playground TTL so the cookie can't outlive
565 // the account in the DB. `sessionCookieOptions()` defaults to 30d
566 // maxAge; override here.
567 const base = sessionCookieOptions();
568 setCookie(c, "session", result.sessionToken, {
569 ...base,
570 maxAge: Math.floor(PLAYGROUND_TTL_MS / 1000),
571 });
572
573 return c.redirect(
574 `/${result.user.username}/sandbox?welcome=1`
575 );
576});
577
578// ---------------------------------------------------------------------------
579// GET /play/claim — render the "Save your work" form
580// ---------------------------------------------------------------------------
581
582playgroundRoutes.get("/play/claim", requireAuth, (c) => {
583 const user = c.get("user")!;
584 const csrf = c.get("csrfToken") as string | undefined;
585 const err = c.req.query("error");
586
587 // Already-real users: bounce home with a hint.
588 if (!(user as any).isPlayground) {
589 return c.redirect("/dashboard?info=Your+account+is+already+saved");
590 }
591
592 return c.html(
593 <Layout title="Save your playground" user={user}>
398a10cClaude594 <style dangerouslySetInnerHTML={{ __html: playStyles }} />
595 <div class="play-claim-wrap">
596 <div class="play-claim-card">
597 <div class="play-claim-eyebrow">Claim your sandbox</div>
598 <h1 class="play-claim-title">Save your work</h1>
599 <p class="play-claim-desc">
600 Convert{" "}
601 <code>{user.username}</code>{" "}
602 into a permanent account. We'll send a verification link to your
603 email; nothing changes about the repo you've been working in.
604 </p>
605 {err && (
606 <div class="play-error" role="alert">
607 {decodeURIComponent(err)}
608 </div>
609 )}
610 <Form
611 method="post"
612 action="/play/claim"
613 csrfToken={csrf}
cd4f63bTest User614 >
398a10cClaude615 <div class="play-field">
616 <label class="play-field-label" for="email">Email</label>
617 <input
618 type="email"
619 id="email"
620 name="email"
621 required
622 placeholder="you@example.com"
623 autocomplete="email"
624 aria-label="Email"
625 class="play-field-input"
626 />
627 </div>
628 <div class="play-field">
629 <label class="play-field-label" for="password">Password</label>
630 <input
631 type="password"
632 id="password"
633 name="password"
634 required
635 minLength={8}
636 placeholder="Min 8 characters"
637 autocomplete="new-password"
638 aria-label="Password"
639 class="play-field-input"
640 />
641 </div>
642 <div class="play-field">
643 <label class="play-field-label" for="username">
644 Pick a new username (optional)
645 </label>
646 <input
647 type="text"
648 id="username"
649 name="username"
650 pattern="^[a-zA-Z0-9_-]+$"
651 minLength={2}
652 maxLength={39}
653 placeholder={user.username}
654 autocomplete="username"
655 class="play-field-input"
656 />
657 </div>
658 <div class="play-claim-actions">
659 <button type="submit" class="play-submit">
660 Save my account
661 </button>
662 <a href={`/${user.username}/sandbox`} class="play-claim-cancel">
663 Back to the sandbox
664 </a>
665 </div>
666 </Form>
667 </div>
cd4f63bTest User668 </div>
669 </Layout>
670 );
671});
672
673// ---------------------------------------------------------------------------
674// POST /play/claim — convert to real account
675// ---------------------------------------------------------------------------
676
677const CLAIM_REASON_TO_MSG: Record<string, string> = {
678 invalid_email: "That doesn't look like a valid email.",
679 password_too_short: "Password must be at least 8 characters.",
680 invalid_username:
681 "Usernames may only contain letters, numbers, hyphens and underscores (2–39 chars).",
682 email_taken: "That email is already registered.",
683 username_taken: "That username is already taken.",
684 not_a_playground_account: "Your account is already saved.",
685 user_not_found: "Account not found. Please sign in again.",
686 lookup_failed: "Service unavailable. Please try again.",
687 update_failed: "Service unavailable. Please try again.",
688};
689
690playgroundRoutes.post("/play/claim", requireAuth, async (c) => {
691 const user = c.get("user")!;
692 const body = await c.req.parseBody();
693 const email = String(body.email || "");
694 const password = String(body.password || "");
695 const usernameRaw = String(body.username || "").trim();
696
697 const result = await claimPlaygroundAccount(user.id, {
698 email,
699 password,
700 username: usernameRaw ? usernameRaw : undefined,
701 });
702
703 if (!result.ok) {
704 const msg =
705 (result.reason && CLAIM_REASON_TO_MSG[result.reason]) ||
706 "Could not save account. Try again.";
707 return c.redirect(
708 `/play/claim?error=${encodeURIComponent(msg)}`
709 );
710 }
711
712 return c.redirect("/dashboard?info=Account+saved.+Check+your+email+to+verify.");
713});
714
715export default playgroundRoutes;