- 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.
85 lines
2.7 KiB
TypeScript
85 lines
2.7 KiB
TypeScript
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<string, unknown>;
|
|
|
|
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 };
|
|
});
|