From 0eacc98e1759d94fd466139064adc90ef066baf3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 6 May 2026 10:45:14 +0700 Subject: [PATCH] website: add /cloud design partners page and shared shell - New /cloud page with signup form (email, name, company, role, message) posting to a Discord webhook via a TanStack Start server function on the Cloudflare Worker. Webhook URL read from DISCORD_WEBHOOK_URL secret. - Extract SiteShell, SiteFooter, FAQItem from landing-page. - Migrate /privacy, /download, /blog, /changelog, /cloud to SiteShell so they share header + footer with consistent width. - Add /cloud to header and footer nav. - Make /docs sidebar full-width; bump footer text from xs to sm. - Add Cloudflare Workers types and ignore .dev.vars. --- .gitignore | 2 + packages/website/src/cloud-signup.ts | 84 ++++ packages/website/src/components/faq-item.tsx | 14 + .../website/src/components/landing-page.tsx | 136 +------ .../website/src/components/site-footer.tsx | 126 ++++++ .../website/src/components/site-header.tsx | 6 + .../website/src/components/site-shell.tsx | 24 ++ packages/website/src/routeTree.gen.ts | 21 + packages/website/src/routes/blog.tsx | 13 +- packages/website/src/routes/changelog.tsx | 17 +- packages/website/src/routes/cloud.tsx | 216 +++++++++++ packages/website/src/routes/docs.tsx | 4 +- packages/website/src/routes/download.tsx | 359 ++++++++---------- packages/website/src/routes/privacy.tsx | 137 ++++--- packages/website/tsconfig.json | 1 + packages/website/vite.config.ts | 1 + 16 files changed, 730 insertions(+), 431 deletions(-) create mode 100644 packages/website/src/cloud-signup.ts create mode 100644 packages/website/src/components/faq-item.tsx create mode 100644 packages/website/src/components/site-footer.tsx create mode 100644 packages/website/src/components/site-shell.tsx create mode 100644 packages/website/src/routes/cloud.tsx diff --git a/.gitignore b/.gitignore index f5c8aac8e..8209a7266 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ out/ .env.local .env.test.local .env*.local +.dev.vars +**/.dev.vars # Logs *.log diff --git a/packages/website/src/cloud-signup.ts b/packages/website/src/cloud-signup.ts new file mode 100644 index 000000000..d5cee1cfb --- /dev/null +++ b/packages/website/src/cloud-signup.ts @@ -0,0 +1,84 @@ +import { env } from "cloudflare:workers"; +import { createServerFn } from "@tanstack/react-start"; + +export interface CloudSignupInput { + email: string; + name?: string; + company?: string; + role?: string; + message: string; + honeypot?: string; +} + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +function asString(v: unknown, max: number): string { + if (typeof v !== "string") throw new Error("expected string"); + const trimmed = v.trim(); + if (trimmed.length > max) throw new Error("field too long"); + return trimmed; +} + +function validate(raw: unknown): CloudSignupInput { + if (typeof raw !== "object" || raw === null) throw new Error("invalid input"); + const r = raw as Record; + + const email = asString(r.email, 320); + if (!EMAIL_RE.test(email)) throw new Error("invalid email"); + + const message = asString(r.message, 4000); + if (message.length === 0) throw new Error("message required"); + + const name = r.name === undefined || r.name === "" ? undefined : asString(r.name, 200); + const company = + r.company === undefined || r.company === "" ? undefined : asString(r.company, 200); + const role = r.role === undefined || r.role === "" ? undefined : asString(r.role, 200); + const honeypot = typeof r.honeypot === "string" ? r.honeypot : ""; + + return { email, name, company, role, message, honeypot }; +} + +function truncate(s: string, max: number): string { + return s.length <= max ? s : `${s.slice(0, max - 1)}…`; +} + +function buildEmbed(input: CloudSignupInput) { + const fields: Array<{ name: string; value: string; inline?: boolean }> = [ + { name: "Email", value: truncate(input.email, 1024), inline: true }, + ]; + if (input.name) { + fields.push({ name: "Name", value: truncate(input.name, 1024), inline: true }); + } + if (input.company) { + fields.push({ name: "Company", value: truncate(input.company, 1024), inline: true }); + } + if (input.role) { + fields.push({ name: "Role", value: truncate(input.role, 1024), inline: true }); + } + fields.push({ name: "Message", value: truncate(input.message, 1024) }); + + return { + title: "Paseo Cloud signup", + color: 0x5865f2, + fields, + timestamp: new Date().toISOString(), + }; +} + +export const submitCloudSignup = createServerFn({ method: "POST" }) + .inputValidator(validate) + .handler(async ({ data }) => { + if (data.honeypot) return { ok: true }; + + const url = (env as { DISCORD_WEBHOOK_URL?: string }).DISCORD_WEBHOOK_URL; + if (!url) throw new Error("webhook not configured"); + + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ embeds: [buildEmbed(data)] }), + }); + + if (!res.ok) throw new Error(`webhook ${res.status}`); + return { ok: true }; + }); diff --git a/packages/website/src/components/faq-item.tsx b/packages/website/src/components/faq-item.tsx new file mode 100644 index 000000000..217e4d128 --- /dev/null +++ b/packages/website/src/components/faq-item.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from "react"; + +export function FAQItem({ question, children }: { question: string; children: ReactNode }) { + return ( +
+ + + + + {question} + +
{children}
+
+ ); +} diff --git a/packages/website/src/components/landing-page.tsx b/packages/website/src/components/landing-page.tsx index bbc276e25..52d835a95 100644 --- a/packages/website/src/components/landing-page.tsx +++ b/packages/website/src/components/landing-page.tsx @@ -48,6 +48,8 @@ import { useRelease } from "~/routes/__root"; import { Mic } from "lucide-react"; import { HeroMockup } from "~/components/hero-mockup"; import { ClaudeIcon } from "~/components/mockup"; +import { FAQItem } from "~/components/faq-item"; +import { SiteFooter } from "~/components/site-footer"; import { SiteHeader } from "~/components/site-header"; import "~/styles.css"; @@ -97,126 +99,7 @@ export function LandingPage({ title, subtitle }: LandingPageProps) { - + ); @@ -1685,16 +1568,3 @@ function SponsorCTA() { ); } - -function FAQItem({ question, children }: { question: string; children: React.ReactNode }) { - return ( -
- - + - - {question} - -
{children}
-
- ); -} diff --git a/packages/website/src/components/site-footer.tsx b/packages/website/src/components/site-footer.tsx new file mode 100644 index 000000000..7843d81b6 --- /dev/null +++ b/packages/website/src/components/site-footer.tsx @@ -0,0 +1,126 @@ +import { appStoreUrl, playStoreUrl, webAppUrl } from "~/downloads"; + +interface SiteFooterProps { + width?: "default" | "prose"; +} + +export function SiteFooter({ width = "default" }: SiteFooterProps) { + const widthClasses = + width === "prose" ? "max-w-prose p-6 md:p-12 md:pt-0" : "max-w-5xl p-6 md:p-20 md:pt-0"; + return ( + + ); +} diff --git a/packages/website/src/components/site-header.tsx b/packages/website/src/components/site-header.tsx index 2b765be16..d57020f34 100644 --- a/packages/website/src/components/site-header.tsx +++ b/packages/website/src/components/site-header.tsx @@ -28,6 +28,12 @@ export function SiteHeader() { > Changelog + + Cloud + +
+
+ +
+ {children} +
+ + + ); +} diff --git a/packages/website/src/routeTree.gen.ts b/packages/website/src/routeTree.gen.ts index a844c9ef3..b225986f8 100644 --- a/packages/website/src/routeTree.gen.ts +++ b/packages/website/src/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as OpencodeRouteImport } from "./routes/opencode"; import { Route as DownloadRouteImport } from "./routes/download"; import { Route as DocsRouteImport } from "./routes/docs"; import { Route as CodexRouteImport } from "./routes/codex"; +import { Route as CloudRouteImport } from "./routes/cloud"; import { Route as ClaudeCodeRouteImport } from "./routes/claude-code"; import { Route as ChangelogRouteImport } from "./routes/changelog"; import { Route as BlogRouteImport } from "./routes/blog"; @@ -48,6 +49,11 @@ const CodexRoute = CodexRouteImport.update({ path: "/codex", getParentRoute: () => rootRouteImport, } as any); +const CloudRoute = CloudRouteImport.update({ + id: "/cloud", + path: "/cloud", + getParentRoute: () => rootRouteImport, +} as any); const ClaudeCodeRoute = ClaudeCodeRouteImport.update({ id: "/claude-code", path: "/claude-code", @@ -94,6 +100,7 @@ export interface FileRoutesByFullPath { "/blog": typeof BlogRouteWithChildren; "/changelog": typeof ChangelogRoute; "/claude-code": typeof ClaudeCodeRoute; + "/cloud": typeof CloudRoute; "/codex": typeof CodexRoute; "/docs": typeof DocsRouteWithChildren; "/download": typeof DownloadRoute; @@ -108,6 +115,7 @@ export interface FileRoutesByTo { "/": typeof IndexRoute; "/changelog": typeof ChangelogRoute; "/claude-code": typeof ClaudeCodeRoute; + "/cloud": typeof CloudRoute; "/codex": typeof CodexRoute; "/download": typeof DownloadRoute; "/opencode": typeof OpencodeRoute; @@ -123,6 +131,7 @@ export interface FileRoutesById { "/blog": typeof BlogRouteWithChildren; "/changelog": typeof ChangelogRoute; "/claude-code": typeof ClaudeCodeRoute; + "/cloud": typeof CloudRoute; "/codex": typeof CodexRoute; "/docs": typeof DocsRouteWithChildren; "/download": typeof DownloadRoute; @@ -140,6 +149,7 @@ export interface FileRouteTypes { | "/blog" | "/changelog" | "/claude-code" + | "/cloud" | "/codex" | "/docs" | "/download" @@ -154,6 +164,7 @@ export interface FileRouteTypes { | "/" | "/changelog" | "/claude-code" + | "/cloud" | "/codex" | "/download" | "/opencode" @@ -168,6 +179,7 @@ export interface FileRouteTypes { | "/blog" | "/changelog" | "/claude-code" + | "/cloud" | "/codex" | "/docs" | "/download" @@ -184,6 +196,7 @@ export interface RootRouteChildren { BlogRoute: typeof BlogRouteWithChildren; ChangelogRoute: typeof ChangelogRoute; ClaudeCodeRoute: typeof ClaudeCodeRoute; + CloudRoute: typeof CloudRoute; CodexRoute: typeof CodexRoute; DocsRoute: typeof DocsRouteWithChildren; DownloadRoute: typeof DownloadRoute; @@ -228,6 +241,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof CodexRouteImport; parentRoute: typeof rootRouteImport; }; + "/cloud": { + id: "/cloud"; + path: "/cloud"; + fullPath: "/cloud"; + preLoaderRoute: typeof CloudRouteImport; + parentRoute: typeof rootRouteImport; + }; "/claude-code": { id: "/claude-code"; path: "/claude-code"; @@ -316,6 +336,7 @@ const rootRouteChildren: RootRouteChildren = { BlogRoute: BlogRouteWithChildren, ChangelogRoute: ChangelogRoute, ClaudeCodeRoute: ClaudeCodeRoute, + CloudRoute: CloudRoute, CodexRoute: CodexRoute, DocsRoute: DocsRouteWithChildren, DownloadRoute: DownloadRoute, diff --git a/packages/website/src/routes/blog.tsx b/packages/website/src/routes/blog.tsx index f7330969b..e0dcfc7ee 100644 --- a/packages/website/src/routes/blog.tsx +++ b/packages/website/src/routes/blog.tsx @@ -1,5 +1,5 @@ import { createFileRoute, Outlet } from "@tanstack/react-router"; -import { SiteHeader } from "~/components/site-header"; +import { SiteShell } from "~/components/site-shell"; export const Route = createFileRoute("/blog")({ component: BlogLayout, @@ -7,13 +7,8 @@ export const Route = createFileRoute("/blog")({ function BlogLayout() { return ( -
-
-
- -
- -
-
+ + + ); } diff --git a/packages/website/src/routes/changelog.tsx b/packages/website/src/routes/changelog.tsx index 89a0ca986..302552fed 100644 --- a/packages/website/src/routes/changelog.tsx +++ b/packages/website/src/routes/changelog.tsx @@ -1,8 +1,8 @@ import { createFileRoute } from "@tanstack/react-router"; import ReactMarkdown from "react-markdown"; import changelogMarkdown from "../../../../CHANGELOG.md?raw"; +import { SiteShell } from "~/components/site-shell"; import { pageMeta } from "~/meta"; -import { SiteHeader } from "~/components/site-header"; export const Route = createFileRoute("/changelog")({ head: () => ({ @@ -16,15 +16,10 @@ export const Route = createFileRoute("/changelog")({ function Changelog() { return ( -
-
-
- -
-
- {changelogMarkdown} -
-
-
+ +
+ {changelogMarkdown} +
+
); } diff --git a/packages/website/src/routes/cloud.tsx b/packages/website/src/routes/cloud.tsx new file mode 100644 index 000000000..eb6d514db --- /dev/null +++ b/packages/website/src/routes/cloud.tsx @@ -0,0 +1,216 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useCallback, useState, type FormEvent } from "react"; +import { submitCloudSignup, type CloudSignupInput } from "~/cloud-signup"; +import { FAQItem } from "~/components/faq-item"; +import { SiteShell } from "~/components/site-shell"; +import { pageMeta } from "~/meta"; + +export const Route = createFileRoute("/cloud")({ + head: () => ({ + meta: pageMeta( + "Paseo Cloud - Design Partners", + "Paseo across machines, with a team, or inside a company. Looking for design partners.", + ), + }), + component: Cloud, +}); + +const INPUT_CLASS = + "block w-full rounded-md bg-white/5 border border-white/10 px-3.5 py-2.5 text-sm text-white placeholder:text-white/30 focus:outline-none focus:border-white/30 transition-colors"; + +type Status = "idle" | "submitting" | "success" | "error"; + +function Cloud() { + return ( + +

Paseo Cloud

+

+ For using Paseo across machines, with a team, or inside a company. Looking for design + partners. +

+ +
+ + +
+
+ ); +} + +function SignupForm() { + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + + const handleSubmit = useCallback(async (event: FormEvent) => { + event.preventDefault(); + setStatus("submitting"); + setError(null); + + const form = new FormData(event.currentTarget); + const data: CloudSignupInput = { + email: String(form.get("email") ?? ""), + name: form.get("name") ? String(form.get("name")) : undefined, + company: form.get("company") ? String(form.get("company")) : undefined, + role: form.get("role") ? String(form.get("role")) : undefined, + message: String(form.get("message") ?? ""), + honeypot: form.get("website") ? String(form.get("website")) : "", + }; + + try { + await submitCloudSignup({ data }); + setStatus("success"); + } catch (err) { + setStatus("error"); + setError(err instanceof Error ? err.message : "Something went wrong."); + } + }, []); + + if (status === "success") { + return ( +
+

+ Got it. I'll be in touch. If you don't hear back within a week, ping me on{" "} + + Discord + + . +

+
+ ); + } + + const submitting = status === "submitting"; + + return ( +
+
+ + + + + + + + + + + + +
+ + +