Merge pull request #23 from ibelick/feat/add-form-lp

Feat/add form lp
This commit is contained in:
Julien Thibeaut
2026-02-09 17:39:03 +01:00
committed by GitHub
13 changed files with 637 additions and 9 deletions
+1 -1
View File
@@ -22,7 +22,7 @@
<meta name="twitter:image" content="/webclaw-cover.webp" />
</head>
<body>
<div id="root"></div>
<div id="root" class="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script defer src="https://assets.onedollarstats.com/stonks.js"></script>
</body>
+1
View File
@@ -10,6 +10,7 @@
"deploy": "wrangler deploy"
},
"dependencies": {
"@base-ui/react": "^1.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
+210 -6
View File
@@ -1,6 +1,78 @@
import { Button } from "@/components/ui/button";
import { useState } from "react";
import type { FormEvent } from "react";
import { CodeBlock, CodeBlockCode } from "@/components/ui/code-block";
import {
DialogClose,
DialogContent,
DialogDescription,
DialogRoot,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
export function App() {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [hasSubmitted, setHasSubmitted] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [submittedAt, setSubmittedAt] = useState(() => String(Date.now()));
function handleDialogOpenChange(nextOpen: boolean) {
setIsDialogOpen(nextOpen);
if (!nextOpen) {
setHasSubmitted(false);
setSubmitError(null);
} else {
setSubmittedAt(String(Date.now()));
}
}
async function handleWorkspaceSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isSubmitting) {
return;
}
const form = event.currentTarget;
const formData = new FormData(form);
const payload = {
workEmail: String(formData.get("workEmail") ?? ""),
companyName: String(formData.get("companyName") ?? ""),
companySize: String(formData.get("companySize") ?? ""),
role: String(formData.get("role") ?? ""),
usage: String(formData.get("usage") ?? ""),
website: String(formData.get("website") ?? ""),
submittedAt: String(formData.get("submittedAt") ?? ""),
};
setIsSubmitting(true);
setSubmitError(null);
try {
const response = await fetch("/api/lead", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error("Request failed");
}
setHasSubmitted(true);
} catch (error) {
setSubmitError("Something went wrong. Try again in a moment.");
} finally {
setIsSubmitting(false);
}
}
return (
<div
className="min-h-screen text-neutral-900 selection:bg-neutral-900 selection:text-white"
@@ -14,11 +86,15 @@ export function App() {
<h1 className="font-medium">WebClaw</h1>
<p className="text-neutral-500">Fast web client for OpenClaw.</p>
<div className="flex flex-wrap gap-3">
<a
className="inline-flex items-center gap-1.5 rounded-full bg-neutral-900 px-5 py-2.5 text-white select-none"
href="https://github.com/ibelick/webclaw"
target="_blank"
rel="noopener noreferrer"
<Button
className="gap-1.5"
onClick={() =>
window.open(
"https://github.com/ibelick/webclaw",
"_blank",
"noopener,noreferrer"
)
}
>
Github Repository
<svg
@@ -44,7 +120,135 @@ export function App() {
strokeLinejoin="round"
></path>
</svg>
</a>
</Button>
<DialogRoot
open={isDialogOpen}
onOpenChange={handleDialogOpenChange}
>
<DialogTrigger
render={(props) => (
<Button size="md" variant="secondary" {...props}>
Workspace access{" "}
<svg
xmlns="http://www.w3.org/2000/svg"
width="128"
height="128"
viewBox="0 0 24 24"
fill="none"
className="size-4"
>
<path
d="M9.00005 6C9.00005 6 15 10.4189 15 12C15 13.5812 9 18 9 18"
stroke="#000000"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
></path>
</svg>
</Button>
)}
/>
<DialogContent>
<DialogTitle>
{hasSubmitted ? "You're on the list" : "Workspace access"}
</DialogTitle>
<DialogDescription className="mt-2">
{hasSubmitted ? (
"Thanks for the details. You're on the list. We'll follow up with early access."
) : (
<>
Shared sessions, history, and a real workspace for
OpenClaw. <br />
Request early access.
</>
)}
</DialogDescription>
<DialogClose aria-label="Close">
<svg
viewBox="0 0 20 20"
aria-hidden="true"
className="size-4"
fill="none"
>
<path
d="M5 5l10 10M15 5L5 15"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</DialogClose>
{hasSubmitted ? (
<div className="mt-6 flex justify-end">
<Button size="sm" onClick={() => setIsDialogOpen(false)}>
Close
</Button>
</div>
) : (
<form
className="mt-6 grid gap-4 sm:grid-cols-2"
onSubmit={handleWorkspaceSubmit}
>
<input
autoComplete="off"
className="hidden"
name="website"
tabIndex={-1}
type="text"
/>
<input name="submittedAt" type="hidden" value={submittedAt} />
<label className="flex flex-col gap-1.5 text-sm text-neutral-700">
Work email (required)
<Input
placeholder="alex@company.com"
type="email"
required
name="workEmail"
/>
</label>
<label className="flex flex-col gap-1.5 text-sm text-neutral-700">
Company / team name (optional)
<Input placeholder="Company or team" name="companyName" />
</label>
<label className="flex flex-col gap-1.5 text-sm text-neutral-700">
Company size
<Select
placeholder="Select size"
name="companySize"
options={[
{ value: "1-10", label: "1-10" },
{ value: "11-50", label: "11-50" },
{ value: "51-200", label: "51-200" },
{ value: "201-500", label: "201-500" },
{ value: "500+", label: "500+" },
]}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm text-neutral-700">
Your role (optional)
<Input placeholder="eg. Engineering lead" name="role" />
</label>
<label className="flex flex-col gap-1.5 text-sm text-neutral-700 sm:col-span-2">
How are you using OpenClaw today? (optional short text)
<Textarea
placeholder="Share your use case, infra needs, or rollout plans."
name="usage"
/>
</label>
{submitError ? (
<p className="sm:col-span-2 text-sm text-neutral-500">
{submitError}
</p>
) : null}
<div className="sm:col-span-2 flex justify-end">
<Button size="sm" type="submit" disabled={isSubmitting}>
{isSubmitting ? "Submitting..." : "Request access"}
</Button>
</div>
</form>
)}
</DialogContent>
</DialogRoot>
</div>
</div>
+48
View File
@@ -0,0 +1,48 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import type * as React from "react";
type ButtonVariant = "primary" | "secondary";
type ButtonSize = "md" | "sm";
type ButtonProps = useRender.ComponentProps<"button"> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
const variantClasses: Record<ButtonVariant, string> = {
primary:
"bg-neutral-900 text-white hover:bg-neutral-800 shadow-sm shadow-neutral-900/10",
secondary:
"bg-white text-neutral-900 border border-neutral-200 hover:bg-neutral-100",
};
const sizeClasses: Record<ButtonSize, string> = {
md: "px-5 py-2.5 text-base",
sm: "px-4 py-2 text-sm",
};
export function Button({
className,
variant = "primary",
size = "md",
render,
...props
}: ButtonProps) {
const typeValue: React.ButtonHTMLAttributes<HTMLButtonElement>["type"] =
render ? undefined : "button";
const classes = [
"inline-flex items-center justify-center gap-1.5 rounded-full font-[450] transition-colors",
sizeClasses[size],
variantClasses[variant],
className,
]
.filter(Boolean)
.join(" ");
return useRender({
defaultTagName: "button",
props: mergeProps<"button">({ className: classes, type: typeValue }, props),
render,
});
}
+78
View File
@@ -0,0 +1,78 @@
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import type * as React from "react";
type DialogRootProps = React.ComponentProps<typeof DialogPrimitive.Root>;
function DialogRoot({ children, ...props }: DialogRootProps) {
return <DialogPrimitive.Root {...props}>{children}</DialogPrimitive.Root>;
}
type DialogTriggerProps = React.ComponentProps<typeof DialogPrimitive.Trigger>;
function DialogTrigger({ className, ...props }: DialogTriggerProps) {
return <DialogPrimitive.Trigger className={className} {...props} />;
}
type DialogContentProps = React.ComponentProps<typeof DialogPrimitive.Popup>;
function DialogContent({ className, ...props }: DialogContentProps) {
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Backdrop className="fixed inset-0 bg-neutral-900/30" />
<DialogPrimitive.Popup
className={
"fixed left-1/2 top-1/2 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-neutral-200 bg-white p-6 shadow-xl " +
(className ?? "")
}
{...props}
/>
</DialogPrimitive.Portal>
);
}
type DialogTitleProps = React.ComponentProps<typeof DialogPrimitive.Title>;
function DialogTitle({ className, ...props }: DialogTitleProps) {
return (
<DialogPrimitive.Title
className={"text-lg font-[450] text-neutral-900 " + (className ?? "")}
{...props}
/>
);
}
type DialogDescriptionProps = React.ComponentProps<
typeof DialogPrimitive.Description
>;
function DialogDescription({ className, ...props }: DialogDescriptionProps) {
return (
<DialogPrimitive.Description
className={"text-sm text-neutral-500 " + (className ?? "")}
{...props}
/>
);
}
type DialogCloseProps = React.ComponentProps<typeof DialogPrimitive.Close>;
function DialogClose({ className, ...props }: DialogCloseProps) {
return (
<DialogPrimitive.Close
className={
"absolute right-4 top-4 inline-flex size-8 items-center justify-center rounded-full text-neutral-500 hover:bg-neutral-100 " +
(className ?? "")
}
{...props}
/>
);
}
export {
DialogRoot,
DialogTrigger,
DialogContent,
DialogTitle,
DialogDescription,
DialogClose,
};
+20
View File
@@ -0,0 +1,20 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
type InputProps = useRender.ComponentProps<"input">;
export function Input({ className, render, ...props }: InputProps) {
const classes = [
"w-full rounded-xl border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-900",
"placeholder:text-neutral-400 focus:outline-none focus:ring-2 focus:ring-neutral-900/10",
className,
]
.filter(Boolean)
.join(" ");
return useRender({
defaultTagName: "input",
props: mergeProps<"input">({ className: classes }, props),
render,
});
}
+85
View File
@@ -0,0 +1,85 @@
import { Select as SelectPrimitive } from "@base-ui/react/select";
type SelectOption = {
value: string;
label: string;
};
type SelectProps = {
value?: string;
defaultValue?: string;
placeholder?: string;
options: Array<SelectOption>;
onValueChange?: (value: string | null) => void;
className?: string;
name?: string;
};
export function Select({
value,
defaultValue,
placeholder = "Select an option",
options,
onValueChange,
className,
name,
}: SelectProps) {
return (
<SelectPrimitive.Root
value={value}
defaultValue={defaultValue}
onValueChange={onValueChange}
name={name}
>
<SelectPrimitive.Trigger
className={[
"flex w-full items-center justify-between rounded-xl border border-neutral-200",
"bg-white px-3 py-2 text-sm text-neutral-900 transition-colors",
"focus:outline-none focus:ring-2 focus:ring-neutral-900/10",
className,
]
.filter(Boolean)
.join(" ")}
>
<SelectPrimitive.Value
placeholder={placeholder}
className="text-neutral-900 data-placeholder:text-neutral-400"
/>
<SelectPrimitive.Icon className="text-neutral-400">
<svg
viewBox="0 0 20 20"
aria-hidden="true"
className="size-4"
fill="none"
>
<path
d="M6 8l4 4 4-4"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner sideOffset={6}>
<SelectPrimitive.Popup className="min-w-[12rem] rounded-xl border border-neutral-200 bg-white p-1 text-sm text-neutral-900 shadow-lg">
{options.map((option) => (
<SelectPrimitive.Item
key={option.value}
value={option.value}
className="flex w-full items-center rounded-lg px-2 py-1.5 outline-none hover:bg-neutral-100 data-highlighted:bg-neutral-100"
>
<SelectPrimitive.ItemText>
{option.label}
</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))}
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
</SelectPrimitive.Root>
);
}
@@ -0,0 +1,20 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
type TextareaProps = useRender.ComponentProps<"textarea">;
export function Textarea({ className, render, ...props }: TextareaProps) {
const classes = [
"w-full min-h-[120px] rounded-xl border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-900",
"placeholder:text-neutral-400 focus:outline-none focus:ring-2 focus:ring-neutral-900/10",
className,
]
.filter(Boolean)
.join(" ");
return useRender({
defaultTagName: "textarea",
props: mergeProps<"textarea">({ className: classes }, props),
render,
});
}
+1 -1
View File
@@ -12,5 +12,5 @@ if (!root) {
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
</StrictMode>
);
+5 -1
View File
@@ -5,6 +5,10 @@ body {
margin: 0;
}
.root {
isolation: isolate;
}
html,
body {
@apply m-0 font-sans;
@@ -12,4 +16,4 @@ body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #fcfcfc;
}
}
+166
View File
@@ -0,0 +1,166 @@
type SlackWebhookPayload = {
text: string;
};
type Env = {
SLACK_WEBHOOK_URL: string;
ASSETS: {
fetch: (request: Request) => Promise<Response>;
};
};
type LeadPayload = {
workEmail: string;
companyName: string;
companySize: string;
role: string;
usage: string;
website?: string;
submittedAt?: string;
};
type RateLimitState = {
count: number;
resetAt: number;
};
const ALLOWED_ORIGINS = new Set(["https://webclaw.dev"]);
const RATE_LIMIT_WINDOW_MS = 60_000;
const RATE_LIMIT_MAX = 8;
const rateLimitCache = new Map<string, RateLimitState>();
function buildSlackMessage(payload: LeadPayload) {
const fields = [
`Work email: ${payload.workEmail || "-"}`,
`Company / team: ${payload.companyName || "-"}`,
`Company size: ${payload.companySize || "-"}`,
`Role: ${payload.role || "-"}`,
`Usage: ${payload.usage || "-"}`,
];
return fields.join("\n");
}
export default {
async fetch(request: Request, env: Env) {
const url = new URL(request.url);
if (url.pathname === "/api/lead") {
if (request.method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "https://webclaw.dev",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
});
}
if (request.method !== "POST") {
return new Response("Method Not Allowed", { status: 405 });
}
const originHeader = request.headers.get("Origin");
const refererHeader = request.headers.get("Referer");
const originToCheck = originHeader ?? refererHeader;
if (!originToCheck) {
return new Response("Missing origin", { status: 403 });
}
let origin: string;
try {
origin = new URL(originToCheck).origin;
} catch (error) {
return new Response("Invalid origin", { status: 403 });
}
if (!ALLOWED_ORIGINS.has(origin)) {
return new Response("Forbidden", { status: 403 });
}
const ip =
request.headers.get("CF-Connecting-IP") ??
request.headers.get("X-Forwarded-For") ??
"unknown";
const now = Date.now();
const rateState = rateLimitCache.get(ip);
if (!rateState || rateState.resetAt < now) {
rateLimitCache.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
} else {
rateState.count += 1;
if (rateState.count > RATE_LIMIT_MAX) {
return new Response("Too many requests", { status: 429 });
}
}
let payload: LeadPayload;
try {
payload = (await request.json()) as LeadPayload;
} catch (error) {
return new Response("Invalid JSON", { status: 400 });
}
if (!payload.workEmail) {
return new Response("Missing work email", { status: 400 });
}
if (payload.website) {
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": origin,
},
});
}
if (payload.submittedAt) {
const submittedTime = Number(payload.submittedAt);
const maxAgeMs = 15 * 60_000;
if (!Number.isFinite(submittedTime)) {
return new Response("Invalid submission time", { status: 400 });
}
if (submittedTime > now + 5_000) {
return new Response("Invalid submission time", { status: 400 });
}
if (now - submittedTime > maxAgeMs) {
return new Response("Submission expired", { status: 400 });
}
}
const webhookPayload: SlackWebhookPayload = {
text: `New WebClaw workspace lead\n${buildSlackMessage(payload)}`,
};
const slackResponse = await fetch(env.SLACK_WEBHOOK_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(webhookPayload),
});
if (!slackResponse.ok) {
return new Response("Slack webhook failed", { status: 502 });
}
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": origin,
},
});
}
return env.ASSETS.fetch(request);
},
};
+1
View File
@@ -5,6 +5,7 @@ import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
resolve: {
dedupe: ["react", "react-dom"],
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
+1
View File
@@ -1,4 +1,5 @@
name = "webclaw-landing"
compatibility_date = "2026-02-07"
main = "src/worker.ts"
assets = { directory = "./dist", not_found_handling = "single-page-application" }