Compare commits

...
Author SHA1 Message Date
bflatastic d83733af02 Add mission page and update beta signup form (website)
- New /mission page: principles, ownership, and a beta/Discord CTA
- Beta form now asks Name, Email, Personal or Company, and company
  size (plus optional company name) for companies
- Link Mission and Beta from the nav Resources menu and footer; add
  /mission to the sitemap and scroll ruler
2026-10-12 08:44:55 +07:00
6 changed files with 208 additions and 68 deletions

No files matched your search

@@ -0,0 +1,90 @@
import type { Metadata } from "next";
import DiscordButton from "@/components/discord-button";
import RevealManager from "@/components/reveal-manager";
import Ownership from "@/components/landing/ownership";
import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell";
import { Accent, PageHeader } from "@/components/page/page-header";
import { Button } from "@/components/ui";
export const metadata: Metadata = {
title: "Our Mission",
description:
"ArtCraft exists to put artists in control of AI image and video. An open-source studio you own, built in the open with the creators who use it.",
alternates: { canonical: "/mission" },
openGraph: { title: "The ArtCraft Mission" },
};
const BELIEFS = [
{
title: "Control over chance",
body: "Typing a prompt and hoping isn't a creative process. Pose the characters, frame the camera, compose the shot, and let the model render what you meant.",
},
{
title: "Every model, one studio",
body: "The best models change every few months. Your workflow shouldn't. Bring each new one into the same canvas instead of rebuilding your process around it.",
},
{
title: "Built with artists",
body: "We ship early, test with our community, and let working creators decide what comes next. The people using ArtCraft shape it.",
},
];
export default function MissionPage() {
return (
<>
<RevealManager />
<PageHeader
id="mission"
index="01"
label="Mission"
annotation="Made by artists, for artists"
title={
<>
Put artists back in <Accent>control</Accent>.
</>
}
lede="AI can now make an image or a shot in seconds. We're building the studio that lets artists decide exactly what ends up on screen, and own the tools they make it with."
/>
<SectionShell id="principles">
<SectionEyebrow index="02" label="What we believe" annotation="Three principles" />
<ol data-reveal-group className="grid gap-px bg-line md:grid-cols-3">
{BELIEFS.map((belief, i) => (
<li key={belief.title} data-reveal className="bg-bg p-6 md:p-8">
<p className="hud-label text-faint">{String(i + 1).padStart(2, "0")}</p>
<h2 className="mt-6 font-display text-2xl font-medium tracking-[-0.02em] text-ink-strong">
{belief.title}
</h2>
<p className="mt-2 leading-relaxed text-muted">{belief.body}</p>
</li>
))}
</ol>
</SectionShell>
<Ownership index="03" />
<SectionShell id="community">
<div className="flex flex-col items-center px-6 py-16 text-center md:py-24">
<p className="hud-label text-faint">Join us</p>
<h2
data-reveal
className="mt-6 max-w-2xl font-display text-4xl font-medium leading-[1.02] tracking-[-0.035em] text-ink-strong sm:text-5xl"
>
Help us <Accent>build</Accent> it.
</h2>
<p className="mt-5 max-w-lg leading-relaxed text-muted">
Beta testers get new tools before anyone else and a direct line to
the team building them.
</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
<Button href="/beta" size="lg">
Join the beta
</Button>
<DiscordButton variant="secondary" size="lg" />
</div>
</div>
</SectionShell>
</>
);
}
@@ -15,6 +15,7 @@ const STATIC_ROUTES: { path: string; priority: number; changeFrequency: Entry["c
{ path: "/support", priority: 0.5, changeFrequency: "monthly" },
{ path: "/press-kit", priority: 0.5, changeFrequency: "monthly" },
{ path: "/beta", priority: 0.5, changeFrequency: "monthly" },
{ path: "/mission", priority: 0.5, changeFrequency: "monthly" },
{ path: "/seedance-2", priority: 0.8, changeFrequency: "weekly" },
{ path: "/seedance2-5", priority: 0.8, changeFrequency: "weekly" },
{ path: "/minimax-h3", priority: 0.8, changeFrequency: "weekly" },
@@ -22,19 +22,24 @@ const FORM_FIELD_IDS = {
referrer: "entry.617211358",
} as const;
const USER_TYPES = ["Business", "Hobbyist", "Film maker", "Agency", "Other"] as const;
type UserType = (typeof USER_TYPES)[number];
// Every Google Form field is free text, so these map onto the existing
// "Type" / "Type Detail" columns without touching the form.
const ACCOUNT_TYPES = ["Personal", "Company"] as const;
type AccountType = (typeof ACCOUNT_TYPES)[number];
const COMPANY_SIZES = ["1–10", "11–50", "51–200", "201–1,000", "1,000+"] as const;
type CompanySize = (typeof COMPANY_SIZES)[number];
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
type FieldErrors = Partial<Record<"name" | "email" | "type" | "typeOther", string>>;
type FieldErrors = Partial<Record<"name" | "email" | "accountType" | "companySize", string>>;
export default function BetaForm() {
const [name, setName] = useState("");
const [company, setCompany] = useState("");
const [email, setEmail] = useState("");
const [userType, setUserType] = useState<UserType | null>(null);
const [typeOther, setTypeOther] = useState("");
const [accountType, setAccountType] = useState<AccountType | null>(null);
const [companySize, setCompanySize] = useState<CompanySize | null>(null);
// Honeypot: hidden from humans; bots that fill it are silently dropped.
const [website, setWebsite] = useState("");
@@ -50,9 +55,9 @@ export default function BetaForm() {
else if (!EMAIL_PATTERN.test(email.trim())) {
next.email = "That doesn't look like a valid email address.";
}
if (userType === null) next.type = "Please pick the option closest to you.";
else if (userType === "Other" && !typeOther.trim()) {
next.typeOther = "Tell us a little about what you do.";
if (accountType === null) next.accountType = "Please choose personal or company.";
else if (accountType === "Company" && companySize === null) {
next.companySize = "Please pick your company size.";
}
return next;
};
@@ -70,21 +75,22 @@ export default function BetaForm() {
return;
}
const isCompany = accountType === "Company";
setLoading(true);
try {
const body = new URLSearchParams({
[FORM_FIELD_IDS.name]: name.trim(),
[FORM_FIELD_IDS.company]: company.trim(),
[FORM_FIELD_IDS.company]: isCompany ? company.trim() : "",
[FORM_FIELD_IDS.email]: email.trim(),
[FORM_FIELD_IDS.type]: userType ?? "",
[FORM_FIELD_IDS.typeDetail]: userType === "Other" ? typeOther.trim() : "",
[FORM_FIELD_IDS.type]: accountType ?? "",
[FORM_FIELD_IDS.typeDetail]: isCompany ? (companySize ?? "") : "",
[FORM_FIELD_IDS.page]: window.location.href,
[FORM_FIELD_IDS.referrer]: document.referrer,
});
// Google Forms sends no CORS headers, so this is fire-and-forget: the
// response is opaque and "request completed" counts as success.
await fetch(FORM_RESPONSE_URL, { method: "POST", mode: "no-cors", body });
trackEvent("generate_lead", { form_name: "beta", user_type: userType ?? "" });
trackEvent("generate_lead", { form_name: "beta", user_type: accountType ?? "" });
setSubmitted(true);
} catch {
setSubmitError(
@@ -95,9 +101,14 @@ export default function BetaForm() {
}
};
const selectType = (type: UserType) => {
setUserType(type);
setErrors((prev) => ({ ...prev, type: undefined, typeOther: undefined }));
const selectAccountType = (type: AccountType) => {
setAccountType(type);
setErrors((prev) => ({ ...prev, accountType: undefined, companySize: undefined }));
};
const selectCompanySize = (size: CompanySize) => {
setCompanySize(size);
setErrors((prev) => ({ ...prev, companySize: undefined }));
};
if (submitted) {
@@ -142,16 +153,6 @@ export default function BetaForm() {
errorMessage={errors.name}
/>
<Input
id="beta-company"
label="Company (optional)"
type="text"
autoComplete="organization"
value={company}
onChange={(e) => setCompany(e.target.value)}
placeholder="Studio or company name"
/>
<Input
id="beta-email"
label="Email"
@@ -164,50 +165,35 @@ export default function BetaForm() {
errorMessage={errors.email}
/>
<div className="flex flex-col">
<Label htmlFor="beta-type-Business">I am a…</Label>
<div className="flex flex-wrap gap-px bg-line" role="radiogroup" aria-label="I am a…">
{USER_TYPES.map((type) => {
const selected = userType === type;
return (
<button
key={type}
id={`beta-type-${type}`}
type="button"
role="radio"
aria-checked={selected}
onClick={() => selectType(type)}
className={twMerge(
"hud-label h-10 px-4 transition-colors",
selected
? "bg-invert-bg text-invert-fg"
: "bg-bg text-muted hover:bg-bg-raised hover:text-ink",
)}
>
{type}
</button>
);
})}
</div>
{errors.type && (
<p className="mt-1 font-mono text-[10px] uppercase tracking-[0.1em] text-danger">
{errors.type}
</p>
)}
</div>
<ChoiceGroup
id="beta-account-type"
label="Signing up as"
options={ACCOUNT_TYPES}
value={accountType}
onChange={selectAccountType}
error={errors.accountType}
/>
{userType === "Other" && (
<Input
id="beta-type-other"
label="What best describes you?"
type="text"
value={typeOther}
onChange={(e) => setTypeOther(e.target.value)}
placeholder="Game developer, educator, …"
autoFocus
isError={!!errors.typeOther}
errorMessage={errors.typeOther}
/>
{accountType === "Company" && (
<>
<Input
id="beta-company"
label="Company name (optional)"
type="text"
autoComplete="organization"
value={company}
onChange={(e) => setCompany(e.target.value)}
placeholder="Studio or company name"
/>
<ChoiceGroup
id="beta-company-size"
label="Company size"
options={COMPANY_SIZES}
value={companySize}
onChange={selectCompanySize}
error={errors.companySize}
/>
</>
)}
<div aria-hidden="true" className="absolute -left-[9999px] top-auto h-px w-px overflow-hidden">
@@ -235,3 +221,61 @@ export default function BetaForm() {
</form>
);
}
// Single-select row of squared pills, ARIA radio semantics.
function ChoiceGroup<T extends string>({
id,
label,
options,
value,
onChange,
error,
}: {
id: string;
label: string;
options: readonly T[];
value: T | null;
onChange: (value: T) => void;
error?: string;
}) {
const labelId = `${id}-label`;
const errorId = `${id}-error`;
return (
<div className="flex flex-col">
<Label id={labelId}>{label}</Label>
<div
className="flex flex-wrap gap-px bg-line"
role="radiogroup"
aria-labelledby={labelId}
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
>
{options.map((option) => {
const selected = value === option;
return (
<button
key={option}
type="button"
role="radio"
aria-checked={selected}
onClick={() => onChange(option)}
className={twMerge(
"hud-label h-10 flex-1 whitespace-nowrap px-4 transition-colors",
selected
? "bg-invert-bg text-invert-fg"
: "bg-bg text-muted hover:bg-bg-raised hover:text-ink",
)}
>
{option}
</button>
);
})}
</div>
{error && (
<p id={errorId} className="mt-1 font-mono text-[10px] uppercase tracking-[0.1em] text-danger">
{error}
</p>
)}
</div>
);
}
@@ -31,6 +31,8 @@ const LINK_COLUMNS: {
{ name: "News", href: "/news" },
{ name: "FAQ", href: "/faq" },
{ name: "Press Kit", href: "/press-kit" },
{ name: "Mission", href: "/mission" },
{ name: "Beta", href: "/beta" },
{ name: "Support", href: "/support" },
],
},
@@ -28,6 +28,8 @@ const NAV_ITEMS: NavEntry[] = [
{ name: "News", href: "/news" },
{ name: "FAQ", href: "/faq" },
{ name: "Press Kit", href: "/press-kit" },
{ name: "Mission", href: "/mission" },
{ name: "Beta", href: "/beta" },
],
},
{ name: "Download", href: "/download" },
@@ -156,6 +156,7 @@ export const RULER_SECTIONS: RulerSection[] = [
{ id: "support", label: "SUPPORT" },
{ id: "press-kit", label: "PRESS KIT" },
{ id: "beta", label: "BETA" },
{ id: "mission", label: "MISSION" },
{ id: "plans", label: "PLANS" },
{ id: "enterprise", label: "ENTERPRISE" },
{ id: "credits", label: "CREDITS" },