mirror of
https://github.com/lukevella/rallly.git
synced 2025-04-29 10:16:32 +02:00
✨ Add user setup screen
This commit is contained in:
parent
99ca9a180d
commit
eacd7b7e5e
11 changed files with 419 additions and 8 deletions
|
@ -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."
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
60
apps/web/src/app/[locale]/setup/page.tsx
Normal file
60
apps/web/src/app/[locale]/setup/page.tsx
Normal 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",
|
||||
}),
|
||||
};
|
||||
}
|
28
apps/web/src/data/get-onboarded-user.tsx
Normal file
28
apps/web/src/data/get-onboarded-user.tsx
Normal 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,
|
||||
};
|
||||
});
|
99
apps/web/src/features/setup/actions.ts
Normal file
99
apps/web/src/features/setup/actions.ts
Normal 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("/");
|
||||
}
|
25
apps/web/src/features/setup/api.ts
Normal file
25
apps/web/src/features/setup/api.ts
Normal 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 };
|
||||
});
|
145
apps/web/src/features/setup/components/setup-form.tsx
Normal file
145
apps/web/src/features/setup/components/setup-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
9
apps/web/src/features/setup/schema.ts
Normal file
9
apps/web/src/features/setup/schema.ts
Normal 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>;
|
14
apps/web/src/features/setup/types.ts
Normal file
14
apps/web/src/features/setup/types.ts
Normal 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;
|
11
apps/web/src/features/setup/utils.ts
Normal file
11
apps/web/src/features/setup/utils.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
export function isUserOnboarded({
|
||||
name,
|
||||
timeZone,
|
||||
locale,
|
||||
}: {
|
||||
name?: string | null;
|
||||
timeZone?: string | null;
|
||||
locale?: string | null;
|
||||
}) {
|
||||
return name && timeZone && locale;
|
||||
}
|
|
@ -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 () => {
|
||||
|
|
Loading…
Add table
Reference in a new issue