Signup inital work on UI

pull/11421/merge^2
Sean Brydon 2023-09-07 10:30:56 +01:00
parent de5df416df
commit f6a9e3e645
4 changed files with 452 additions and 15 deletions

307
apps/web/pages/_signup.tsx Normal file
View File

@ -0,0 +1,307 @@
import { zodResolver } from "@hookform/resolvers/zod";
import type { GetServerSidePropsContext } from "next";
import { signIn } from "next-auth/react";
import { useSearchParams } from "next/navigation";
import type { CSSProperties } from "react";
import type { SubmitHandler } from "react-hook-form";
import { FormProvider, useForm } from "react-hook-form";
import { z } from "zod";
import getStripe from "@calcom/app-store/stripepayment/lib/client";
import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername";
import { getOrgFullDomain } from "@calcom/features/ee/organizations/lib/orgDomains";
import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
import { useFlagMap } from "@calcom/features/flags/context/provider";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
import { IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import slugify from "@calcom/lib/slugify";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { signupSchema as apiSignupSchema } from "@calcom/prisma/zod-utils";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { Alert, Button, EmailField, HeadSeo, PasswordField, TextField } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
import { IS_GOOGLE_LOGIN_ENABLED } from "../server/lib/constants";
import { ssrInit } from "../server/lib/ssr";
const signupSchema = apiSignupSchema.extend({
apiError: z.string().optional(), // Needed to display API errors doesnt get passed to the API
});
type FormValues = z.infer<typeof signupSchema>;
type SignupProps = inferSSRProps<typeof getServerSideProps>;
export default function Signup({ prepopulateFormValues, token, orgSlug }: SignupProps) {
const searchParams = useSearchParams();
const telemetry = useTelemetry();
const { t, i18n } = useLocale();
const flags = useFlagMap();
const methods = useForm<FormValues>({
mode: "onChange",
resolver: zodResolver(signupSchema),
defaultValues: prepopulateFormValues,
});
const {
register,
formState: { errors, isSubmitting },
} = methods;
const handleErrors = async (resp: Response) => {
if (!resp.ok) {
const err = await resp.json();
if (err.checkoutSessionId) {
const stripe = await getStripe();
if (stripe) {
console.log("Redirecting to stripe checkout");
const { error } = await stripe.redirectToCheckout({
sessionId: err.checkoutSessionId,
});
console.warn(error.message);
}
} else {
throw new Error(err.message);
}
}
};
const signUp: SubmitHandler<FormValues> = async (data) => {
await fetch("/api/auth/signup", {
body: JSON.stringify({
...data,
language: i18n.language,
token,
}),
headers: {
"Content-Type": "application/json",
},
method: "POST",
})
.then(handleErrors)
.then(async () => {
telemetry.event(telemetryEventTypes.signup, collectPageParameters());
const verifyOrGettingStarted = flags["email-verification"] ? "auth/verify-email" : "getting-started";
await signIn<"credentials">("credentials", {
...data,
callbackUrl: `${
searchParams?.get("callbackUrl")
? `${WEBAPP_URL}/${searchParams.get("callbackUrl")}`
: `${WEBAPP_URL}/${verifyOrGettingStarted}`
}?from=signup`,
});
})
.catch((err) => {
methods.setError("apiError", { message: err.message });
});
};
return (
<>
<div
className="bg-muted flex min-h-screen flex-col justify-center "
style={
{
"--cal-brand": "#111827",
"--cal-brand-emphasis": "#101010",
"--cal-brand-text": "white",
"--cal-brand-subtle": "#9CA3AF",
} as CSSProperties
}
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<HeadSeo title={t("sign_up")} description={t("sign_up")} />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="font-cal text-emphasis text-center text-3xl font-extrabold">
{t("create_your_account")}
</h2>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-default mx-2 p-6 shadow sm:rounded-lg lg:p-8">
<FormProvider {...methods}>
<form
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
if (methods.formState?.errors?.apiError) {
methods.clearErrors("apiError");
}
methods.handleSubmit(signUp)(event);
}}
className="bg-default space-y-6">
{errors.apiError && <Alert severity="error" message={errors.apiError?.message} />}
<div className="space-y-4">
<TextField
addOnLeading={
orgSlug
? getOrgFullDomain(orgSlug, { protocol: true })
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/`
}
{...register("username")}
disabled={!!orgSlug}
required
/>
<EmailField
{...register("email")}
disabled={prepopulateFormValues?.email}
className="disabled:bg-emphasis disabled:hover:cursor-not-allowed"
/>
<PasswordField
labelProps={{
className: "block text-sm font-medium text-default",
}}
{...register("password")}
hintErrors={["caplow", "min", "num"]}
className="border-default mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:border-black focus:outline-none focus:ring-black sm:text-sm"
/>
</div>
<div className="flex space-x-2 rtl:space-x-reverse">
<Button type="submit" loading={isSubmitting} className="w-full justify-center">
{t("create_account")}
</Button>
{!token && (
<Button
color="secondary"
className="w-full justify-center"
onClick={() =>
signIn("Cal.com", {
callbackUrl: searchParams?.get("callbackUrl")
? `${WEBAPP_URL}/${searchParams.get("callbackUrl")}`
: `${WEBAPP_URL}/getting-started`,
})
}>
{t("login_instead")}
</Button>
)}
</div>
</form>
</FormProvider>
</div>
</div>
</div>
</>
);
}
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const flags = await getFeatureFlagMap(prisma);
const ssr = await ssrInit(ctx);
const token = z.string().optional().parse(ctx.query.token);
const props = {
isGoogleLoginEnabled: IS_GOOGLE_LOGIN_ENABLED,
isSAMLLoginEnabled,
trpcState: ssr.dehydrate(),
prepopulateFormValues: undefined,
};
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true" || flags["disable-signup"]) {
return {
notFound: true,
};
}
// no token given, treat as a normal signup without verification token
if (!token) {
return {
props: JSON.parse(JSON.stringify(props)),
};
}
const verificationToken = await prisma.verificationToken.findUnique({
where: {
token,
},
include: {
team: {
select: {
metadata: true,
parentId: true,
parent: {
select: {
slug: true,
},
},
slug: true,
},
},
},
});
if (!verificationToken || verificationToken.expires < new Date()) {
return {
notFound: true,
};
}
const existingUser = await prisma.user.findFirst({
where: {
AND: [
{
email: verificationToken?.identifier,
},
{
emailVerified: {
not: null,
},
},
],
},
});
if (existingUser) {
return {
redirect: {
permanent: false,
destination: "/auth/login?callbackUrl=" + `${WEBAPP_URL}/${ctx.query.callbackUrl}`,
},
};
}
const guessUsernameFromEmail = (email: string) => {
const [username] = email.split("@");
return username;
};
let username = guessUsernameFromEmail(verificationToken.identifier);
const tokenTeam = {
...verificationToken?.team,
metadata: teamMetadataSchema.parse(verificationToken?.team?.metadata),
};
// Detect if the team is an org by either the metadata flag or if it has a parent team
const isOrganization = tokenTeam.metadata?.isOrganization || tokenTeam?.parentId !== null;
// If we are dealing with an org, the slug may come from the team itself or its parent
const orgSlug = isOrganization
? tokenTeam.slug || tokenTeam.metadata?.requestedSlug || tokenTeam.parent?.slug
: null;
// Org context shouldn't check if a username is premium
if (!IS_SELF_HOSTED && !isOrganization) {
// Im not sure we actually hit this because of next redirects signup to website repo - but just in case this is pretty cool :)
const { available, suggestion } = await checkPremiumUsername(username);
username = available ? username : suggestion || username;
}
return {
props: {
...props,
token,
prepopulateFormValues: {
email: verificationToken.identifier,
username: slugify(username),
},
orgSlug,
},
};
};
Signup.isThemeSupported = false;
Signup.PageWrapper = PageWrapper;

View File

@ -1,18 +1,21 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { ShieldCheckIcon } from "lucide-react";
import type { GetServerSidePropsContext } from "next";
import { signIn } from "next-auth/react";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import type { CSSProperties } from "react";
import type { SubmitHandler } from "react-hook-form";
import { FormProvider, useForm } from "react-hook-form";
import { useForm } from "react-hook-form";
import { z } from "zod";
import getStripe from "@calcom/app-store/stripepayment/lib/client";
import { getOrgFullDomain } from "@calcom/ee/organizations/lib/orgDomains";
import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername";
import { getOrgFullDomain } from "@calcom/features/ee/organizations/lib/orgDomains";
import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
import { useFlagMap } from "@calcom/features/flags/context/provider";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
import { classNames } from "@calcom/lib";
import { IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import slugify from "@calcom/lib/slugify";
@ -20,7 +23,7 @@ import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calco
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { signupSchema as apiSignupSchema } from "@calcom/prisma/zod-utils";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { Alert, Button, EmailField, HeadSeo, PasswordField, TextField } from "@calcom/ui";
import { Button, HeadSeo, PasswordField, TextField, Form } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
@ -39,16 +42,17 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
const searchParams = useSearchParams();
const telemetry = useTelemetry();
const { t, i18n } = useLocale();
const router = useRouter();
const flags = useFlagMap();
const methods = useForm<FormValues>({
const formMethods = useForm<FormValues>({
mode: "onChange",
resolver: zodResolver(signupSchema),
defaultValues: prepopulateFormValues,
defaultValues: prepopulateFormValues satisfies FormValues,
});
const {
register,
formState: { errors, isSubmitting },
} = methods;
} = formMethods;
const handleErrors = async (resp: Response) => {
if (!resp.ok) {
@ -94,14 +98,14 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
});
})
.catch((err) => {
methods.setError("apiError", { message: err.message });
formMethods.setError("apiError", { message: err.message });
});
};
return (
<>
<div
className="bg-muted flex min-h-screen flex-col justify-center "
className="bg-muted grid min-h-screen grid-cols-1 grid-rows-1 lg:grid-cols-2 "
style={
{
"--cal-brand": "#111827",
@ -109,12 +113,133 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
"--cal-brand-text": "white",
"--cal-brand-subtle": "#9CA3AF",
} as CSSProperties
}
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
}>
<HeadSeo title={t("sign_up")} description={t("sign_up")} />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="flex w-full flex-col px-28 pt-16">
{/* Header */}
<div className="flex flex-col gap-3 ">
<h1 className="font-cal text-[28px] ">Create your Cal.com account</h1>
<p className="text-subtle text-base font-medium leading-none">
Free for individuals. Team plans for collaborative features.
</p>
</div>
{/* Form Container */}
<div className="mt-10">
<Form
className="flex flex-col gap-5"
form={formMethods}
onSubmit={(values) => console.log(values)}>
{/* Username */}
<TextField
{...register("username")}
label={t("username")}
addOnLeading={
orgSlug
? getOrgFullDomain(orgSlug, { protocol: true })
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/`
}
/>
{/* Email */}
<TextField {...register("email")} label={t("email")} type="email" />
{/* Password */}
<PasswordField
label={t("password")}
{...register("password")}
hintErrors={["caplow", "min", "num"]}
/>
<Button type="submit" className="w-full justify-center">
{t("create_account")}
</Button>
</Form>
{/* Continue with Social Logins */}
<div className="mt-6">
<div className="relative flex items-center">
<div className="border-subtle flex-grow border-t" />
<span className="text-subtle leadning-none mx-2 flex-shrink text-sm font-normal ">
Or continue with
</span>
<div className="border-subtle flex-grow border-t" />
</div>
</div>
{/* Social Logins */}
<div className="mt-6 grid gap-2 lg:grid-cols-2">
<Button
color="secondary"
disabled={!!formMethods.formState.errors.username}
className={classNames(
"w-full justify-center rounded-md text-center",
formMethods.formState.errors.username ? "opacity-50" : ""
)}
onClick={async () => {
if (!formMethods.getValues("username")) {
formMethods.trigger("username");
return;
}
const username = formMethods.getValues("username");
const searchQueryParams = new URLSearchParams();
searchQueryParams.set("username", formMethods.getValues("username"));
const baseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL;
localStorage.setItem("username", username);
// @NOTE: don't remove username query param as it's required right now for stripe payment page
const googleAuthUrl = `${baseUrl}/auth/sso/google?${searchQueryParams.toString()}`;
router.push(googleAuthUrl);
}}>
<img className="mr-2 h-5 w-5" src="/google-icon.svg" alt="" />
Google
</Button>
<Button
color="secondary"
disabled={!!formMethods.formState.errors.username || !!formMethods.formState.errors.email}
className={classNames(
"w-full justify-center rounded-md text-center",
formMethods.formState.errors.username && formMethods.formState.errors.email
? "opacity-50"
: ""
)}
onClick={() => {
if (!formMethods.getValues("username")) {
formMethods.trigger("username");
}
if (!formMethods.getValues("email")) {
formMethods.trigger("email");
return;
}
const username = formMethods.getValues("username");
localStorage.setItem("username", username);
const sp = new URLSearchParams();
// @NOTE: don't remove username query param as it's required right now for stripe payment page
sp.set("username", formMethods.getValues("username"));
sp.set("email", formMethods.getValues("email"));
router.push(process.env.NEXT_PUBLIC_WEBAPP_URL + "/auth/sso/saml" + "?" + sp.toString());
}}>
<ShieldCheckIcon className="mr-2 h-5 w-5" />
{t("saml_sso")}
</Button>
</div>
</div>
{/* Already have an account & T&C */}
<div className="mb-6 mt-auto">
<div className="flex flex-col text-sm">
<Link href="/auth/login" className="text-emphasis hover:underline">
I already have an account.
</Link>
<p className="text-subtle">
By signing up, you agree to our <span className="text-emphasis">Terms of Service</span> and{" "}
<span className="text-emphasis">Privacy Policy</span>.
</p>
</div>
</div>
</div>
<div
className="my-6 w-full rounded-l-lg"
style={{
background: "radial-gradient(234.86% 110.55% at 109.58% 35%, #667593 0%, #D4D4D5 100%)",
}}>
<></>
</div>
{/* <div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="font-cal text-emphasis text-center text-3xl font-extrabold">
{t("create_your_account")}
</h2>
@ -181,7 +306,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
</form>
</FormProvider>
</div>
</div>
</div> */}
</div>
</>
);

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.31877 15.36C4.26002 15.36 0.95752 12.0588 0.95752 8.00001C0.95752 3.94126 4.26002 0.640015 8.31877 0.640015C10.1575 0.640015 11.9175 1.32126 13.2763 2.55876L13.5238 2.78501L11.0963 5.21251L10.8713 5.02001C10.1588 4.41001 9.25252 4.07376 8.31877 4.07376C6.15377 4.07376 4.39127 5.83501 4.39127 8.00001C4.39127 10.165 6.15377 11.9263 8.31877 11.9263C9.88002 11.9263 11.1138 11.1288 11.695 9.77001H7.99877V6.45626L15.215 6.46626L15.2688 6.72001C15.645 8.50626 15.3438 11.1338 13.8188 13.0138C12.5563 14.57 10.7063 15.36 8.31877 15.36Z" fill="#4B5563"/>
</svg>

After

Width:  |  Height:  |  Size: 665 B

View File

@ -22,6 +22,8 @@ export function HintsOrErrors<T extends FieldValues = FieldValues>({
// @ts-ignore
const fieldErrors: FieldErrors<T> | undefined = formState.errors[fieldName];
console.log(hintErrors, fieldErrors);
if (!hintErrors && fieldErrors && !fieldErrors.message) {
// no hints passed, field errors exist and they are custom ones
return (