mirror of
https://github.com/ibelick/webclaw.git
synced 2026-08-14 09:02:04 +00:00
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -12,5 +12,5 @@ if (!root) {
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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,4 +1,5 @@
|
||||
name = "webclaw-landing"
|
||||
compatibility_date = "2026-02-07"
|
||||
main = "src/worker.ts"
|
||||
|
||||
assets = { directory = "./dist", not_found_handling = "single-page-application" }
|
||||
|
||||
Reference in New Issue
Block a user