Files
2026-07-13 13:32:57 +08:00

248 lines
9.1 KiB
TypeScript

import { getFormProps, getInputProps, useForm } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import { BuildingOffice2Icon, GlobeAltIcon } from "@heroicons/react/20/solid";
import { RadioGroup } from "@radix-ui/react-radio-group";
import { json, redirect, type ActionFunction, type LoaderFunctionArgs } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { BackgroundWrapper } from "~/components/BackgroundWrapper";
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { FormTitle } from "~/components/primitives/FormTitle";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { RadioGroupItem } from "~/components/primitives/RadioButton";
import { useFaviconUrl } from "~/hooks/useFaviconUrl";
import { useFeatures } from "~/hooks/useFeatures";
import { createOrganization } from "~/models/organization.server";
import { NewOrganizationPresenter } from "~/presenters/NewOrganizationPresenter.server";
import { requireUser, requireUserId } from "~/services/session.server";
import { extractDomain, faviconUrl } from "~/utils/favicon";
import { organizationPath, rootPath } from "~/utils/pathBuilder";
const schema = z.object({
orgName: z.string().min(3).max(50),
companySize: z.string().optional(),
companyUrl: z.string().optional(),
});
export const loader = async ({ request }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const presenter = new NewOrganizationPresenter();
const { hasOrganizations } = await presenter.call({ userId: userId });
return typedjson({
hasOrganizations,
});
};
export const action: ActionFunction = async ({ request }) => {
const user = await requireUser(request);
const formData = await request.formData();
const submission = parseWithZod(formData, { schema });
if (submission.status !== "success") {
return json(submission.reply());
}
try {
const companySize = submission.value.companySize ?? null;
const onboardingData: Record<string, string> = {};
if (submission.value.companyUrl) {
onboardingData.companyUrl = submission.value.companyUrl;
}
if (submission.value.companySize) {
onboardingData.companySize = submission.value.companySize;
}
let avatar: { type: "image"; url: string } | undefined;
if (submission.value.companyUrl) {
const domain = extractDomain(submission.value.companyUrl);
if (domain) {
avatar = { type: "image", url: faviconUrl(domain) };
}
}
const organization = await createOrganization({
title: submission.value.orgName,
userId: user.id,
companySize,
onboardingData: Object.keys(onboardingData).length > 0 ? onboardingData : undefined,
avatar,
});
// A promo code carried over from the /promo landing page (via cookie) is
// redeemed later, once the org is activated through plan selection and its
// usage entitlement exists — not here, where there's nothing to grant onto.
const url = new URL(request.url);
const code = url.searchParams.get("code");
const configurationId = url.searchParams.get("configurationId");
const integration = url.searchParams.get("integration");
const next = url.searchParams.get("next");
if (code && configurationId && integration === "vercel") {
const params = new URLSearchParams({
code,
configurationId,
integration,
});
if (next) {
params.set("next", next);
}
return redirect(`${organizationPath(organization)}/projects/new?${params.toString()}`);
}
return redirect(organizationPath(organization));
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
}
};
export default function NewOrganizationPage() {
const { hasOrganizations } = useTypedLoaderData<typeof loader>();
const lastSubmission = useActionData();
const { isManagedCloud } = useFeatures();
const navigation = useNavigation();
const [companyUrl, setCompanyUrl] = useState("");
const faviconUrl = useFaviconUrl(companyUrl);
const [faviconError, setFaviconError] = useState(false);
const [form, { orgName }] = useForm({
id: "create-organization",
lastResult: lastSubmission as any,
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
shouldRevalidate: "onSubmit",
shouldValidate: "onSubmit",
});
const isLoading = navigation.state === "submitting" || navigation.state === "loading";
const urlIcon =
faviconUrl && !faviconError ? (
<img
src={faviconUrl}
alt=""
width={16}
height={16}
className="ml-0.5 shrink-0 rounded-sm"
onError={() => setFaviconError(true)}
onLoad={() => setFaviconError(false)}
/>
) : (
GlobeAltIcon
);
return (
<AppContainer className="bg-background-deep">
<BackgroundWrapper>
<MainCenteredContainer
variant="onboarding"
className="max-w-104 rounded-lg border border-grid-bright bg-background-dimmed p-5 shadow-lg"
>
<FormTitle
LeadingIcon={<BuildingOffice2Icon className="size-6 text-fuchsia-600" />}
title="Create an Organization"
/>
<Form method="post" {...getFormProps(form)}>
<Fieldset>
<InputGroup>
<Label htmlFor={orgName.id}>Organization name *</Label>
<Input
{...getInputProps(orgName, { type: "text" })}
placeholder="Your Organization name"
icon={BuildingOffice2Icon}
autoFocus
/>
<Hint>Normally your company name.</Hint>
<FormError id={orgName.errorId}>{orgName.errors}</FormError>
</InputGroup>
{isManagedCloud && (
<>
<InputGroup>
<Label htmlFor="companyUrl">URL</Label>
<Input
id="companyUrl"
name="companyUrl"
type="url"
placeholder="Your Organization URL"
icon={urlIcon}
value={companyUrl}
onChange={(e) => {
setCompanyUrl(e.target.value);
setFaviconError(false);
}}
/>
<Hint>Add your company URL and we'll use it as your organization's logo.</Hint>
</InputGroup>
<InputGroup>
<Label htmlFor="companySize">Number of employees</Label>
<RadioGroup
name="companySize"
className="flex items-center justify-between gap-2"
>
<RadioGroupItem
id="employees-1-5"
label="1-5"
value={"1-5"}
variant="button/small"
className="grow"
/>
<RadioGroupItem
id="employees-6-49"
label="6-49"
value={"6-49"}
variant="button/small"
className="grow"
/>
<RadioGroupItem
id="employees-50-99"
label="50-99"
value={"50-99"}
variant="button/small"
className="grow"
/>
<RadioGroupItem
id="employees-100+"
label="100+"
value={"100+"}
variant="button/small"
className="grow"
/>
</RadioGroup>
</InputGroup>
</>
)}
<FormButtons
confirmButton={
<Button type="submit" variant={"primary/small"} isLoading={isLoading}>
Create
</Button>
}
cancelButton={
hasOrganizations ? (
<LinkButton to={rootPath()} variant={"secondary/small"}>
Cancel
</LinkButton>
) : null
}
/>
</Fieldset>
</Form>
</MainCenteredContainer>
</BackgroundWrapper>
</AppContainer>
);
}