248 lines
9.1 KiB
TypeScript
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>
|
|
);
|
|
}
|