feat: whyrating - initial project from turbostarter boilerplate
This commit is contained in:
14
apps/mobile/src/app/(setup)/_layout.tsx
Normal file
14
apps/mobile/src/app/(setup)/_layout.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
24
apps/mobile/src/app/(setup)/auth/_layout.tsx
Normal file
24
apps/mobile/src/app/(setup)/auth/_layout.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
39
apps/mobile/src/app/(setup)/auth/error.tsx
Normal file
39
apps/mobile/src/app/(setup)/auth/error.tsx
Normal 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;
|
||||
99
apps/mobile/src/app/(setup)/auth/join.tsx
Normal file
99
apps/mobile/src/app/(setup)/auth/join.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
apps/mobile/src/app/(setup)/auth/login.tsx
Normal file
23
apps/mobile/src/app/(setup)/auth/login.tsx
Normal 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;
|
||||
20
apps/mobile/src/app/(setup)/auth/password/forgot.tsx
Normal file
20
apps/mobile/src/app/(setup)/auth/password/forgot.tsx
Normal 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;
|
||||
24
apps/mobile/src/app/(setup)/auth/password/update.tsx
Normal file
24
apps/mobile/src/app/(setup)/auth/password/update.tsx
Normal 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;
|
||||
49
apps/mobile/src/app/(setup)/auth/register.tsx
Normal file
49
apps/mobile/src/app/(setup)/auth/register.tsx
Normal 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;
|
||||
143
apps/mobile/src/app/(setup)/steps/_layout.tsx
Normal file
143
apps/mobile/src/app/(setup)/steps/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
apps/mobile/src/app/(setup)/steps/final.tsx
Normal file
45
apps/mobile/src/app/(setup)/steps/final.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
113
apps/mobile/src/app/(setup)/steps/required.tsx
Normal file
113
apps/mobile/src/app/(setup)/steps/required.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
40
apps/mobile/src/app/(setup)/steps/skip.tsx
Normal file
40
apps/mobile/src/app/(setup)/steps/skip.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
36
apps/mobile/src/app/(setup)/steps/start.tsx
Normal file
36
apps/mobile/src/app/(setup)/steps/start.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
107
apps/mobile/src/app/(setup)/welcome.tsx
Normal file
107
apps/mobile/src/app/(setup)/welcome.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user