From eacd7b7e5e872164211e93c69294d84c2c609aa7 Mon Sep 17 00:00:00 2001 From: Luke Vella Date: Wed, 23 Apr 2025 12:01:41 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20user=20setup=20screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/public/locales/en/app.json | 7 +- apps/web/src/app/[locale]/(space)/layout.tsx | 5 +- apps/web/src/app/[locale]/setup/page.tsx | 60 ++++++++ apps/web/src/data/get-onboarded-user.tsx | 28 ++++ apps/web/src/features/setup/actions.ts | 99 ++++++++++++ apps/web/src/features/setup/api.ts | 25 +++ .../features/setup/components/setup-form.tsx | 145 ++++++++++++++++++ apps/web/src/features/setup/schema.ts | 9 ++ apps/web/src/features/setup/types.ts | 14 ++ apps/web/src/features/setup/utils.ts | 11 ++ apps/web/src/next-auth.ts | 24 ++- 11 files changed, 419 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/app/[locale]/setup/page.tsx create mode 100644 apps/web/src/data/get-onboarded-user.tsx create mode 100644 apps/web/src/features/setup/actions.ts create mode 100644 apps/web/src/features/setup/api.ts create mode 100644 apps/web/src/features/setup/components/setup-form.tsx create mode 100644 apps/web/src/features/setup/schema.ts create mode 100644 apps/web/src/features/setup/types.ts create mode 100644 apps/web/src/features/setup/utils.ts diff --git a/apps/web/public/locales/en/app.json b/apps/web/public/locales/en/app.json index 67f1900f9..e9ff72c0d 100644 --- a/apps/web/public/locales/en/app.json +++ b/apps/web/public/locales/en/app.json @@ -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." } diff --git a/apps/web/src/app/[locale]/(space)/layout.tsx b/apps/web/src/app/[locale]/(space)/layout.tsx index ac55bb1e6..e5f52accc 100644 --- a/apps/web/src/app/[locale]/(space)/layout.tsx +++ b/apps/web/src/app/[locale]/(space)/layout.tsx @@ -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 ( diff --git a/apps/web/src/app/[locale]/setup/page.tsx b/apps/web/src/app/[locale]/setup/page.tsx new file mode 100644 index 000000000..4c13473ba --- /dev/null +++ b/apps/web/src/app/[locale]/setup/page.tsx @@ -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 ( +
+
+
+
+ +
+
+

+ +

+

+ +

+
+
+ + +
+
+
+
+ ); +} + +export async function generateMetadata() { + const { t } = await getTranslation(); + return { + title: t("setupFormTitle", { + defaultValue: "Setup", + }), + }; +} diff --git a/apps/web/src/data/get-onboarded-user.tsx b/apps/web/src/data/get-onboarded-user.tsx new file mode 100644 index 000000000..8c446052e --- /dev/null +++ b/apps/web/src/data/get-onboarded-user.tsx @@ -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, + }; +}); diff --git a/apps/web/src/features/setup/actions.ts b/apps/web/src/features/setup/actions.ts new file mode 100644 index 000000000..f1c17858e --- /dev/null +++ b/apps/web/src/features/setup/actions.ts @@ -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 { + 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["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("/"); +} diff --git a/apps/web/src/features/setup/api.ts b/apps/web/src/features/setup/api.ts new file mode 100644 index 000000000..65ad40e99 --- /dev/null +++ b/apps/web/src/features/setup/api.ts @@ -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 }; +}); diff --git a/apps/web/src/features/setup/components/setup-form.tsx b/apps/web/src/features/setup/components/setup-form.tsx new file mode 100644 index 000000000..d4553b9d8 --- /dev/null +++ b/apps/web/src/features/setup/components/setup-form.tsx @@ -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; +} + +export function SetupForm({ defaultValues }: SetupFormProps) { + const { timezone } = useTimezone(); + const { i18n } = useTranslation(); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const [serverError, setServerError] = React.useState(null); + const form = useForm({ + 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 ( +
+ + {serverError && ( +

+ {serverError} +

+ )} +
+ ( + + + + + + + + + + )} + /> + + ( + + + + + + + + + + )} + /> + + ( + + + + + + + { + field.onChange(value); + i18n.changeLanguage(value); + }} + /> + + + + )} + /> +
+
+ +
+
+ + ); +} diff --git a/apps/web/src/features/setup/schema.ts b/apps/web/src/features/setup/schema.ts new file mode 100644 index 000000000..03600917d --- /dev/null +++ b/apps/web/src/features/setup/schema.ts @@ -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; diff --git a/apps/web/src/features/setup/types.ts b/apps/web/src/features/setup/types.ts new file mode 100644 index 000000000..da1adf5ed --- /dev/null +++ b/apps/web/src/features/setup/types.ts @@ -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; diff --git a/apps/web/src/features/setup/utils.ts b/apps/web/src/features/setup/utils.ts new file mode 100644 index 000000000..657c64888 --- /dev/null +++ b/apps/web/src/features/setup/utils.ts @@ -0,0 +1,11 @@ +export function isUserOnboarded({ + name, + timeZone, + locale, +}: { + name?: string | null; + timeZone?: string | null; + locale?: string | null; +}) { + return name && timeZone && locale; +} diff --git a/apps/web/src/next-auth.ts b/apps/web/src/next-auth.ts index dd1a1235e..63edb07ab 100644 --- a/apps/web/src/next-auth.ts +++ b/apps/web/src/next-auth.ts @@ -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 () => {