diff --git a/.env.example b/.env.example index 646eadd..0d5069b 100644 --- a/.env.example +++ b/.env.example @@ -35,3 +35,17 @@ PYMTHOUSE_ALLOW_INSECURE_HTTP= # Invite-only Console HTML (/home, /usage, /keys, /calls, /settings). # Empty = no gate. Comma-separated emails, matched case-insensitively. # CONSOLE_EMAIL_ALLOWLIST=alice@livepeer.org,bob@studio.com + +# Waitlist + canonical users (Postgres migrations 0000-0005). +# Production must use the existing waitlist database. Previews and tests must +# use an isolated database branch; never point them at the production branch. +DATABASE_URL=postgresql://user:password@host/database?sslmode=require +ATTRIBUTION_HASH_SECRET=replace-with-at-least-32-random-bytes +RESEND_API_KEY=re_replace_me +RESEND_NEWSLETTER_SEGMENT_ID=replace-with-resend-segment-id +EMAIL_FROM=Livepeer Waitlist +EMAIL_REPLY_TO=help@example.com +INTERNAL_OUTBOX_SECRET=replace-with-at-least-32-random-bytes +NEXT_PUBLIC_SITE_URL=http://localhost:3000 +# Publishable token; analytics safely no-ops when unset. +NEXT_PUBLIC_POSTHOG_KEY= diff --git a/.gitignore b/.gitignore index 219d8bd..a636d5f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ # next.js /.next/ +/.next-cutover/ /out/ # production diff --git a/.prettierignore b/.prettierignore index bd5535a..72cb57f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,3 @@ pnpm-lock.yaml +.next/ +.next-cutover/ diff --git a/app/(app)/device/page.tsx b/app/(app)/device/page.tsx index 68b8817..e9c237e 100644 --- a/app/(app)/device/page.tsx +++ b/app/(app)/device/page.tsx @@ -1,6 +1,7 @@ import { redirect } from "next/navigation"; import { auth0 } from "@/lib/auth0"; +import { authLoginHref } from "@/lib/console/auth-login"; import { parseDeviceInitiateParams } from "@/lib/console/device-approval"; import DeviceApproveForm, { DevicePageChrome } from "./DeviceApproveForm"; @@ -18,20 +19,22 @@ export default async function DevicePage({ const params = await searchParams; const query = new URLSearchParams(); if (params.iss) query.set("iss", params.iss); - if (params.target_link_uri) query.set("target_link_uri", params.target_link_uri); + if (params.target_link_uri) + query.set("target_link_uri", params.target_link_uri); if (params.login_hint) query.set("login_hint", params.login_hint); const returnTo = `/device${query.size ? `?${query.toString()}` : ""}`; const session = await auth0.getSession(); if (!session?.user?.sub) { - redirect(`/auth/login?returnTo=${encodeURIComponent(returnTo)}`); + redirect(authLoginHref({ returnTo })); } let parsed; try { parsed = parseDeviceInitiateParams(query); } catch (error) { - const message = error instanceof Error ? error.message : "Invalid device request"; + const message = + error instanceof Error ? error.message : "Invalid device request"; return (

{message}

diff --git a/app/(app)/waitlist/page.tsx b/app/(app)/waitlist/page.tsx deleted file mode 100644 index b02a543..0000000 --- a/app/(app)/waitlist/page.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { auth0 } from "@/lib/auth0"; -import ConsolePageHeader from "@/components/console/ConsolePageHeader"; -import SectionHeader from "@/components/console/SectionHeader"; -import { consoleSignInHref } from "@/lib/console/auth-login"; -import { isEmailAllowlisted } from "@/lib/console/email-allowlist"; - -export const dynamic = "force-dynamic"; - -export default async function WaitlistPage() { - const session = await auth0.getSession(); - const email = session?.user?.email; - const listed = isEmailAllowlisted(email); - - return ( - <> - -
- - {email ? ( -

{email}

- ) : ( -

- - Sign in - {" "} - to join the waitlist with your account email. -

- )} - {listed ? ( -

- - Enter Console - -

- ) : null} -
- - ); -} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 9e07de7..b3655e1 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -2,6 +2,7 @@ import { redirect } from "next/navigation"; import { auth0 } from "@/lib/auth0"; import { authLoginHref, safeReturnTo } from "@/lib/console/auth-login"; import LoginPage from "@/components/console/LoginPage"; +import { syncCanonicalUserBestEffort } from "@/lib/identity/canonical-user"; import type { Metadata } from "next"; @@ -25,6 +26,14 @@ export default async function LoginRoute({ const session = await auth0.getSession(); if (session) { + const sub = session.user.sub?.trim(); + if (sub) { + await syncCanonicalUserBestEffort({ + sub, + email: session.user.email?.trim() || undefined, + emailVerified: session.user.email_verified === true, + }); + } redirect(mcpOauth ? MCP_CALLBACK_PATH : returnTo); } diff --git a/app/(auth)/signup/page.tsx b/app/(auth)/signup/page.tsx index b1dc73f..7034420 100644 --- a/app/(auth)/signup/page.tsx +++ b/app/(auth)/signup/page.tsx @@ -3,6 +3,7 @@ import { redirect } from "next/navigation"; import { auth0 } from "@/lib/auth0"; import { safeReturnTo } from "@/lib/console/auth-login"; import LoginPage from "@/components/console/LoginPage"; +import { syncCanonicalUserBestEffort } from "@/lib/identity/canonical-user"; export const metadata: Metadata = { title: "Sign up — Livepeer Early Access", @@ -16,6 +17,16 @@ export default async function SignupRoute({ const params = await searchParams; const returnTo = safeReturnTo(params.returnTo); const session = await auth0.getSession(); - if (session) redirect(returnTo); + if (session) { + const sub = session.user.sub?.trim(); + if (sub) { + await syncCanonicalUserBestEffort({ + sub, + email: session.user.email?.trim() || undefined, + emailVerified: session.user.email_verified === true, + }); + } + redirect(returnTo); + } return ; } diff --git a/app/(waitlist)/layout.tsx b/app/(waitlist)/layout.tsx new file mode 100644 index 0000000..42a9621 --- /dev/null +++ b/app/(waitlist)/layout.tsx @@ -0,0 +1,68 @@ +import type { Metadata } from "next"; +import localFont from "next/font/local"; + +const waitlistInter = localFont({ + src: [ + { + path: "../../assets/fonts/waitlist/InterVariable.woff2", + weight: "100 900", + style: "normal", + }, + { + path: "../../assets/fonts/waitlist/InterVariable-Italic.woff2", + weight: "100 900", + style: "italic", + }, + ], + variable: "--font-inter", +}); + +const waitlistFavorit = localFont({ + src: [ + { + path: "../../assets/fonts/waitlist/FavoritPro-Light.woff2", + weight: "300", + }, + { + path: "../../assets/fonts/waitlist/FavoritPro-Book.woff2", + weight: "350", + }, + { + path: "../../assets/fonts/waitlist/FavoritPro-Regular.woff2", + weight: "400", + }, + { + path: "../../assets/fonts/waitlist/FavoritPro-Medium.woff2", + weight: "500", + }, + { + path: "../../assets/fonts/waitlist/FavoritPro-Bold.woff2", + weight: "700", + }, + ], + variable: "--font-favorit", +}); + +const waitlistMono = localFont({ + src: "../../assets/fonts/waitlist/FavoritMono-Regular.woff2", + weight: "400", + variable: "--font-mono", +}); + +export const metadata: Metadata = { + title: "Join the waitlist — Livepeer Early Access", + description: + "Request early access to a faster way to build, run, and scale live video products.", +}; + +export default function WaitlistLayout({ + children, +}: Readonly<{ children: React.ReactNode }>) { + return ( +
+ {children} +
+ ); +} diff --git a/app/(waitlist)/waitlist/page.tsx b/app/(waitlist)/waitlist/page.tsx new file mode 100644 index 0000000..991de73 --- /dev/null +++ b/app/(waitlist)/waitlist/page.tsx @@ -0,0 +1,20 @@ +import { AgentScrollerPage } from "@/components/livepeer-ui/agent-scroller-page"; +import { + capabilities, + networkImages, +} from "@/components/livepeer-ui/frozen-content"; +import { WaitlistSessionProvider } from "@/components/livepeer-ui/waitlist-session"; +import { getCurrentWaitlistSession } from "@/lib/waitlist/current-session"; + +export default async function WaitlistPage() { + const initialSession = await getCurrentWaitlistSession(); + + return ( + + + + ); +} diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx new file mode 100644 index 0000000..f66cbe0 --- /dev/null +++ b/app/admin/layout.tsx @@ -0,0 +1,3 @@ +import WaitlistLayout from "@/app/(waitlist)/layout"; + +export default WaitlistLayout; diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..1fbe83a --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,119 @@ +import { redirect } from "next/navigation"; +import { Download } from "lucide-react"; + +import { LivepeerHeader } from "@/components/livepeer-ui/livepeer-header"; +import { buttonVariants } from "@/components/ui/button"; +import { + getAdminWaitlistRows, + getAdminWaitlistSummary, +} from "@/lib/waitlist/admin"; +import { getAdminSession } from "@/lib/waitlist/admin-auth"; +import { cn } from "@/lib/utils"; + +export const dynamic = "force-dynamic"; + +export default async function AdminPage() { + const admin = await getAdminSession(); + if (!admin) redirect("/waitlist"); + + const [rows, summary] = await Promise.all([ + getAdminWaitlistRows(), + getAdminWaitlistSummary(), + ]); + + return ( +
+ + + + + } + /> + +
+

+ Waitlist +

+ +
+ {[ + ["Total signups", summary.totalSignups], + ["Verified signups", summary.confirmedSignups], + ["Total verified referrals", summary.totalVerifiedReferrals], + ["Newsletter opt-ins", summary.newsletterSubscribers], + ].map(([label, value]) => ( +
+
{label}
+
+ {Number(value).toLocaleString()} +
+
+ ))} +
+ +
+ + + + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + + + ))} + +
EmailStatusReferred by + Verified referrals + + Pending referrals + PointsNewsletterJoined
{row.email}{row.status} + {row.referredByEmail ?? "—"} + + {row.verifiedReferrals} + + {row.pendingReferrals} + + {row.points} + + {row.marketingConsent ? "Subscribed" : "Not subscribed"} + + {row.firstSeenAt.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} +
+
+
+
+ ); +} diff --git a/app/api/admin/signups.csv/route.ts b/app/api/admin/signups.csv/route.ts new file mode 100644 index 0000000..d3c8399 --- /dev/null +++ b/app/api/admin/signups.csv/route.ts @@ -0,0 +1,57 @@ +import { getAdminWaitlistRows } from "@/lib/waitlist/admin"; +import { getAdminSession } from "@/lib/waitlist/admin-auth"; + +export const runtime = "nodejs"; + +function csvCell(value: string | number | boolean | null) { + let text = value === null ? "" : String(value); + if (/^[=+\-@]/.test(text)) text = `'${text}`; + return `"${text.replaceAll('"', '""')}"`; +} + +export async function GET() { + if (!(await getAdminSession())) { + return Response.json({ message: "Not found." }, { status: 404 }); + } + + const rows = await getAdminWaitlistRows(5000); + const header = [ + "email", + "status", + "newsletter_subscribed", + "referral_code", + "referred_by_email", + "verified_referrals", + "pending_referrals", + "points", + "first_seen_at", + "confirmed_at", + ]; + const lines = [ + header.map(csvCell).join(","), + ...rows.map((row) => + [ + row.email, + row.status, + row.marketingConsent, + row.referralCode, + row.referredByEmail, + row.verifiedReferrals, + row.pendingReferrals, + row.points, + row.firstSeenAt.toISOString(), + row.confirmedAt?.toISOString() ?? null, + ] + .map(csvCell) + .join(",") + ), + ]; + + return new Response(`\uFEFF${lines.join("\r\n")}\r\n`, { + headers: { + "cache-control": "private, no-store", + "content-disposition": 'attachment; filename="livepeer-waitlist.csv"', + "content-type": "text/csv; charset=utf-8", + }, + }); +} diff --git a/app/api/identity/sync/route.ts b/app/api/identity/sync/route.ts new file mode 100644 index 0000000..8fe12ae --- /dev/null +++ b/app/api/identity/sync/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { auth0 } from "@/lib/auth0"; +import { syncCanonicalUserBestEffort } from "@/lib/identity/canonical-user"; +import { safeIdentityReturnTo } from "@/lib/identity/sync-return"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + const returnTo = safeIdentityReturnTo( + request.nextUrl.searchParams.get("returnTo") + ); + const session = await auth0.getSession(); + const user = session?.user; + const sub = user?.sub?.trim(); + + if (!user || !sub) { + const login = new URL("/login", request.url); + login.searchParams.set("returnTo", returnTo); + return NextResponse.redirect(login); + } + + await syncCanonicalUserBestEffort({ + sub, + email: user.email?.trim() || undefined, + emailVerified: user.email_verified === true, + }); + return NextResponse.redirect(new URL(returnTo, request.url)); +} diff --git a/app/api/internal/outbox/route.ts b/app/api/internal/outbox/route.ts new file mode 100644 index 0000000..d5afc32 --- /dev/null +++ b/app/api/internal/outbox/route.ts @@ -0,0 +1,23 @@ +import { dispatchPendingOutbox } from "@/lib/email/outbox"; +import { isAuthorizedOutboxRequest } from "@/lib/email/internal-auth"; + +export const runtime = "nodejs"; + +export async function POST(request: Request) { + if (!isAuthorizedOutboxRequest(request)) { + return Response.json({ message: "Unauthorized." }, { status: 401 }); + } + + try { + const result = await dispatchPendingOutbox(); + return Response.json(result); + } catch (error) { + console.error("email_outbox_dispatch_failed", { + errorType: error instanceof Error ? error.name : "unknown", + }); + return Response.json( + { message: "Outbox dispatch failed." }, + { status: 503 } + ); + } +} diff --git a/app/api/logout/route.ts b/app/api/logout/route.ts new file mode 100644 index 0000000..a5a2b2f --- /dev/null +++ b/app/api/logout/route.ts @@ -0,0 +1,21 @@ +import { eq } from "drizzle-orm"; +import { cookies } from "next/headers"; + +import { getDb } from "@/lib/db"; +import { sessions } from "@/lib/db/schema"; +import { hashToken, SESSION_COOKIE } from "@/lib/waitlist/security"; + +export const runtime = "nodejs"; + +export async function POST() { + const cookieStore = await cookies(); + const rawToken = cookieStore.get(SESSION_COOKIE)?.value; + if (rawToken) { + await getDb() + .update(sessions) + .set({ revokedAt: new Date() }) + .where(eq(sessions.tokenHash, hashToken(rawToken))); + } + cookieStore.delete(SESSION_COOKIE); + return Response.json({ message: "Signed out." }); +} diff --git a/app/api/mcp/oauth/callback/route.ts b/app/api/mcp/oauth/callback/route.ts index 426ce46..1c4cd4c 100644 --- a/app/api/mcp/oauth/callback/route.ts +++ b/app/api/mcp/oauth/callback/route.ts @@ -2,11 +2,12 @@ import { NextRequest, NextResponse } from "next/server"; import { auth0 } from "@/lib/auth0"; import { externalUserIdFromSub } from "@/lib/console/external-user-id"; +import { syncCanonicalUserBestEffort } from "@/lib/identity/canonical-user"; import { issueAuthCode, parsePending, PKCE_COOKIE, - pkceCookieOptions + pkceCookieOptions, } from "@/lib/mcp/as"; export const runtime = "nodejs"; @@ -32,6 +33,13 @@ export async function GET(req: NextRequest) { const externalUserId = await externalUserIdFromSub(sub); const email = session.user.email?.trim(); + // MCP starts Auth0 directly, bypassing the UI login's reconciliation return. + // This must never gate the existing external-ID authorization-code flow. + await syncCanonicalUserBestEffort({ + sub, + email: email || undefined, + emailVerified: session.user.email_verified === true, + }); let code: string; try { code = issueAuthCode({ @@ -39,7 +47,7 @@ export async function GET(req: NextRequest) { codeChallenge: pending.codeChallenge, clientId: pending.clientId, externalUserId, - email: email || undefined + email: email || undefined, }); } catch { return clear; diff --git a/app/api/newsletter-consent/route.ts b/app/api/newsletter-consent/route.ts new file mode 100644 index 0000000..d55d110 --- /dev/null +++ b/app/api/newsletter-consent/route.ts @@ -0,0 +1,77 @@ +import { eq } from "drizzle-orm"; +import { cookies } from "next/headers"; +import { z } from "zod"; + +import { getDb } from "@/lib/db"; +import { consentEvents, emailOutbox, waitlistSignups } from "@/lib/db/schema"; +import { + dispatchOutboxEvent, + newsletterConsentOutboxValues, +} from "@/lib/email/outbox"; +import { NEWSLETTER_CONSENT_VERSION } from "@/lib/waitlist/contracts"; +import { getSignupForSession } from "@/lib/waitlist/queries"; +import { SESSION_COOKIE } from "@/lib/waitlist/security"; + +export const runtime = "nodejs"; + +const consentSchema = z.object({ newsletterOptIn: z.boolean() }); + +export async function PUT(request: Request) { + const rawToken = (await cookies()).get(SESSION_COOKIE)?.value; + const current = await getSignupForSession(rawToken); + if (!current) { + return Response.json( + { message: "Authentication required." }, + { status: 401 } + ); + } + + let parsed: z.infer; + try { + parsed = consentSchema.parse(await request.json()); + } catch { + return Response.json({ message: "Invalid preference." }, { status: 400 }); + } + + const outboxEventId = await getDb().transaction(async (tx) => { + await tx + .update(waitlistSignups) + .set({ marketingConsent: parsed.newsletterOptIn }) + .where(eq(waitlistSignups.id, current.signup.id)); + const [consentEvent] = await tx + .insert(consentEvents) + .values({ + signupId: current.signup.id, + purpose: "product_marketing", + granted: parsed.newsletterOptIn, + disclosureVersion: NEWSLETTER_CONSENT_VERSION, + source: "home_panel", + }) + .returning({ id: consentEvents.id }); + const [outboxEvent] = await tx + .insert(emailOutbox) + .values( + newsletterConsentOutboxValues({ + signupId: current.signup.id, + consentEventId: consentEvent.id, + email: current.signup.email, + subscribed: parsed.newsletterOptIn, + }) + ) + .onConflictDoNothing({ target: emailOutbox.idempotencyKey }) + .returning({ id: emailOutbox.id }); + return outboxEvent?.id; + }); + + if (outboxEventId) { + try { + await dispatchOutboxEvent(outboxEventId); + } catch (error) { + console.error("newsletter_immediate_dispatch_failed", { + errorType: error instanceof Error ? error.name : "unknown", + }); + } + } + + return Response.json({ newsletterOptIn: parsed.newsletterOptIn }); +} diff --git a/app/api/session/route.ts b/app/api/session/route.ts new file mode 100644 index 0000000..324d49f --- /dev/null +++ b/app/api/session/route.ts @@ -0,0 +1,14 @@ +import { getCurrentWaitlistSession } from "@/lib/waitlist/current-session"; + +export const runtime = "nodejs"; + +export async function GET() { + const session = await getCurrentWaitlistSession(); + if (!session) { + return Response.json( + { message: "Authentication required." }, + { status: 401 } + ); + } + return Response.json(session); +} diff --git a/app/api/waitlist/route.ts b/app/api/waitlist/route.ts new file mode 100644 index 0000000..64a0502 --- /dev/null +++ b/app/api/waitlist/route.ts @@ -0,0 +1,273 @@ +import { and, eq, isNull, sql } from "drizzle-orm"; +import { z } from "zod"; + +import { getDb } from "@/lib/db"; +import { + attributionTouches, + emailOutbox, + rateLimits, + verificationTokens, + waitlistSignups, +} from "@/lib/db/schema"; +import { getEnv } from "@/lib/env"; +import { + dispatchOutboxEvent, + VERIFICATION_EMAIL_EVENT, +} from "@/lib/email/outbox"; +import { + hashIdentifier, + hashToken, + normalizeEmail, + randomReferralCode, + randomToken, + VERIFICATION_TTL_MS, +} from "@/lib/waitlist/security"; + +export const runtime = "nodejs"; + +const GENERIC_MESSAGE = + "If that address can join, a verification link is on its way."; +const signupSchema = z.object({ + authOnly: z.boolean().default(false), + email: z.string().trim().email().max(320), + newsletterOptIn: z.boolean().default(false), + referralCode: z.string().trim().max(64).optional(), + company: z.string().max(0).optional(), + attribution: z.record(z.string(), z.string().max(500)).default({}), +}); + +function clientIp(request: Request) { + return ( + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + request.headers.get("x-real-ip") + ); +} + +async function withinRateLimit( + keyHash: string | null, + maximumAttempts: number +) { + if (!keyHash) return true; + const db = getDb(); + const bucket = new Date(Math.floor(Date.now() / 900_000) * 900_000); + const [row] = await db + .insert(rateLimits) + .values({ keyHash, bucket, attempts: 1 }) + .onConflictDoUpdate({ + target: [rateLimits.keyHash, rateLimits.bucket], + set: { attempts: sql`${rateLimits.attempts} + 1` }, + }) + .returning({ attempts: rateLimits.attempts }); + return row.attempts <= maximumAttempts; +} + +export async function POST(request: Request) { + const contentLength = Number(request.headers.get("content-length") || 0); + if (contentLength > 10_000) { + return Response.json({ message: "Request is too large." }, { status: 413 }); + } + + let parsed: z.infer; + try { + parsed = signupSchema.parse(await request.json()); + } catch { + return Response.json( + { message: "Enter a valid email address." }, + { status: 400 } + ); + } + + if (parsed.company) return Response.json({ message: GENERIC_MESSAGE }); + + const normalizedEmail = normalizeEmail(parsed.email); + const ipHash = hashIdentifier( + clientIp(request) ? `signup-ip:${clientIp(request)}` : null + ); + const emailHash = hashIdentifier(`signup-email:${normalizedEmail}`); + let ipAllowed: boolean; + let emailAllowed: boolean; + try { + const rateLimitResult = await Promise.all([ + withinRateLimit(ipHash, 10), + withinRateLimit(emailHash, 5), + ]); + ipAllowed = rateLimitResult[0]; + emailAllowed = rateLimitResult[1]; + } catch (error) { + console.error("waitlist_rate_limit_check_failed", { + errorType: error instanceof Error ? error.name : "unknown", + }); + return Response.json( + { + message: "We could not process that request. Please try again shortly.", + }, + { status: 503 } + ); + } + if (!ipAllowed || !emailAllowed) { + return Response.json( + { message: "Please try again later." }, + { status: 429 } + ); + } + + try { + const db = getDb(); + const env = getEnv(); + const rawToken = randomToken(); + const expiresAt = new Date(Date.now() + VERIFICATION_TTL_MS); + const touch = { + ...parsed.attribution, + captured_at: new Date().toISOString(), + }; + + const outboxEventId = await db.transaction(async (tx) => { + let created = false; + let signup: { id: string; marketingConsent: boolean } | undefined; + + if (parsed.authOnly) { + const [existing] = await tx + .select({ + id: waitlistSignups.id, + marketingConsent: waitlistSignups.marketingConsent, + }) + .from(waitlistSignups) + .where( + and( + eq(waitlistSignups.normalizedEmail, normalizedEmail), + eq(waitlistSignups.status, "confirmed") + ) + ) + .for("update") + .limit(1); + + signup = existing; + if (!signup) return null; + } else { + const [referrer] = parsed.referralCode + ? await tx + .select({ + id: waitlistSignups.id, + normalizedEmail: waitlistSignups.normalizedEmail, + }) + .from(waitlistSignups) + .where( + and( + eq(waitlistSignups.referralCode, parsed.referralCode), + eq(waitlistSignups.status, "confirmed") + ) + ) + .limit(1) + : []; + + const [inserted] = await tx + .insert(waitlistSignups) + .values({ + email: parsed.email.trim(), + normalizedEmail, + referralCode: randomReferralCode(), + referredBy: + referrer?.normalizedEmail === normalizedEmail + ? null + : referrer?.id, + firstTouch: touch, + lastTouch: touch, + ipHash, + userAgent: request.headers.get("user-agent")?.slice(0, 500) ?? null, + }) + .onConflictDoNothing({ target: waitlistSignups.normalizedEmail }) + .returning({ + id: waitlistSignups.id, + marketingConsent: waitlistSignups.marketingConsent, + }); + + created = Boolean(inserted); + signup = inserted + ? inserted + : ( + await tx + .select({ + id: waitlistSignups.id, + marketingConsent: waitlistSignups.marketingConsent, + }) + .from(waitlistSignups) + .where(eq(waitlistSignups.normalizedEmail, normalizedEmail)) + .for("update") + .limit(1) + )[0]; + } + + if (!signup) throw new Error("signup_not_found"); + + await tx + .insert(attributionTouches) + .values({ signupId: signup.id, data: touch }); + if (!created) { + await tx + .update(waitlistSignups) + .set({ + lastTouch: touch, + lastSeenAt: new Date(), + }) + .where(eq(waitlistSignups.id, signup.id)); + } + + await tx + .update(verificationTokens) + .set({ consumedAt: new Date() }) + .where( + and( + eq(verificationTokens.signupId, signup.id), + isNull(verificationTokens.consumedAt) + ) + ); + + await tx.insert(verificationTokens).values({ + signupId: signup.id, + tokenHash: hashToken(rawToken), + requestedMarketingConsent: parsed.authOnly + ? signup.marketingConsent + : parsed.newsletterOptIn, + expiresAt, + }); + const [outboxEvent] = await tx + .insert(emailOutbox) + .values({ + signupId: signup.id, + eventType: VERIFICATION_EMAIL_EVENT, + payload: { + to: parsed.email.trim(), + verificationUrl: `${env.NEXT_PUBLIC_SITE_URL.replace(/\/$/, "")}/verify?token=${rawToken}`, + expiresAt: expiresAt.toISOString(), + }, + idempotencyKey: `verify:${hashToken(rawToken)}`, + }) + .returning({ id: emailOutbox.id }); + + return outboxEvent.id; + }); + + try { + if (!outboxEventId) { + return Response.json({ message: GENERIC_MESSAGE }, { status: 202 }); + } + await dispatchOutboxEvent(outboxEventId); + } catch (error) { + console.error("verification_email_immediate_dispatch_failed", { + errorType: error instanceof Error ? error.name : "unknown", + }); + } + + return Response.json({ message: GENERIC_MESSAGE }, { status: 202 }); + } catch (error) { + console.error("waitlist_signup_failed", { + error: error instanceof Error ? error.message : "unknown", + }); + return Response.json( + { + message: "We could not process that request. Please try again shortly.", + }, + { status: 503 } + ); + } +} diff --git a/app/globals.css b/app/globals.css index bda6a79..1daf275 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,4 +1,6 @@ @import "tailwindcss"; +@import "tw-animate-css"; +@import "./waitlist.css"; /* Custom variant: targets descendants of html[data-theme="light"]. Use as `light:bg-foo` to override values when the page theme is light. */ @@ -38,7 +40,8 @@ ); --font-inter: InterVariable, Inter, ui-sans-serif, system-ui, sans-serif; - --font-display: "Favorit", "Favorit Pro", var(--font-inter), sans-serif; + --font-display: + var(--font-favorit, "Favorit"), "Favorit Pro", var(--font-inter), sans-serif; --font-heading: var(--font-inter), Inter, sans-serif; --font-sans: var(--font-inter), Inter, ui-sans-serif, system-ui, sans-serif; --font-mono: "Favorit Mono", ui-monospace, monospace; @@ -572,7 +575,8 @@ textarea:-webkit-autofill { @theme inline { --font-inter: InterVariable, Inter, ui-sans-serif, system-ui, sans-serif; - --font-display: "Favorit", "Favorit Pro", var(--font-inter), sans-serif; + --font-display: + var(--font-favorit, "Favorit"), "Favorit Pro", var(--font-inter), sans-serif; --font-heading: var(--font-inter), Inter, sans-serif; --font-sans: var(--font-inter), Inter, ui-sans-serif, system-ui, sans-serif; --font-mono: "Favorit Mono", ui-monospace, monospace; diff --git a/app/verify/route.ts b/app/verify/route.ts new file mode 100644 index 0000000..a23172e --- /dev/null +++ b/app/verify/route.ts @@ -0,0 +1,156 @@ +import { randomUUID } from "node:crypto"; +import { and, eq, gt, isNull } from "drizzle-orm"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { after } from "next/server"; + +import { captureEmailVerified } from "@/lib/analytics-server"; +import { getDb } from "@/lib/db"; +import { + consentEvents, + emailOutbox, + pointEvents, + sessions, + verificationTokens, + waitlistSignups, +} from "@/lib/db/schema"; +import { NEWSLETTER_CONSENT_VERSION } from "@/lib/waitlist/contracts"; +import { newsletterConsentOutboxValues } from "@/lib/email/outbox"; +import { + analyticsMemberId, + hashToken, + randomToken, + SESSION_COOKIE, + sessionCookieOptions, + SESSION_TTL_MS, +} from "@/lib/waitlist/security"; + +export const runtime = "nodejs"; + +export async function GET(request: Request) { + const token = new URL(request.url).searchParams.get("token"); + if (!token) redirect("/waitlist?verification=invalid"); + + const db = getDb(); + const now = new Date(); + const rawSessionToken = randomToken(); + const sessionExpiresAt = new Date(now.getTime() + SESSION_TTL_MS); + + const result = await db.transaction(async (tx) => { + const [verification] = await tx + .select({ + id: verificationTokens.id, + signupId: verificationTokens.signupId, + requestedMarketingConsent: verificationTokens.requestedMarketingConsent, + }) + .from(verificationTokens) + .where( + and( + eq(verificationTokens.tokenHash, hashToken(token)), + isNull(verificationTokens.consumedAt), + gt(verificationTokens.expiresAt, now) + ) + ) + .for("update") + .limit(1); + if (!verification) return null; + + const [signup] = await tx + .select({ + id: waitlistSignups.id, + email: waitlistSignups.email, + marketingConsent: waitlistSignups.marketingConsent, + referredBy: waitlistSignups.referredBy, + }) + .from(waitlistSignups) + .where(eq(waitlistSignups.id, verification.signupId)) + .limit(1); + if (!signup) return null; + + await tx + .update(verificationTokens) + .set({ consumedAt: now }) + .where(eq(verificationTokens.id, verification.id)); + await tx + .update(waitlistSignups) + .set({ + status: "confirmed", + confirmedAt: now, + }) + .where( + and( + eq(waitlistSignups.id, signup.id), + eq(waitlistSignups.status, "pending") + ) + ); + await tx + .update(waitlistSignups) + .set({ marketingConsent: verification.requestedMarketingConsent }) + .where(eq(waitlistSignups.id, signup.id)); + const [consentEvent] = await tx + .insert(consentEvents) + .values({ + signupId: signup.id, + purpose: "product_marketing", + granted: verification.requestedMarketingConsent, + disclosureVersion: NEWSLETTER_CONSENT_VERSION, + source: "email_verification", + occurredAt: now, + }) + .returning({ id: consentEvents.id }); + await tx + .insert(emailOutbox) + .values( + newsletterConsentOutboxValues({ + signupId: signup.id, + consentEventId: consentEvent.id, + email: signup.email, + subscribed: verification.requestedMarketingConsent, + }) + ) + .onConflictDoNothing({ target: emailOutbox.idempotencyKey }); + + if (signup.referredBy && signup.referredBy !== signup.id) { + await tx + .insert(pointEvents) + .values({ + id: randomUUID(), + signupId: signup.referredBy, + points: 1, + reason: "verified_referral", + referralSignupId: signup.id, + }) + .onConflictDoNothing({ + target: [pointEvents.reason, pointEvents.referralSignupId], + }); + } + + await tx.insert(sessions).values({ + signupId: signup.id, + tokenHash: hashToken(rawSessionToken), + expiresAt: sessionExpiresAt, + }); + return { + analyticsId: analyticsMemberId(signup.id), + verificationId: verification.id, + }; + }); + + if (!result) redirect("/waitlist?verification=invalid"); + const cookieStore = await cookies(); + cookieStore.set( + SESSION_COOKIE, + rawSessionToken, + sessionCookieOptions(sessionExpiresAt) + ); + after(async () => { + try { + await captureEmailVerified(result); + } catch (error) { + console.error("waitlist_verification_analytics_failed", { + errorType: error instanceof Error ? error.name : "unknown", + }); + } + }); + redirect("/waitlist"); +} diff --git a/app/waitlist.css b/app/waitlist.css new file mode 100644 index 0000000..946ebfe --- /dev/null +++ b/app/waitlist.css @@ -0,0 +1,325 @@ +@custom-variant dark (&:where(.dark, .dark *)); + +@theme inline { + --color-inverse-foreground: var(--inverse-foreground); + --color-inverse-background: var(--inverse-background); + --text-ui-caption: 0.75rem; + --text-ui-caption--line-height: 1rem; + --text-ui-caption--font-weight: 500; + --text-ui-body: 0.875rem; + --text-ui-body--line-height: 1.25rem; + --text-ui-body--font-weight: 400; + --text-display-sm: 2.25rem; + --text-display-sm--line-height: 0.98; + --text-display-sm--letter-spacing: -0.045em; + --text-display-sm--font-weight: 300; + --text-display-md: 3rem; + --text-display-md--line-height: 0.98; + --text-display-md--letter-spacing: -0.045em; + --text-display-md--font-weight: 300; + --text-display-lg: 3.75rem; + --text-display-lg--line-height: 0.98; + --text-display-lg--letter-spacing: -0.045em; + --text-display-lg--font-weight: 300; + --text-display-fluid: clamp(2.5rem, 4.5vw, 4rem); + --text-display-fluid--line-height: 0.98; + --text-display-fluid--letter-spacing: -0.045em; + --text-display-fluid--font-weight: 300; + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); +} + +.waitlist-surface { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --inverse-background: oklch(0 0 0); + --inverse-foreground: oklch(1 0 0); + --radius: 0.625rem; + min-height: 100dvh; + background: var(--background); + color: var(--foreground); +} + +.waitlist-surface .dark, +.waitlist-surface.dark { + --background: oklch(0 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.16 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --inverse-background: oklch(1 0 0); + --inverse-foreground: oklch(0 0 0); +} + +.input-size-xs::placeholder { + font-size: 0.75rem; +} + +.animated-emerald-radial-frame { + background-color: #059669; +} + +.animated-emerald-radial-frame::before { + position: absolute; + inset: 0; + z-index: 0; + content: ""; + background-image: + radial-gradient(circle at 18% 18%, #a7f3d0 0%, transparent 34%), + radial-gradient(circle at 82% 28%, #34d399 0%, transparent 42%), + linear-gradient(135deg, #047857 0%, #10b981 38%, #6ee7b7 66%, #059669 100%); + background-position: 0% 0%; + background-size: 180% 180%; + filter: saturate(1.18) contrast(1.08); + animation: emerald-radial-drift 5.5s ease-in-out infinite alternate; +} + +@keyframes emerald-radial-drift { + from { + background-position: 0% 0%; + } + 50% { + background-position: 100% 45%; + } + to { + background-position: 20% 100%; + } +} + +@keyframes input-bump { + 0%, + 100% { + transform: translateX(0); + } + 25% { + transform: translateX(-4px); + } + 50% { + transform: translateX(4px); + } + 75% { + transform: translateX(-2px); + } +} + +.animate-input-bump { + animation: input-bump 280ms ease-out; +} + +@layer utilities { + @keyframes scene-frame-exit-up { + 0%, + 8% { + clip-path: inset(0 0 0 0 round var(--scene-frame-radius)); + } + 48%, + 100% { + clip-path: inset(0 0 100% 0 round var(--scene-frame-radius)); + } + } + @keyframes scene-frame-exit-down { + 0%, + 8% { + clip-path: inset(0 0 0 0 round var(--scene-frame-radius)); + } + 48%, + 100% { + clip-path: inset(100% 0 0 0 round var(--scene-frame-radius)); + } + } + @keyframes scene-frame-reveal-up { + 0%, + 12% { + clip-path: inset(100% 0 0 0 round var(--scene-frame-radius)); + } + 92%, + 100% { + clip-path: inset(0 0 0 0 round var(--scene-frame-radius)); + } + } + @keyframes scene-frame-reveal-down { + 0%, + 12% { + clip-path: inset(0 0 100% 0 round var(--scene-frame-radius)); + } + 92%, + 100% { + clip-path: inset(0 0 0 0 round var(--scene-frame-radius)); + } + } + @keyframes scene-frame-reveal-up-synced { + 0%, + 48% { + clip-path: inset(100% 0 0 0 round var(--scene-frame-radius)); + } + 92%, + 100% { + clip-path: inset(0 0 0 0 round var(--scene-frame-radius)); + } + } + @keyframes scene-frame-reveal-down-synced { + 0%, + 48% { + clip-path: inset(0 0 100% 0 round var(--scene-frame-radius)); + } + 92%, + 100% { + clip-path: inset(0 0 0 0 round var(--scene-frame-radius)); + } + } + @keyframes scene-content-exit-up { + 0%, + 8% { + opacity: 1; + transform: translateY(0); + } + 48%, + 100% { + opacity: 0; + transform: translateY(clamp(-5rem, -7svh, -3rem)); + } + } + @keyframes scene-content-enter-down { + 0%, + 48% { + opacity: 0; + transform: translateY(clamp(3rem, 7svh, 5rem)); + } + 92%, + 100% { + opacity: 1; + transform: translateY(0); + } + } + @keyframes scene-content-exit-down { + 0%, + 8% { + opacity: 1; + transform: translateY(0); + } + 48%, + 100% { + opacity: 0; + transform: translateY(clamp(3rem, 7svh, 5rem)); + } + } + @keyframes scene-content-enter-up { + 0%, + 48% { + opacity: 0; + transform: translateY(clamp(-5rem, -7svh, -3rem)); + } + 92%, + 100% { + opacity: 1; + transform: translateY(0); + } + } + @keyframes lock-submitted-email { + from { + width: min(60vw, 22rem); + } + to { + width: var(--submitted-email-width); + } + } + + .animate-lock-submitted-email { + width: var(--submitted-email-width); + animation: lock-submitted-email 360ms cubic-bezier(0.32, 0.72, 0, 1) both; + } + + .animate-scene-frame-exit-up, + .animate-scene-frame-exit-down, + .animate-scene-frame-reveal-up, + .animate-scene-frame-reveal-down, + .animate-scene-frame-reveal-up-synced, + .animate-scene-frame-reveal-down-synced, + .animate-scene-content-exit-up, + .animate-scene-content-enter-down, + .animate-scene-content-exit-down, + .animate-scene-content-enter-up { + animation-duration: 760ms; + animation-timing-function: cubic-bezier(0.65, 0, 0.35, 1); + animation-fill-mode: both; + } + + .animate-scene-frame-exit-up { + animation-name: scene-frame-exit-up; + } + .animate-scene-frame-exit-down { + animation-name: scene-frame-exit-down; + } + .animate-scene-frame-reveal-up { + animation-name: scene-frame-reveal-up; + } + .animate-scene-frame-reveal-down { + animation-name: scene-frame-reveal-down; + } + .animate-scene-frame-reveal-up-synced { + animation-name: scene-frame-reveal-up-synced; + } + .animate-scene-frame-reveal-down-synced { + animation-name: scene-frame-reveal-down-synced; + } + .animate-scene-content-exit-up { + animation-name: scene-content-exit-up; + } + .animate-scene-content-enter-down { + animation-name: scene-content-enter-down; + } + .animate-scene-content-exit-down { + animation-name: scene-content-exit-down; + } + .animate-scene-content-enter-up { + animation-name: scene-content-enter-up; + } + + .font-identifier { + font-feature-settings: + "liga" 0, + "calt" 0; + font-variant-ligatures: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .animated-emerald-radial-frame::before, + .animate-input-bump, + .animate-lock-submitted-email, + [class*="animate-scene-"] { + animation: none; + } +} diff --git a/assets/fonts/waitlist/FavoritMono-Regular.woff2 b/assets/fonts/waitlist/FavoritMono-Regular.woff2 new file mode 100644 index 0000000..6c1b5b1 Binary files /dev/null and b/assets/fonts/waitlist/FavoritMono-Regular.woff2 differ diff --git a/assets/fonts/waitlist/FavoritPro-Bold.woff2 b/assets/fonts/waitlist/FavoritPro-Bold.woff2 new file mode 100644 index 0000000..b2f1fe9 Binary files /dev/null and b/assets/fonts/waitlist/FavoritPro-Bold.woff2 differ diff --git a/assets/fonts/waitlist/FavoritPro-Book.woff2 b/assets/fonts/waitlist/FavoritPro-Book.woff2 new file mode 100644 index 0000000..6f24cb4 Binary files /dev/null and b/assets/fonts/waitlist/FavoritPro-Book.woff2 differ diff --git a/assets/fonts/waitlist/FavoritPro-Light.woff2 b/assets/fonts/waitlist/FavoritPro-Light.woff2 new file mode 100644 index 0000000..f8e2088 Binary files /dev/null and b/assets/fonts/waitlist/FavoritPro-Light.woff2 differ diff --git a/assets/fonts/waitlist/FavoritPro-Medium.woff2 b/assets/fonts/waitlist/FavoritPro-Medium.woff2 new file mode 100644 index 0000000..9385ee2 Binary files /dev/null and b/assets/fonts/waitlist/FavoritPro-Medium.woff2 differ diff --git a/assets/fonts/waitlist/FavoritPro-Regular.woff2 b/assets/fonts/waitlist/FavoritPro-Regular.woff2 new file mode 100644 index 0000000..340d81c Binary files /dev/null and b/assets/fonts/waitlist/FavoritPro-Regular.woff2 differ diff --git a/assets/fonts/waitlist/InterVariable-Italic.woff2 b/assets/fonts/waitlist/InterVariable-Italic.woff2 new file mode 100644 index 0000000..b3530f3 Binary files /dev/null and b/assets/fonts/waitlist/InterVariable-Italic.woff2 differ diff --git a/assets/fonts/waitlist/InterVariable.woff2 b/assets/fonts/waitlist/InterVariable.woff2 new file mode 100644 index 0000000..5a8d3e7 Binary files /dev/null and b/assets/fonts/waitlist/InterVariable.woff2 differ diff --git a/components/brand.tsx b/components/brand.tsx new file mode 100644 index 0000000..84202bb --- /dev/null +++ b/components/brand.tsx @@ -0,0 +1,247 @@ +import * as React from "react"; + +const symbolPaths = ( + <> + + + + + + + +); + +const wordmarkPaths = ( + <> + + + + + + + + + +); + +function LivepeerSymbol(props: React.SVGProps) { + return ( + + {symbolPaths} + + ); +} + +function LivepeerGradientSymbol(props: React.SVGProps) { + const gradientId = React.useId().replaceAll(":", ""); + + return ( + + + + + + + + + {symbolPaths} + + ); +} + +function LivepeerWordmark(props: React.SVGProps) { + return ( + + {wordmarkPaths} + + ); +} + +function LivepeerForegroundGradientWordmark( + props: React.SVGProps +) { + const gradientId = React.useId().replaceAll(":", ""); + + return ( + + + + + + + + {wordmarkPaths} + + ); +} + +function LivepeerLogo({ className, ...props }: React.ComponentProps<"span">) { + return ( + + {/* + ); +} + +function LivepeerLockup(props: React.SVGProps) { + return ( + + {symbolPaths} + {wordmarkPaths} + + ); +} + +function LivepeerGradientLockup({ + metallic = false, + ...props +}: React.SVGProps & { metallic?: boolean }) { + const gradientId = React.useId().replaceAll(":", ""); + const wordmarkGradientId = `${gradientId}-wordmark`; + const metallicSymbolGradientId = `${gradientId}-metallic-symbol`; + + return ( + + + + + + + + + + + + + + + + + + + + + + {symbolPaths} + + + {wordmarkPaths} + + + ); +} + +function AgentWordmark(props: React.SVGProps) { + return ( + + + + + + + + ); +} + +function RegistryUiMark(props: React.SVGProps) { + return ( + + + + + ); +} + +export { + LivepeerSymbol, + LivepeerGradientSymbol, + LivepeerWordmark, + LivepeerForegroundGradientWordmark, + LivepeerLogo, + LivepeerLockup, + LivepeerGradientLockup, + AgentWordmark, + RegistryUiMark, +}; diff --git a/components/livepeer-ui/agent-capabilities-section.tsx b/components/livepeer-ui/agent-capabilities-section.tsx new file mode 100644 index 0000000..b978d71 --- /dev/null +++ b/components/livepeer-ui/agent-capabilities-section.tsx @@ -0,0 +1,82 @@ +import Link from "next/link"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export function AgentCapabilitiesSection({ + capabilities, + content, + showCta = true, + badgesFirst = false, + className, + badgesClassName, + badgeClassName, + headingClassName, +}: { + capabilities: string[]; + content: { heading: string; cta: { label: string; href: string } }; + showCta?: boolean; + badgesFirst?: boolean; + className?: string; + badgesClassName?: string; + badgeClassName?: string; + headingClassName?: string; +}) { + return ( +
+
+

+ {content.heading} +

+
+ {capabilities.map((capability) => ( + + {capability} + + ))} +
+ {showCta && ( + + )} +
+
+ ); +} diff --git a/components/livepeer-ui/agent-scroller-page.tsx b/components/livepeer-ui/agent-scroller-page.tsx new file mode 100644 index 0000000..cb53b78 --- /dev/null +++ b/components/livepeer-ui/agent-scroller-page.tsx @@ -0,0 +1,1350 @@ +"use client"; + +import Image from "next/image"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; + +import { + LivepeerForegroundGradientWordmark, + LivepeerSymbol, +} from "@/components/brand"; +import { AgentCapabilitiesSection } from "@/components/livepeer-ui/agent-capabilities-section"; +import { + authModalMedia, + compatibilityMarks, + featuredMedia, +} from "@/components/livepeer-ui/frozen-content"; +import { LivepeerHeader } from "@/components/livepeer-ui/livepeer-header"; +import { getCapabilityFamilyLogos } from "@/components/livepeer-ui/model-family-logos"; +import { + JoinWaitlistControl, + WaitlistHeaderAuth, +} from "@/components/livepeer-ui/waitlist-header-auth"; +import { cn } from "@/lib/utils"; + +type MediaItem = { + id: string; + src: string; + alt: string; + kind?: "image" | "video"; + fit?: "portrait"; + accentFrame?: boolean; + coverBottomEdge?: boolean; +}; +type SceneTheme = "base" | "inverse"; +type SceneLayout = "hero" | "split" | "capabilities" | "footer"; + +type StoryScene = { + id: string; + title: string; + body?: string; + theme: SceneTheme; + layout: SceneLayout; + compatibility?: boolean; + media: readonly MediaItem[]; +}; + +type TransitionState = { + from: number; + to: number; + direction: "down" | "up"; + token: number; +}; + +type InputPhase = "ready" | "collecting" | "transitioning" | "cooldown"; +type FrameAnimation = + | "exit-up" + | "exit-down" + | "reveal-up" + | "reveal-down" + | "reveal-up-synced" + | "reveal-down-synced"; +type ContentAnimation = "exit-up" | "enter-down" | "exit-down" | "enter-up"; + +const SCENE_TOP_OFFSET = 0; +const TRANSITION_DURATION = 760; +const THEME_HANDOFF_DELAY = 365; +const WHEEL_THRESHOLD = 48; +const WHEEL_GESTURE_WINDOW = 160; +const INPUT_QUIET_WINDOW = 120; + +const nonShowcaseCapabilities = new Set([ + "create_media", + "critique-batch", + "describe_capability", + "director_export", + "generate_project", + "get_cost_report", + "get_creative_job", + "list_capabilities", + "set_active_brand_kit", + "submit_creative_job", +]); + +const storyContent = [ + { + id: "studio", + title: "Livepeer Agent turns any harness into your dream production studio", + body: "Brings image, video, audio, 3D, editing, rendering, and production tools into your agent’s workflows with Livepeer Agent.", + theme: "base", + layout: "hero", + }, + { + id: "routing", + title: "The right model for every request", + body: "Livepeer Agent understands the work you’re asking for and routes it to the model best suited to handle it.", + theme: "inverse", + layout: "split", + }, + { + id: "pricing", + title: "Pay for the work, not a subscription", + body: "Livepeer Agent shows the real price of every render before it runs. Keep a balance in USD and pay only for the compute you use—no credits, plans, or hidden conversion.", + theme: "base", + layout: "split", + }, + { + id: "workflow", + title: "Run any part of your workflow", + body: "Send one step or an entire production through Livepeer Agent while keeping the files, applications, and processes you already use.", + theme: "inverse", + layout: "split", + }, + { + id: "compatible", + title: "Compatible with", + theme: "base", + layout: "split", + compatibility: true, + }, +] as const; + +function mobileThemeForScene(index: number): SceneTheme { + return index > 0 && index < storyContent.length - 1 ? "inverse" : "base"; +} + +function clamp(value: number, minimum = 0, maximum = 1) { + return Math.min(maximum, Math.max(minimum, value)); +} + +function Media({ item, eager = false }: { item: MediaItem; eager?: boolean }) { + if (item.kind === "video") { + return ( + <> +