mirror of
https://github.com/lukevella/rallly.git
synced 2025-07-21 18:27:53 +02:00
✨ Add setup screen (#1682)
This commit is contained in:
parent
42d0077045
commit
843dfc4a7d
19 changed files with 526 additions and 93 deletions
|
@ -57,6 +57,7 @@
|
||||||
"crypto": "^1.0.1",
|
"crypto": "^1.0.1",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"i18next": "^24.2.2",
|
"i18next": "^24.2.2",
|
||||||
|
"i18next-http-backend": "^3.0.2",
|
||||||
"i18next-icu": "^2.3.0",
|
"i18next-icu": "^2.3.0",
|
||||||
"i18next-resources-to-backend": "^1.2.1",
|
"i18next-resources-to-backend": "^1.2.1",
|
||||||
"ics": "^3.1.0",
|
"ics": "^3.1.0",
|
||||||
|
|
|
@ -328,5 +328,10 @@
|
||||||
"unconfirmedEventsEmptyStateTitle": "No Unconfirmed Events",
|
"unconfirmedEventsEmptyStateTitle": "No Unconfirmed Events",
|
||||||
"unconfirmedEventsEmptyStateDescription": "Unconfirmed events will show up here.",
|
"unconfirmedEventsEmptyStateDescription": "Unconfirmed events will show up here.",
|
||||||
"canceledEventsEmptyStateTitle": "No Canceled Events",
|
"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,8 +4,8 @@ import { SidebarInset, SidebarTrigger } from "@rallly/ui/sidebar";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { OptimizedAvatarImage } from "@/components/optimized-avatar-image";
|
import { OptimizedAvatarImage } from "@/components/optimized-avatar-image";
|
||||||
import { getUser } from "@/data/get-user";
|
|
||||||
import { CommandMenu } from "@/features/navigation/command-menu";
|
import { CommandMenu } from "@/features/navigation/command-menu";
|
||||||
|
import { getOnboardedUser } from "@/features/setup/api";
|
||||||
import { TimezoneProvider } from "@/features/timezone/client/context";
|
import { TimezoneProvider } from "@/features/timezone/client/context";
|
||||||
|
|
||||||
import { AppSidebar } from "./components/sidebar/app-sidebar";
|
import { AppSidebar } from "./components/sidebar/app-sidebar";
|
||||||
|
@ -17,7 +17,8 @@ export default async function Layout({
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const user = await getUser();
|
const user = await getOnboardedUser();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TimezoneProvider initialTimezone={user.timeZone}>
|
<TimezoneProvider initialTimezone={user.timeZone}>
|
||||||
<AppSidebarProvider>
|
<AppSidebarProvider>
|
||||||
|
|
|
@ -3,7 +3,6 @@ import { Button } from "@rallly/ui/button";
|
||||||
import { Form, FormField, FormItem, FormLabel } from "@rallly/ui/form";
|
import { Form, FormField, FormItem, FormLabel } from "@rallly/ui/form";
|
||||||
import { ArrowUpRight } from "lucide-react";
|
import { ArrowUpRight } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
@ -20,7 +19,6 @@ type FormData = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
export const LanguagePreference = () => {
|
export const LanguagePreference = () => {
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
const router = useRouter();
|
|
||||||
const form = useForm<FormData>({
|
const form = useForm<FormData>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
language: i18n.language,
|
language: i18n.language,
|
||||||
|
@ -34,7 +32,8 @@ export const LanguagePreference = () => {
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(async (data) => {
|
onSubmit={form.handleSubmit(async (data) => {
|
||||||
await updatePreferences({ locale: data.language });
|
await updatePreferences({ locale: data.language });
|
||||||
router.refresh();
|
i18n.changeLanguage(data.language);
|
||||||
|
form.reset({ language: data.language });
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import "tailwindcss/tailwind.css";
|
import "tailwindcss/tailwind.css";
|
||||||
import "../../style.css";
|
import "../../style.css";
|
||||||
|
|
||||||
|
import { defaultLocale, supportedLngs } from "@rallly/languages";
|
||||||
import { PostHogProvider } from "@rallly/posthog/client";
|
import { PostHogProvider } from "@rallly/posthog/client";
|
||||||
import { Toaster } from "@rallly/ui/toaster";
|
import { Toaster } from "@rallly/ui/toaster";
|
||||||
import { TooltipProvider } from "@rallly/ui/tooltip";
|
import { TooltipProvider } from "@rallly/ui/tooltip";
|
||||||
import { dehydrate, Hydrate } from "@tanstack/react-query";
|
|
||||||
import { domAnimation, LazyMotion } from "motion/react";
|
import { domAnimation, LazyMotion } from "motion/react";
|
||||||
import type { Viewport } from "next";
|
import type { Viewport } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
|
@ -13,11 +13,12 @@ import React from "react";
|
||||||
|
|
||||||
import { TimeZoneChangeDetector } from "@/app/[locale]/timezone-change-detector";
|
import { TimeZoneChangeDetector } from "@/app/[locale]/timezone-change-detector";
|
||||||
import { UserProvider } from "@/components/user-provider";
|
import { UserProvider } from "@/components/user-provider";
|
||||||
|
import { getUser } from "@/data/get-user";
|
||||||
import { TimezoneProvider } from "@/features/timezone/client/context";
|
import { TimezoneProvider } from "@/features/timezone/client/context";
|
||||||
import { I18nProvider } from "@/i18n/client";
|
import { I18nProvider } from "@/i18n/client";
|
||||||
import { auth } from "@/next-auth";
|
import { getLocale } from "@/i18n/server/get-locale";
|
||||||
|
import { auth, getUserId } from "@/next-auth";
|
||||||
import { TRPCProvider } from "@/trpc/client/provider";
|
import { TRPCProvider } from "@/trpc/client/provider";
|
||||||
import { createSSRHelper } from "@/trpc/server/create-ssr-helper";
|
|
||||||
import { ConnectedDayjsProvider } from "@/utils/dayjs";
|
import { ConnectedDayjsProvider } from "@/utils/dayjs";
|
||||||
|
|
||||||
import { PostHogPageView } from "../posthog-page-view";
|
import { PostHogPageView } from "../posthog-page-view";
|
||||||
|
@ -32,44 +33,58 @@ export const viewport: Viewport = {
|
||||||
initialScale: 1,
|
initialScale: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function loadLocale() {
|
||||||
|
let locale = getLocale();
|
||||||
|
|
||||||
|
const userId = await getUserId();
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
const user = await getUser();
|
||||||
|
if (user.locale) {
|
||||||
|
locale = user.locale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!supportedLngs.includes(locale)) {
|
||||||
|
return defaultLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
return locale;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function Root({
|
export default async function Root({
|
||||||
children,
|
children,
|
||||||
params: { locale },
|
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
params: { locale: string };
|
|
||||||
}) {
|
}) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const trpc = await createSSRHelper();
|
const locale = await loadLocale();
|
||||||
await trpc.user.subscription.prefetch();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale} className={inter.className}>
|
<html lang={locale} className={inter.className}>
|
||||||
<body>
|
<body>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<I18nProvider>
|
<I18nProvider locale={locale}>
|
||||||
<TRPCProvider>
|
<TRPCProvider>
|
||||||
<Hydrate state={dehydrate(trpc.queryClient)}>
|
<LazyMotion features={domAnimation}>
|
||||||
<LazyMotion features={domAnimation}>
|
<SessionProvider session={session}>
|
||||||
<SessionProvider session={session}>
|
<PostHogProvider>
|
||||||
<PostHogProvider>
|
<PostHogPageView />
|
||||||
<PostHogPageView />
|
<TooltipProvider>
|
||||||
<TooltipProvider>
|
<UserProvider>
|
||||||
<UserProvider>
|
<TimezoneProvider
|
||||||
<TimezoneProvider
|
initialTimezone={session?.user?.timeZone ?? undefined}
|
||||||
initialTimezone={session?.user?.timeZone ?? undefined}
|
>
|
||||||
>
|
<ConnectedDayjsProvider>
|
||||||
<ConnectedDayjsProvider>
|
{children}
|
||||||
{children}
|
<TimeZoneChangeDetector />
|
||||||
<TimeZoneChangeDetector />
|
</ConnectedDayjsProvider>
|
||||||
</ConnectedDayjsProvider>
|
</TimezoneProvider>
|
||||||
</TimezoneProvider>
|
</UserProvider>
|
||||||
</UserProvider>
|
</TooltipProvider>
|
||||||
</TooltipProvider>
|
</PostHogProvider>
|
||||||
</PostHogProvider>
|
</SessionProvider>
|
||||||
</SessionProvider>
|
</LazyMotion>
|
||||||
</LazyMotion>
|
|
||||||
</Hydrate>
|
|
||||||
</TRPCProvider>
|
</TRPCProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|
61
apps/web/src/app/[locale]/setup/page.tsx
Normal file
61
apps/web/src/app/[locale]/setup/page.tsx
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
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 { onboardedUserSchema } from "@/features/setup/schema";
|
||||||
|
import { getTranslation } from "@/i18n/server";
|
||||||
|
|
||||||
|
export default async function SetupPage() {
|
||||||
|
const user = await getUser();
|
||||||
|
|
||||||
|
const isUserOnboarded = onboardedUserSchema.safeParse(user).success;
|
||||||
|
|
||||||
|
if (isUserOnboarded) {
|
||||||
|
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",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
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("/");
|
||||||
|
}
|
30
apps/web/src/features/setup/api.ts
Normal file
30
apps/web/src/features/setup/api.ts
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { cache } from "react";
|
||||||
|
|
||||||
|
import { getUser } from "@/data/get-user";
|
||||||
|
|
||||||
|
import { onboardedUserSchema } from "./schema";
|
||||||
|
|
||||||
|
export const getOnboardedUser = cache(async () => {
|
||||||
|
const user = await getUser();
|
||||||
|
|
||||||
|
const onboardedUser = onboardedUserSchema.safeParse(user);
|
||||||
|
|
||||||
|
if (!onboardedUser.success) {
|
||||||
|
const headerList = headers();
|
||||||
|
const pathname = headerList.get("x-pathname");
|
||||||
|
const searchParams =
|
||||||
|
pathname && pathname !== "/"
|
||||||
|
? `?redirectTo=${encodeURIComponent(pathname)}`
|
||||||
|
: "";
|
||||||
|
redirect(`/setup${searchParams}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
timeZone: onboardedUser.data.timeZone,
|
||||||
|
locale: onboardedUser.data.locale,
|
||||||
|
name: onboardedUser.data.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>
|
||||||
|
);
|
||||||
|
}
|
17
apps/web/src/features/setup/schema.ts
Normal file
17
apps/web/src/features/setup/schema.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
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>;
|
||||||
|
|
||||||
|
export const onboardedUserSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
timeZone: z.string().min(1),
|
||||||
|
locale: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type OnboardedUser = z.infer<typeof onboardedUserSchema>;
|
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;
|
|
@ -1,5 +1,5 @@
|
||||||
"use client";
|
"use client";
|
||||||
import { useParams } from "next/navigation";
|
import httpBackend from "i18next-http-backend";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
I18nextProvider,
|
I18nextProvider,
|
||||||
|
@ -14,12 +14,20 @@ export function useTranslation() {
|
||||||
return useTranslationOrg("app");
|
return useTranslationOrg("app");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function I18nProvider({ children }: { children: React.ReactNode }) {
|
export function I18nProvider({
|
||||||
const params = useParams<{ locale: string }>();
|
children,
|
||||||
const locale = params?.locale ?? defaultNS;
|
locale,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
locale: string;
|
||||||
|
}) {
|
||||||
const res = useAsync(async () => {
|
const res = useAsync(async () => {
|
||||||
return await initI18next(locale, "app");
|
return await initI18next({
|
||||||
|
lng: locale,
|
||||||
|
middleware: (i18n) => {
|
||||||
|
i18n.use(httpBackend);
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.value) {
|
if (!res.value) {
|
||||||
|
@ -27,7 +35,7 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<I18nextProvider i18n={res.value} defaultNS={defaultNS}>
|
<I18nextProvider i18n={res.value.i18n} defaultNS={defaultNS}>
|
||||||
{children}
|
{children}
|
||||||
</I18nextProvider>
|
</I18nextProvider>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,22 +1,21 @@
|
||||||
import type { Namespace } from "i18next";
|
import type { i18n, Namespace } from "i18next";
|
||||||
import { createInstance } from "i18next";
|
import { createInstance } from "i18next";
|
||||||
import ICU from "i18next-icu";
|
import ICU from "i18next-icu";
|
||||||
import resourcesToBackend from "i18next-resources-to-backend";
|
|
||||||
import { initReactI18next } from "react-i18next/initReactI18next";
|
import { initReactI18next } from "react-i18next/initReactI18next";
|
||||||
|
|
||||||
import { getOptions } from "./settings";
|
import { getOptions } from "./settings";
|
||||||
|
|
||||||
export const initI18next = async (lng: string, ns: Namespace) => {
|
export const initI18next = async ({
|
||||||
const i18nInstance = createInstance();
|
lng,
|
||||||
await i18nInstance
|
ns,
|
||||||
.use(initReactI18next)
|
middleware,
|
||||||
.use(ICU)
|
}: {
|
||||||
.use(
|
lng: string;
|
||||||
resourcesToBackend(
|
ns?: Namespace;
|
||||||
(language: string, namespace: string) =>
|
middleware: (i18n: i18n) => void;
|
||||||
import(`../../public/locales/${language}/${namespace}.json`),
|
}) => {
|
||||||
),
|
const i18nInstance = createInstance().use(initReactI18next).use(ICU);
|
||||||
)
|
middleware(i18nInstance);
|
||||||
.init(getOptions(lng, ns));
|
const t = await i18nInstance.init(getOptions(lng, ns));
|
||||||
return i18nInstance;
|
return { t, i18n: i18nInstance };
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,14 +1,25 @@
|
||||||
|
import resourcesToBackend from "i18next-resources-to-backend";
|
||||||
|
|
||||||
import { defaultNS } from "@/i18n/settings";
|
import { defaultNS } from "@/i18n/settings";
|
||||||
import { getLocaleFromPath } from "@/utils/locale/get-locale-from-path";
|
|
||||||
|
|
||||||
import { initI18next } from "./i18n";
|
import { initI18next } from "./i18n";
|
||||||
|
import { getLocale } from "./server/get-locale";
|
||||||
|
|
||||||
export async function getTranslation(localeOverride?: string) {
|
export async function getTranslation(localeOverride?: string) {
|
||||||
const localeFromPath = getLocaleFromPath();
|
const locale = localeOverride || getLocale();
|
||||||
const locale = localeOverride || localeFromPath;
|
const { i18n } = await initI18next({
|
||||||
const i18nextInstance = await initI18next(locale, defaultNS);
|
lng: locale,
|
||||||
|
middleware: (i18n) => {
|
||||||
|
i18n.use(
|
||||||
|
resourcesToBackend(
|
||||||
|
(language: string, namespace: string) =>
|
||||||
|
import(`../../public/locales/${language}/${namespace}.json`),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
t: i18nextInstance.getFixedT(locale, defaultNS),
|
t: i18n.getFixedT(locale, defaultNS),
|
||||||
i18n: i18nextInstance,
|
i18n,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
11
apps/web/src/i18n/server/get-locale.ts
Normal file
11
apps/web/src/i18n/server/get-locale.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import { defaultLocale } from "@rallly/languages";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
export function getLocale() {
|
||||||
|
const headersList = headers();
|
||||||
|
const localeFromHeader = headersList.get("x-locale");
|
||||||
|
if (!localeFromHeader) {
|
||||||
|
return defaultLocale;
|
||||||
|
}
|
||||||
|
return localeFromHeader;
|
||||||
|
}
|
|
@ -10,30 +10,23 @@ const supportedLocales = Object.keys(languages);
|
||||||
export const middleware = withAuth(async (req) => {
|
export const middleware = withAuth(async (req) => {
|
||||||
const { nextUrl } = req;
|
const { nextUrl } = req;
|
||||||
const newUrl = nextUrl.clone();
|
const newUrl = nextUrl.clone();
|
||||||
|
const pathname = newUrl.pathname;
|
||||||
|
|
||||||
const isLoggedIn = req.auth?.user?.email;
|
const isLoggedIn = req.auth?.user?.email;
|
||||||
// if the user is already logged in, don't let them access the login page
|
// if the user is already logged in, don't let them access the login page
|
||||||
if (
|
if (/^\/(login)/.test(pathname) && isLoggedIn) {
|
||||||
/^\/(login)/.test(newUrl.pathname) &&
|
|
||||||
isLoggedIn &&
|
|
||||||
!newUrl.searchParams.get("invalidSession")
|
|
||||||
) {
|
|
||||||
newUrl.pathname = "/";
|
newUrl.pathname = "/";
|
||||||
return NextResponse.redirect(newUrl);
|
return NextResponse.redirect(newUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if locale is specified in cookie
|
const locale = req.auth?.user?.locale || getPreferredLocale(req);
|
||||||
let locale = req.auth?.user?.locale;
|
if (supportedLocales.includes(locale)) {
|
||||||
if (locale && supportedLocales.includes(locale)) {
|
newUrl.pathname = `/${locale}${pathname}`;
|
||||||
newUrl.pathname = `/${locale}${newUrl.pathname}`;
|
|
||||||
} else {
|
|
||||||
// Check if locale is specified in header
|
|
||||||
locale = getPreferredLocale(req);
|
|
||||||
newUrl.pathname = `/${locale}${newUrl.pathname}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = NextResponse.rewrite(newUrl);
|
const res = NextResponse.rewrite(newUrl);
|
||||||
res.headers.set("x-pathname", newUrl.pathname);
|
res.headers.set("x-locale", locale);
|
||||||
|
res.headers.set("x-pathname", pathname);
|
||||||
|
|
||||||
if (req.auth?.user?.id) {
|
if (req.auth?.user?.id) {
|
||||||
await withPostHog(res, { distinctID: req.auth.user.id });
|
await withPostHog(res, { distinctID: req.auth.user.id });
|
||||||
|
|
|
@ -66,6 +66,22 @@ const {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
events: {
|
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 }) {
|
signIn({ user, account }) {
|
||||||
if (user.id) {
|
if (user.id) {
|
||||||
posthog?.capture({
|
posthog?.capture({
|
||||||
|
@ -76,8 +92,8 @@ const {
|
||||||
$set: {
|
$set: {
|
||||||
name: user.name,
|
name: user.name,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
timeZone: user.timeZone,
|
timeZone: user.timeZone ?? undefined,
|
||||||
locale: user.locale,
|
locale: user.locale ?? undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -168,7 +184,6 @@ const {
|
||||||
select: {
|
select: {
|
||||||
name: true,
|
name: true,
|
||||||
email: true,
|
email: true,
|
||||||
locale: true,
|
|
||||||
timeFormat: true,
|
timeFormat: true,
|
||||||
timeZone: true,
|
timeZone: true,
|
||||||
weekStart: true,
|
weekStart: true,
|
||||||
|
@ -180,7 +195,6 @@ const {
|
||||||
token.name = user.name;
|
token.name = user.name;
|
||||||
token.email = user.email;
|
token.email = user.email;
|
||||||
token.picture = user.image;
|
token.picture = user.image;
|
||||||
token.locale = user.locale;
|
|
||||||
token.timeFormat = user.timeFormat;
|
token.timeFormat = user.timeFormat;
|
||||||
token.timeZone = user.timeZone;
|
token.timeZone = user.timeZone;
|
||||||
token.weekStart = user.weekStart;
|
token.weekStart = user.weekStart;
|
||||||
|
@ -218,7 +232,7 @@ const requireUser = async () => {
|
||||||
*/
|
*/
|
||||||
export const getUserId = async () => {
|
export const getUserId = async () => {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
return session?.user?.email ? session.user.id : null;
|
return session?.user?.email ? session.user.id : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getLoggedIn = async () => {
|
export const getLoggedIn = async () => {
|
||||||
|
|
|
@ -1,11 +0,0 @@
|
||||||
import { defaultLocale, supportedLngs } from "@rallly/languages";
|
|
||||||
import { headers } from "next/headers";
|
|
||||||
|
|
||||||
export function getLocaleFromPath() {
|
|
||||||
const headersList = headers();
|
|
||||||
const pathname = headersList.get("x-pathname") || defaultLocale;
|
|
||||||
const localeFromPath = pathname.split("/")[1];
|
|
||||||
return supportedLngs.includes(localeFromPath)
|
|
||||||
? localeFromPath
|
|
||||||
: defaultLocale;
|
|
||||||
}
|
|
21
yarn.lock
21
yarn.lock
|
@ -8102,6 +8102,13 @@ cross-env@^7.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
cross-spawn "^7.0.1"
|
cross-spawn "^7.0.1"
|
||||||
|
|
||||||
|
cross-fetch@4.0.0:
|
||||||
|
version "4.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-4.0.0.tgz#f037aef1580bb3a1a35164ea2a848ba81b445983"
|
||||||
|
integrity sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==
|
||||||
|
dependencies:
|
||||||
|
node-fetch "^2.6.12"
|
||||||
|
|
||||||
cross-spawn@^6.0.5:
|
cross-spawn@^6.0.5:
|
||||||
version "6.0.5"
|
version "6.0.5"
|
||||||
resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz"
|
resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz"
|
||||||
|
@ -9925,6 +9932,13 @@ hyphenate-style-name@^1.0.3:
|
||||||
resolved "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz"
|
resolved "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz"
|
||||||
integrity sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==
|
integrity sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==
|
||||||
|
|
||||||
|
i18next-http-backend@^3.0.2:
|
||||||
|
version "3.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz#7c8daa31aa69679e155ec1f96a37846225bdf907"
|
||||||
|
integrity sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==
|
||||||
|
dependencies:
|
||||||
|
cross-fetch "4.0.0"
|
||||||
|
|
||||||
i18next-icu@^2.3.0:
|
i18next-icu@^2.3.0:
|
||||||
version "2.3.0"
|
version "2.3.0"
|
||||||
resolved "https://registry.npmjs.org/i18next-icu/-/i18next-icu-2.3.0.tgz"
|
resolved "https://registry.npmjs.org/i18next-icu/-/i18next-icu-2.3.0.tgz"
|
||||||
|
@ -11583,6 +11597,13 @@ nice-try@^1.0.4:
|
||||||
resolved "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz"
|
resolved "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz"
|
||||||
integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==
|
integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==
|
||||||
|
|
||||||
|
node-fetch@^2.6.12:
|
||||||
|
version "2.7.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
|
||||||
|
integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
|
||||||
|
dependencies:
|
||||||
|
whatwg-url "^5.0.0"
|
||||||
|
|
||||||
node-fetch@^2.6.7:
|
node-fetch@^2.6.7:
|
||||||
version "2.6.9"
|
version "2.6.9"
|
||||||
resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.9.tgz"
|
resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.9.tgz"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue