Add user setup screen

This commit is contained in:
Luke Vella 2025-04-23 12:01:41 +01:00
parent 99ca9a180d
commit eacd7b7e5e
No known key found for this signature in database
GPG key ID: 469CAD687F0D784C
11 changed files with 419 additions and 8 deletions

View file

@ -328,5 +328,10 @@
"unconfirmedEventsEmptyStateTitle": "No Unconfirmed Events",
"unconfirmedEventsEmptyStateDescription": "Unconfirmed events will show up here.",
"canceledEventsEmptyStateTitle": "No Canceled Events",
"canceledEventsEmptyStateDescription": "Canceled events will show up here."
"canceledEventsEmptyStateDescription": "Canceled events will show up here.",
"setupFormTitle": "Setup",
"setupFormDescription": "Finish setting up your account.",
"errorNotAuthenticated": "Not authenticated",
"errorInvalidFields": "Invalid fields. Please check your input.",
"errorDatabaseUpdateFailed": "Database error: Failed to update settings."
}

View file

@ -4,7 +4,7 @@ import { SidebarInset, SidebarTrigger } from "@rallly/ui/sidebar";
import Link from "next/link";
import { OptimizedAvatarImage } from "@/components/optimized-avatar-image";
import { getUser } from "@/data/get-user";
import { getOnboardedUser } from "@/data/get-onboarded-user";
import { CommandMenu } from "@/features/navigation/command-menu";
import { TimezoneProvider } from "@/features/timezone/client/context";
@ -17,7 +17,8 @@ export default async function Layout({
}: {
children: React.ReactNode;
}) {
const user = await getUser();
const user = await getOnboardedUser();
return (
<TimezoneProvider initialTimezone={user.timeZone}>
<AppSidebarProvider>

View file

@ -0,0 +1,60 @@
import { redirect } from "next/navigation";
import { ProfilePicture } from "@/app/[locale]/(space)/settings/profile/profile-picture";
import { Logo } from "@/components/logo";
import { Trans } from "@/components/trans";
import { getUser } from "@/data/get-user";
import { SetupForm } from "@/features/setup/components/setup-form";
import { isUserOnboarded } from "@/features/setup/utils";
import { getTranslation } from "@/i18n/server";
export default async function SetupPage() {
const user = await getUser();
if (isUserOnboarded(user)) {
// User is already onboarded, redirect to dashboard
redirect("/");
}
return (
<div className="bg-background flex min-h-dvh justify-center p-4 sm:items-center">
<div className="w-full max-w-sm">
<article className="space-y-8 lg:space-y-10">
<div className="py-8">
<Logo className="mx-auto" />
</div>
<header className="text-center">
<h1 className="text-2xl font-bold">
<Trans i18nKey="setupFormTitle" defaults="Setup" />
</h1>
<p className="text-muted-foreground mt-1">
<Trans
i18nKey="setupFormDescription"
defaults="Finish setting up your account."
/>
</p>
</header>
<main className="space-y-4">
<ProfilePicture name={user.name} image={user.image ?? undefined} />
<SetupForm
defaultValues={{
name: user.name ?? undefined,
timeZone: user.timeZone ?? undefined,
locale: user.locale ?? undefined,
}}
/>
</main>
</article>
</div>
</div>
);
}
export async function generateMetadata() {
const { t } = await getTranslation();
return {
title: t("setupFormTitle", {
defaultValue: "Setup",
}),
};
}

View file

@ -0,0 +1,28 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { cache } from "react";
import { isUserOnboarded } from "@/features/setup/utils";
import { getUser } from "./get-user";
export const getOnboardedUser = cache(async () => {
const user = await getUser();
if (!isUserOnboarded(user)) {
const headerList = headers();
const pathname = headerList.get("x-pathname");
const searchParams =
pathname && pathname !== "/"
? `?redirectTo=${encodeURIComponent(pathname)}`
: "";
redirect(`/setup${searchParams}`);
}
return {
...user,
timeZone: user.timeZone as string,
locale: user.locale as string,
name: user.name as string,
};
});

View file

@ -0,0 +1,99 @@
"use server";
import { prisma } from "@rallly/database";
import { posthog } from "@rallly/posthog/server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { getTranslation } from "@/i18n/server";
import { auth } from "@/next-auth";
import { setupSchema } from "./schema";
export type SetupFormState = {
message?: string | null;
errors?: {
name?: string[];
timeZone?: string[];
locale?: string[];
};
};
export async function updateUserSetup(
formData: FormData,
): Promise<SetupFormState> {
const { t } = await getTranslation();
const session = await auth();
if (!session?.user?.id) {
return {
message: t("errorNotAuthenticated", {
defaultValue: "Not authenticated",
}),
};
}
const validatedFields = setupSchema.safeParse({
name: formData.get("name"),
timeZone: formData.get("timeZone"),
locale: formData.get("locale"),
});
if (!validatedFields.success) {
const errors = validatedFields.error.flatten().fieldErrors;
const translatedErrors = Object.entries(errors).reduce(
(acc, [key, value]) => {
acc[key as keyof typeof errors] = value?.map((errKey) =>
t(errKey, { defaultValue: `Invalid ${key}` }),
);
return acc;
},
{} as Required<SetupFormState>["errors"],
);
return {
errors: translatedErrors,
message: t("errorInvalidFields", {
defaultValue: "Invalid fields. Please check your input.",
}),
};
}
const { name, timeZone, locale } = validatedFields.data;
try {
await prisma.user.update({
where: { id: session.user.id },
data: {
name,
timeZone,
locale,
},
});
} catch (error) {
console.error("Failed to update user setup:", error);
return {
message: t("errorDatabaseUpdateFailed", {
defaultValue: "Database error: Failed to update settings.",
}),
};
}
posthog?.capture({
event: "user_setup_completed",
distinctId: session.user.id,
properties: {
$set: {
name,
timeZone,
locale,
},
},
});
await posthog?.shutdown();
revalidatePath("/", "layout");
redirect("/");
}

View file

@ -0,0 +1,25 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { cache } from "react";
import { getUser } from "@/data/get-user";
import { isUserOnboarded } from "./utils";
export const getOnboardedUser = cache(async () => {
const user = await getUser();
const { timeZone, locale, name } = user;
if (!isUserOnboarded({ timeZone, locale, name })) {
const headerList = headers();
const pathname = headerList.get("x-pathname");
const searchParams =
pathname && pathname !== "/"
? `?redirectTo=${encodeURIComponent(pathname)}`
: "";
redirect(`/setup${searchParams}`);
}
return { ...user, timeZone, locale, name };
});

View file

@ -0,0 +1,145 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@rallly/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@rallly/ui/form";
import { Input } from "@rallly/ui/input";
import * as React from "react";
import { useForm } from "react-hook-form";
import { LanguageSelect } from "@/components/poll/language-selector";
import { TimeZoneSelect } from "@/components/time-zone-picker/time-zone-select";
import { Trans } from "@/components/trans";
import { useTimezone } from "@/features/timezone";
import { useTranslation } from "@/i18n/client";
import { updateUserSetup } from "../actions";
import { type SetupFormValues, setupSchema } from "../schema";
interface SetupFormProps {
defaultValues?: Partial<SetupFormValues>;
}
export function SetupForm({ defaultValues }: SetupFormProps) {
const { timezone } = useTimezone();
const { i18n } = useTranslation();
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [serverError, setServerError] = React.useState<string | null>(null);
const form = useForm<SetupFormValues>({
resolver: zodResolver(setupSchema),
defaultValues: {
name: defaultValues?.name ?? "",
timeZone: defaultValues?.timeZone || timezone,
locale: defaultValues?.locale || i18n.language,
},
});
async function onSubmit(data: SetupFormValues) {
setIsSubmitting(true);
setServerError(null);
// Construct FormData for the server action
const formData = new FormData();
formData.append("name", data.name);
formData.append("timeZone", data.timeZone);
formData.append("locale", data.locale);
const result = await updateUserSetup(formData);
setIsSubmitting(false);
if (result?.message) {
setServerError(result.message);
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
{serverError && (
<p aria-live="polite" className="text-destructive text-sm">
{serverError}
</p>
)}
<div className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey="name" defaults="Name" />
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="timeZone"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey="timeZone" defaults="Time Zone" />
</FormLabel>
<FormControl>
<TimeZoneSelect
className="w-full"
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="locale"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey="language" defaults="Language" />
</FormLabel>
<FormControl>
<LanguageSelect
className="w-full"
value={field.value}
onChange={(value) => {
field.onChange(value);
i18n.changeLanguage(value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="mt-6">
<Button
variant="primary"
type="submit"
loading={isSubmitting}
className="w-full"
>
<Trans i18nKey="save" defaults="Save" />
</Button>
</div>
</form>
</Form>
);
}

View file

@ -0,0 +1,9 @@
import { z } from "zod";
export const setupSchema = z.object({
name: z.string().min(1),
timeZone: z.string().min(1),
locale: z.string().min(1),
});
export type SetupFormValues = z.infer<typeof setupSchema>;

View file

@ -0,0 +1,14 @@
// Assuming you have a base User type somewhere, e.g., in prisma types or a shared types file
import type { User as BaseUser } from "@prisma/client"; // Or wherever your base User type is
// Define the type for a user who has completed onboarding
// It extends the base user but makes required onboarding fields non-nullable
export interface OnboardedUser extends BaseUser {
name: string; // Guaranteed to be a string
timeZone: string; // Guaranteed to be a string
locale: string; // Guaranteed to be a string
}
// You might also want a type for the potentially un-onboarded user,
// which could just be your BaseUser type
export type PotentiallyUnonboardedUser = BaseUser;

View file

@ -0,0 +1,11 @@
export function isUserOnboarded({
name,
timeZone,
locale,
}: {
name?: string | null;
timeZone?: string | null;
locale?: string | null;
}) {
return name && timeZone && locale;
}

View file

@ -66,6 +66,22 @@ const {
},
},
events: {
createUser({ user }) {
if (user.id) {
posthog?.capture({
distinctId: user.id,
event: "register",
properties: {
$set: {
name: user.name,
email: user.email,
timeZone: user.timeZone ?? undefined,
locale: user.locale ?? undefined,
},
},
});
}
},
signIn({ user, account }) {
if (user.id) {
posthog?.capture({
@ -76,8 +92,8 @@ const {
$set: {
name: user.name,
email: user.email,
timeZone: user.timeZone,
locale: user.locale,
timeZone: user.timeZone ?? undefined,
locale: user.locale ?? undefined,
},
},
});
@ -168,7 +184,6 @@ const {
select: {
name: true,
email: true,
locale: true,
timeFormat: true,
timeZone: true,
weekStart: true,
@ -180,7 +195,6 @@ const {
token.name = user.name;
token.email = user.email;
token.picture = user.image;
token.locale = user.locale;
token.timeFormat = user.timeFormat;
token.timeZone = user.timeZone;
token.weekStart = user.weekStart;
@ -218,7 +232,7 @@ const requireUser = async () => {
*/
export const getUserId = async () => {
const session = await auth();
return session?.user?.email ? session.user.id : null;
return session?.user?.email ? session.user.id : undefined;
};
export const getLoggedIn = async () => {