feat: whyrating - initial project from turbostarter boilerplate

This commit is contained in:
Alejandro Gutiérrez
2026-02-04 01:54:52 +01:00
commit 5cdc07cd39
1618 changed files with 338230 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
import { Stack } from "expo-router";
export default function SetupLayout() {
return (
<Stack
initialRouteName="welcome"
screenOptions={{
headerShown: false,
animation: "fade",
animationDuration: 200,
}}
/>
);
}

View File

@@ -0,0 +1,24 @@
import { router, Stack } from "expo-router";
import { pathsConfig } from "~/config/paths";
import { BaseHeader } from "~/modules/common/layout/header";
export default function AuthLayout() {
return (
<Stack
screenOptions={{
header: () => (
<BaseHeader
onBack={() =>
router.canGoBack()
? router.back()
: router.replace(pathsConfig.index)
}
/>
),
animation: "fade",
animationDuration: 200,
}}
/>
);
}

View File

@@ -0,0 +1,39 @@
import { useLocalSearchParams } from "expo-router";
import { View } from "react-native";
import { useTranslation } from "@turbostarter/i18n";
import { Icons } from "@turbostarter/ui-mobile/icons";
import { Text } from "@turbostarter/ui-mobile/text";
import { pathsConfig } from "~/config/paths";
import { Link } from "~/modules/common/styled";
const AuthError = () => {
const { error } = useLocalSearchParams<{ error?: string }>();
const { t } = useTranslation(["auth", "common"]);
return (
<View className="bg-background flex-1 flex-col items-center justify-center gap-4 px-8">
<Icons.CircleX className="text-destructive" strokeWidth={1.2} size={80} />
<Text className="font-sans-semibold text-center text-2xl">
{t("error.general")}
</Text>
{error && (
<Text className="bg-muted rounded-md px-2 py-0.5 font-mono">
{error}
</Text>
)}
<Link
href={pathsConfig.setup.auth.login}
replace
className="text-muted-foreground mt-3 font-sans underline"
>
{t("goToLogin")}
</Link>
</View>
);
};
export default AuthError;

View File

@@ -0,0 +1,99 @@
import { useQuery } from "@tanstack/react-query";
import { Redirect, useLocalSearchParams } from "expo-router";
import { View } from "react-native";
import { handle } from "@turbostarter/api/utils";
import { pathsConfig } from "~/config/paths";
import { api } from "~/lib/api";
import { authClient } from "~/lib/auth";
import { Spinner } from "~/modules/common/spinner";
import { Invitation } from "~/modules/organization/invitations/invitation";
import { InvitationEmailMismatch } from "~/modules/organization/invitations/invitation-email-mismatch";
import { InvitationExpired } from "~/modules/organization/invitations/invitation-expired";
import { organization } from "~/modules/organization/lib/api";
const InvitationCheck = ({
invitationId,
email,
}: {
invitationId: string;
email?: string;
}) => {
const session = authClient.useSession();
const invitation = useQuery(
organization.queries.invitations.get({ id: invitationId }),
);
const invitationOrganization = useQuery({
queryKey: organization.queries.get({
id: invitation.data?.organizationId ?? "",
}).queryKey,
queryFn: () =>
handle(api.organizations[":id"].$get)({
param: { id: invitation.data?.organizationId ?? "" },
}),
enabled: !!invitation.data,
});
if (invitation.isLoading || invitationOrganization.isLoading) {
return <Spinner />;
}
if (invitation.data && invitationOrganization.data?.organization) {
return (
<Invitation
invitation={invitation.data}
organization={invitationOrganization.data.organization}
/>
);
}
if (email && session.data?.user.email !== email) {
return (
<InvitationEmailMismatch invitationId={invitationId} email={email} />
);
}
return <InvitationExpired />;
};
export default function Join() {
const { invitationId, email } = useLocalSearchParams<{
invitationId?: string;
email?: string;
}>();
const session = authClient.useSession();
if (session.isPending) {
return (
<View className="bg-background flex-1">
<Spinner />
</View>
);
}
if (!invitationId) {
return <Redirect href={pathsConfig.index} />;
}
if (!session.data?.user) {
const searchParams = new URLSearchParams();
searchParams.set("invitationId", invitationId);
if (email) searchParams.set("email", email);
searchParams.set(
"redirectTo",
`${pathsConfig.setup.auth.join}?${searchParams.toString()}`,
);
return (
<Redirect
href={`${pathsConfig.setup.auth.login}?${searchParams.toString()}`}
/>
);
}
return (
<View className="bg-background flex-1">
<InvitationCheck invitationId={invitationId} email={email} />
</View>
);
}

View File

@@ -0,0 +1,23 @@
import { useGlobalSearchParams } from "expo-router";
import { LoginFlow } from "~/modules/auth/login";
import type { Route } from "expo-router";
const LoginPage = () => {
const { redirectTo, invitationId, email } = useGlobalSearchParams<{
redirectTo?: Route;
invitationId?: string;
email?: string;
}>();
return (
<LoginFlow
redirectTo={redirectTo}
invitationId={invitationId}
email={email}
/>
);
};
export default LoginPage;

View File

@@ -0,0 +1,20 @@
import { useTranslation } from "@turbostarter/i18n";
import { ForgotPasswordForm } from "~/modules/auth/form/password/forgot";
import { AuthLayout } from "~/modules/auth/layout/base";
import { AuthHeader } from "~/modules/auth/layout/header";
const ForgotPassword = () => {
const { t } = useTranslation("auth");
return (
<AuthLayout>
<AuthHeader
title={t("account.password.forgot.header.title")}
description={t("account.password.forgot.header.description")}
/>
<ForgotPasswordForm />
</AuthLayout>
);
};
export default ForgotPassword;

View File

@@ -0,0 +1,24 @@
import { useLocalSearchParams } from "expo-router";
import { useTranslation } from "@turbostarter/i18n";
import { UpdatePasswordForm } from "~/modules/auth/form/password/update";
import { AuthLayout } from "~/modules/auth/layout/base";
import { AuthHeader } from "~/modules/auth/layout/header";
const UpdatePassword = () => {
const { token } = useLocalSearchParams<{ token?: string }>();
const { t } = useTranslation("auth");
return (
<AuthLayout>
<AuthHeader
title={t("account.password.update.header.title")}
description={t("account.password.update.header.description")}
/>
<UpdatePasswordForm token={token} />
</AuthLayout>
);
};
export default UpdatePassword;

View File

@@ -0,0 +1,49 @@
import { useGlobalSearchParams } from "expo-router";
import { View } from "react-native";
import { useTranslation } from "@turbostarter/i18n";
import { authConfig } from "~/config/auth";
import { AnonymousLogin } from "~/modules/auth/form/anonymous";
import { LoginCta } from "~/modules/auth/form/login/form";
import { RegisterForm } from "~/modules/auth/form/register-form";
import { SocialProviders } from "~/modules/auth/form/social-providers";
import { AuthLayout } from "~/modules/auth/layout/base";
import { AuthDivider } from "~/modules/auth/layout/divider";
import { AuthHeader } from "~/modules/auth/layout/header";
import { InvitationDisclaimer } from "~/modules/auth/layout/invitation-disclaimer";
import type { Route } from "expo-router";
const RegisterPage = () => {
const { t } = useTranslation("auth");
const { redirectTo, invitationId, email } = useGlobalSearchParams<{
redirectTo?: Route;
invitationId?: string;
email?: string;
}>();
return (
<AuthLayout>
<AuthHeader
title={t("register.header.title")}
description={t("register.header.description")}
/>
{invitationId && <InvitationDisclaimer />}
<SocialProviders
providers={authConfig.providers.oAuth}
redirectTo={redirectTo}
/>
{authConfig.providers.oAuth.length > 0 && <AuthDivider />}
<View className="gap-2">
<RegisterForm redirectTo={redirectTo} email={email} />
{authConfig.providers.anonymous && <AnonymousLogin />}
</View>
<LoginCta />
</AuthLayout>
);
};
export default RegisterPage;

View File

@@ -0,0 +1,143 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { router, Slot, usePathname } from "expo-router";
import { useEffect } from "react";
import { Platform, View } from "react-native";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { cn } from "@turbostarter/ui";
import { Button } from "@turbostarter/ui-mobile/button";
import { Icons } from "@turbostarter/ui-mobile/icons";
import { pathsConfig } from "~/config/paths";
import { SafeAreaView } from "~/modules/common/styled";
const steps = [
pathsConfig.setup.steps.start,
pathsConfig.setup.steps.required,
pathsConfig.setup.steps.skip,
pathsConfig.setup.steps.final,
] as const;
const useSetupStepsStore = create<{
current: number;
setCurrent: (current: number) => void;
}>()(
persist(
(set) => ({
current: 0,
setCurrent: (current) => set({ current }),
}),
{
name: "setup-steps",
storage: createJSONStorage(() => AsyncStorage),
},
),
);
export const useSetupSteps = () => {
const pathname = usePathname();
const { current, setCurrent } = useSetupStepsStore();
const step = steps[current];
useEffect(() => {
const index = steps.findIndex((step) => pathname.startsWith(step));
if (index != -1) {
setCurrent(index);
}
}, [pathname, setCurrent]);
const goNext = () => {
const next = steps[current + 1];
if (!next) {
setCurrent(-1);
return;
}
router.navigate(next);
};
const goBack = () => {
const previous = steps[current - 1];
if (!previous) {
setCurrent(-1);
return;
}
router.navigate(previous);
};
const reset = () => {
setCurrent(0);
};
return {
current,
steps,
step,
goNext,
goBack,
setCurrent,
reset,
};
};
export default function StepsLayout() {
const { current, goBack, reset } = useSetupSteps();
return (
<SafeAreaView
className="bg-background flex-1 pb-4"
style={{
paddingTop: Platform.select({
ios: 8,
android: 16,
}),
}}
>
<View className="flex-1 gap-2 px-6">
<View className="w-full flex-row items-center justify-between">
<Button
size="icon"
variant="outline"
onPress={() =>
current > 0 ? goBack() : router.replace(pathsConfig.setup.welcome)
}
>
<Icons.ChevronLeft
width={22}
height={22}
className="text-muted-foreground"
/>
</Button>
<View className="flex-row gap-1.5">
{steps.map((_, index) => (
<View
key={index}
className={cn("bg-muted h-2 w-7 rounded-full", {
"bg-primary": index <= current,
})}
/>
))}
</View>
<Button
size="icon"
variant="outline"
onPress={() => {
reset();
router.replace(pathsConfig.setup.welcome);
}}
>
<Icons.X width={20} height={20} className="text-muted-foreground" />
</Button>
</View>
<Slot />
</View>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,45 @@
import { router } from "expo-router";
import { View } from "react-native";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-mobile/button";
import { Text } from "@turbostarter/ui-mobile/text";
import { useSetupSteps } from "~/app/(setup)/steps/_layout";
import { pathsConfig } from "~/config/paths";
import { ScrollView } from "~/modules/common/styled";
export default function FinalStep() {
const { t } = useTranslation(["common", "marketing"]);
const { goNext } = useSetupSteps();
return (
<>
<ScrollView
bounces={false}
showsVerticalScrollIndicator={false}
className="pt-4"
>
<View className="items-start gap-1">
<Text className="font-sans-bold text-3xl tracking-tight">
{t("setup.steps.step.final.title")}
</Text>
<Text className="text-muted-foreground leading-snug">
{t("setup.steps.step.final.description")}
</Text>
</View>
</ScrollView>
<Button
className="mt-auto"
size="lg"
onPress={() => {
goNext();
router.replace(pathsConfig.dashboard.user.index);
}}
>
<Text>{t("finish")}</Text>
</Button>
</>
);
}

View File

@@ -0,0 +1,113 @@
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import * as Linking from "expo-linking";
import { useForm } from "react-hook-form";
import { View } from "react-native";
import z from "zod";
import { Trans, useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-mobile/button";
import { Form, FormCheckbox, FormField } from "@turbostarter/ui-mobile/form";
import { Text } from "@turbostarter/ui-mobile/text";
import { useSetupSteps } from "~/app/(setup)/steps/_layout";
import { appConfig } from "~/config/app";
import { ScrollView } from "~/modules/common/styled";
export default function RequiredStep() {
const { t } = useTranslation(["common", "marketing"]);
const { goNext } = useSetupSteps();
const form = useForm({
resolver: standardSchemaResolver(
z.object({
data: z.boolean(),
privacy: z.boolean(),
}),
),
defaultValues: {
data: false,
privacy: false,
},
});
const values = form.watch();
return (
<>
<ScrollView
bounces={false}
showsVerticalScrollIndicator={false}
className="pt-4"
>
<View className="items-start gap-6">
<View className="items-start gap-1">
<Text className="font-sans-bold text-3xl tracking-tight">
{t("setup.steps.step.required.title")}
</Text>
<Text className="text-muted-foreground leading-snug">
{t("setup.steps.step.required.description")}
</Text>
</View>
<View className="w-full gap-2">
<Form {...form}>
<FormField
control={form.control}
name="data"
render={({ field }) => (
<FormCheckbox
name="data"
label={t("setup.steps.step.required.fields.data")}
value={!!field.value}
onChange={field.onChange}
onBlur={field.onBlur}
/>
)}
/>
<FormField
control={form.control}
name="privacy"
render={({ field }) => (
<FormCheckbox
name="privacy"
label={
<Trans
i18nKey="setup.steps.step.required.fields.privacy"
ns="marketing"
components={{
a: (
<Text
onPress={() =>
Linking.openURL(
`${appConfig.url}/legal/privacy-policy`,
)
}
className="font-sans-medium text-primary text-sm underline hover:no-underline"
/>
),
}}
/>
}
value={!!field.value}
onChange={field.onChange}
onBlur={field.onBlur}
/>
)}
/>
</Form>
</View>
</View>
</ScrollView>
<Button
className="mt-auto"
size="lg"
onPress={() => goNext()}
disabled={Object.values(values).some((value) => !value)}
>
<Text>{t("continue")}</Text>
</Button>
</>
);
}

View File

@@ -0,0 +1,40 @@
import { View } from "react-native";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-mobile/button";
import { Text } from "@turbostarter/ui-mobile/text";
import { useSetupSteps } from "~/app/(setup)/steps/_layout";
import { ScrollView } from "~/modules/common/styled";
export default function SkipStep() {
const { t } = useTranslation(["common", "marketing"]);
const { goNext } = useSetupSteps();
return (
<>
<ScrollView
className="pt-4"
bounces={false}
showsVerticalScrollIndicator={false}
>
<View className="items-start gap-1">
<Text className="font-sans-bold text-3xl tracking-tight">
{t("setup.steps.step.skip.title")}
</Text>
<Text className="text-muted-foreground leading-snug">
{t("setup.steps.step.skip.description")}
</Text>
</View>
</ScrollView>
<View className="mt-auto gap-2">
<Button size="lg" onPress={() => goNext()} variant="ghost">
<Text>{t("skip")}</Text>
</Button>
<Button size="lg" onPress={() => goNext()}>
<Text>{t("continue")}</Text>
</Button>
</View>
</>
);
}

View File

@@ -0,0 +1,36 @@
import { View } from "react-native";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-mobile/button";
import { Text } from "@turbostarter/ui-mobile/text";
import { useSetupSteps } from "~/app/(setup)/steps/_layout";
import { ScrollView } from "~/modules/common/styled";
export default function StartStep() {
const { t } = useTranslation(["common", "marketing"]);
const { goNext } = useSetupSteps();
return (
<>
<ScrollView
className="pt-4"
bounces={false}
showsVerticalScrollIndicator={false}
>
<View className="items-start gap-1">
<Text className="font-sans-bold text-3xl tracking-tight">
{t("setup.steps.step.start.title")}
</Text>
<Text className="text-muted-foreground leading-snug">
{t("setup.steps.step.start.description")}
</Text>
</View>
</ScrollView>
<Button className="mt-auto" size="lg" onPress={() => goNext()}>
<Text>{t("continue")}</Text>
</Button>
</>
);
}

View File

@@ -0,0 +1,107 @@
/* eslint-disable @typescript-eslint/no-require-imports */
import { Image } from "expo-image";
import { router } from "expo-router";
import { View } from "react-native";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-mobile/button";
import {
Slider,
SliderList,
SliderListItem,
SliderPaginationDots,
SliderPaginationDot,
} from "@turbostarter/ui-mobile/slider";
import { Text } from "@turbostarter/ui-mobile/text";
import { pathsConfig } from "~/config/paths";
import { useTheme } from "~/modules/common/hooks/use-theme";
import { SafeAreaView } from "~/modules/common/styled";
import { WIDTH } from "~/utils/device";
import type { ImageSource } from "expo-image";
const images = [
{
light: require("../../../public/images/setup/1/light.png") as ImageSource,
dark: require("../../../public/images/setup/1/dark.png") as ImageSource,
},
{
light: require("../../../public/images/setup/2/light.png") as ImageSource,
dark: require("../../../public/images/setup/2/dark.png") as ImageSource,
},
{
light: require("../../../public/images/setup/3/light.png") as ImageSource,
dark: require("../../../public/images/setup/3/dark.png") as ImageSource,
},
];
const ITEM_WIDTH = WIDTH - 48;
const WelcomePage = () => {
const { resolvedTheme } = useTheme();
const { t } = useTranslation(["common", "marketing", "auth"]);
return (
<View className="bg-background flex-1 px-6 pt-2 pb-4">
<SafeAreaView className="bg-background flex-1 gap-12">
<View className="flex-1 gap-8 pt-2">
<Slider threshold={ITEM_WIDTH}>
<SliderList
data={images}
renderItem={({ item, index }) => (
<SliderListItem
className="flex-1 items-center justify-center"
style={{ width: ITEM_WIDTH }}
index={index}
>
<Image
source={item[resolvedTheme]}
contentFit="contain"
style={{ flex: 1, width: "100%" }}
/>
</SliderListItem>
)}
/>
<SliderPaginationDots>
{images.map((_, index) => (
<SliderPaginationDot key={index} index={index} />
))}
</SliderPaginationDots>
</Slider>
<View className="mx-auto mt-auto max-w-xl gap-3">
<Text className="font-sans-bold text-center text-3xl tracking-tighter sm:text-4xl">
{t("product.title")}
</Text>
<Text className="text-muted-foreground px-6 text-center text-base leading-snug sm:text-lg">
{t("product.description")}
</Text>
</View>
</View>
<View className="mt-auto gap-3">
<Button
size="lg"
onPress={() => router.navigate(pathsConfig.setup.auth.register)}
>
<Text>{t("getStarted")}</Text>
</Button>
<Button
variant="outline"
size="lg"
onPress={() => router.navigate(pathsConfig.setup.auth.login)}
>
<Text>
{t("register.alreadyHaveAccount")} {t("login.cta")}
</Text>
</Button>
</View>
</SafeAreaView>
</View>
);
};
export default WelcomePage;