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;
|
||||
32
apps/mobile/src/app/+not-found.tsx
Normal file
32
apps/mobile/src/app/+not-found.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
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 { pathsConfig } from "~/config/paths";
|
||||
|
||||
export default function NotFound() {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<View className="bg-background flex flex-1 items-center justify-center gap-8 px-6">
|
||||
<View className="items-center justify-center gap-4">
|
||||
<Text className="font-sans-bold text-center text-4xl">
|
||||
{t("error.notFound")}
|
||||
</Text>
|
||||
<Text className="text-muted-foreground max-w-md text-center">
|
||||
{t("error.resourceDoesNotExist")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
onPress={() => router.replace(pathsConfig.index)}
|
||||
variant="outline"
|
||||
>
|
||||
<Text>{t("goBackHome")}</Text>
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
122
apps/mobile/src/app/_layout.tsx
Normal file
122
apps/mobile/src/app/_layout.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useReactNavigationDevTools } from "@dev-plugins/react-navigation";
|
||||
import {
|
||||
Geist_400Regular,
|
||||
Geist_500Medium,
|
||||
Geist_600SemiBold,
|
||||
Geist_700Bold,
|
||||
useFonts,
|
||||
} from "@expo-google-fonts/geist";
|
||||
import { GeistMono_400Regular } from "@expo-google-fonts/geist-mono";
|
||||
import { router, Stack, useNavigationContainerRef } from "expo-router";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
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 "~/assets/styles/globals.css";
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth";
|
||||
import "~/lib/polyfills";
|
||||
import { Providers } from "~/lib/providers/providers";
|
||||
import { useTheme } from "~/modules/common/hooks/use-theme";
|
||||
import { Updates } from "~/modules/common/updates";
|
||||
|
||||
import type { ErrorBoundaryProps } from "expo-router";
|
||||
|
||||
void SplashScreen.preventAutoHideAsync();
|
||||
|
||||
SplashScreen.setOptions({
|
||||
duration: 500,
|
||||
fade: true,
|
||||
});
|
||||
|
||||
const RootNavigator = () => {
|
||||
const navigationRef = useNavigationContainerRef();
|
||||
useReactNavigationDevTools(
|
||||
navigationRef as Parameters<typeof useReactNavigationDevTools>[0],
|
||||
);
|
||||
|
||||
return (
|
||||
<Providers>
|
||||
<Updates />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
animation: "fade",
|
||||
animationDuration: 200,
|
||||
}}
|
||||
/>
|
||||
</Providers>
|
||||
);
|
||||
};
|
||||
|
||||
const useSetupAuth = () => {
|
||||
const session = authClient.useSession();
|
||||
const activeOrganization = authClient.useActiveOrganization();
|
||||
const activeMember = authClient.useActiveMember();
|
||||
|
||||
if (session.isPending) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!session.data) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !activeOrganization.isPending && !activeMember.isPending;
|
||||
};
|
||||
|
||||
const RootLayout = () => {
|
||||
useTheme();
|
||||
const [fontsLoaded] = useFonts({
|
||||
GeistMono_400Regular,
|
||||
Geist_400Regular,
|
||||
Geist_500Medium,
|
||||
Geist_600SemiBold,
|
||||
Geist_700Bold,
|
||||
});
|
||||
|
||||
const authLoaded = useSetupAuth();
|
||||
|
||||
const loaded = fontsLoaded && authLoaded;
|
||||
|
||||
if (loaded) {
|
||||
SplashScreen.hide();
|
||||
}
|
||||
|
||||
return <RootNavigator />;
|
||||
};
|
||||
|
||||
export default RootLayout;
|
||||
|
||||
export function ErrorBoundary({ error, retry }: ErrorBoundaryProps) {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<View className="bg-background flex-1 items-center justify-center gap-8 px-6">
|
||||
<View className="flex flex-col items-center justify-center gap-4">
|
||||
<Text className="font-sans-bold text-center text-4xl">
|
||||
{t("error.general")}
|
||||
</Text>
|
||||
<Text className="text-muted-foreground text-center">
|
||||
{error.message || t("error.apologies")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center justify-center gap-4">
|
||||
<Button onPress={retry}>
|
||||
<Text>{t("tryAgain")}</Text>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onPress={() => router.replace(pathsConfig.index)}
|
||||
variant="outline"
|
||||
>
|
||||
<Text>{t("goBackHome")}</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
81
apps/mobile/src/app/dashboard/(user)/_layout.tsx
Normal file
81
apps/mobile/src/app/dashboard/(user)/_layout.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Tabs } from "expo-router";
|
||||
import { Easing } from "react-native";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
|
||||
import { UserHeader } from "~/modules/common/layout/header";
|
||||
import { TabBarLabel } from "~/modules/common/styled";
|
||||
|
||||
export default function UserLayout() {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
initialRouteName="index"
|
||||
screenOptions={{
|
||||
tabBarStyle: {
|
||||
paddingTop: 6,
|
||||
},
|
||||
animation: "fade",
|
||||
transitionSpec: {
|
||||
animation: "timing",
|
||||
config: {
|
||||
duration: 200,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
header: () => <UserHeader />,
|
||||
title: t("home"),
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<Icons.House
|
||||
size={22}
|
||||
className={cn("text-muted-foreground", {
|
||||
"text-primary": focused,
|
||||
})}
|
||||
/>
|
||||
),
|
||||
tabBarLabel: TabBarLabel,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="ai"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: t("ai"),
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<Icons.WandSparkles
|
||||
size={22}
|
||||
className={cn("text-muted-foreground", {
|
||||
"text-primary": focused,
|
||||
})}
|
||||
/>
|
||||
),
|
||||
tabBarLabel: TabBarLabel,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: t("settings"),
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<Icons.Settings
|
||||
size={22}
|
||||
className={cn("text-muted-foreground", {
|
||||
"text-primary": focused,
|
||||
})}
|
||||
/>
|
||||
),
|
||||
tabBarLabel: TabBarLabel,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
176
apps/mobile/src/app/dashboard/(user)/ai.tsx
Normal file
176
apps/mobile/src/app/dashboard/(user)/ai.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { fetch as expoFetch } from "expo/fetch";
|
||||
import { useState } from "react";
|
||||
import { View, Keyboard } from "react-native";
|
||||
import { FlatList } from "react-native-gesture-handler";
|
||||
import Markdown from "react-native-marked";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { logger } from "@turbostarter/shared/logger";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
import { Spin } from "@turbostarter/ui-mobile/spin";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
import { Textarea } from "@turbostarter/ui-mobile/textarea";
|
||||
|
||||
import { api } from "~/lib/api";
|
||||
import { KeyboardAvoidingView, ScrollView } from "~/modules/common/styled";
|
||||
|
||||
const EXAMPLES = [
|
||||
{
|
||||
icon: Icons.Globe2,
|
||||
prompt: "ai.prompt.history",
|
||||
},
|
||||
{
|
||||
icon: Icons.GraduationCap,
|
||||
prompt: "ai.prompt.capitals",
|
||||
},
|
||||
{
|
||||
icon: Icons.Atom,
|
||||
prompt: "ai.prompt.quantum",
|
||||
},
|
||||
{
|
||||
icon: Icons.Brain,
|
||||
prompt: "ai.prompt.realWorld",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export default function AI() {
|
||||
const { t } = useTranslation("marketing");
|
||||
const [input, setInput] = useState("");
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const { messages, error, sendMessage, status } = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
fetch: expoFetch as unknown as typeof globalThis.fetch,
|
||||
api: api.ai.chat.chats.$url().toString(),
|
||||
}),
|
||||
onError: (error) => logger.error(error),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<View className="bg-background flex-1 px-6">
|
||||
<Text>{error.message}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const messagesToDisplay = messages.filter((message) =>
|
||||
["assistant", "user"].includes(message.role),
|
||||
);
|
||||
|
||||
const isLoading = ["submitted", "streaming"].includes(status);
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior="padding"
|
||||
style={{ paddingTop: insets.top }}
|
||||
className="bg-background relative flex-1 px-6"
|
||||
>
|
||||
<ScrollView
|
||||
keyboardDismissMode="interactive"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentContainerClassName="flex pt-4 grow items-start justify-start gap-4 pb-8"
|
||||
showsVerticalScrollIndicator={false}
|
||||
bounces={false}
|
||||
>
|
||||
{messagesToDisplay.map((message) => (
|
||||
<View
|
||||
key={message.id}
|
||||
className={cn("max-w-full", {
|
||||
"bg-muted max-w-4/5 self-end rounded-lg px-5 py-2.5":
|
||||
message.role === "user",
|
||||
})}
|
||||
>
|
||||
{message.parts.map((part, i) => {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return message.role === "assistant" ? (
|
||||
<Markdown
|
||||
value={part.text.trim()}
|
||||
flatListProps={{
|
||||
scrollEnabled: false,
|
||||
style: {
|
||||
flexGrow: 0,
|
||||
},
|
||||
}}
|
||||
key={`${message.id}-${i}`}
|
||||
/>
|
||||
) : (
|
||||
<Text key={`${message.id}-${i}`}>{part.text}</Text>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</View>
|
||||
))}
|
||||
{isLoading && (
|
||||
<View className="mr-auto py-2.5">
|
||||
<Spin>
|
||||
<Icons.Loader className="text-muted-foreground" size={18} />
|
||||
</Spin>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{!messagesToDisplay.length && (
|
||||
<FlatList
|
||||
data={EXAMPLES}
|
||||
contentContainerClassName="gap-2 mt-auto mb-3"
|
||||
bounces={false}
|
||||
renderItem={({ item }) => (
|
||||
<Button
|
||||
onPress={() => {
|
||||
Keyboard.dismiss();
|
||||
void sendMessage({ text: t(item.prompt) });
|
||||
}}
|
||||
key={item.prompt}
|
||||
variant="outline"
|
||||
className="h-auto grow flex-row justify-start gap-3 py-3 text-left"
|
||||
>
|
||||
<item.icon
|
||||
className="text-muted-foreground shrink-0"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
<Text className="text-muted-foreground">{t(item.prompt)}</Text>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<View className="bg-background relative pb-4">
|
||||
<Textarea
|
||||
placeholder={t("ai.placeholder")}
|
||||
value={input}
|
||||
onSubmitEditing={(e) => {
|
||||
e.preventDefault();
|
||||
Keyboard.dismiss();
|
||||
void sendMessage({ text: input });
|
||||
setInput("");
|
||||
}}
|
||||
onChange={(e) => setInput(e.nativeEvent.text)}
|
||||
className="min-h-24"
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
className="absolute right-2 bottom-6 rounded-full"
|
||||
disabled={isLoading}
|
||||
onPress={() => {
|
||||
Keyboard.dismiss();
|
||||
void sendMessage({ text: input });
|
||||
setInput("");
|
||||
}}
|
||||
accessibilityLabel={t("ai.cta")}
|
||||
>
|
||||
<Icons.ArrowUp className="text-primary-foreground" />
|
||||
</Button>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
23
apps/mobile/src/app/dashboard/(user)/index.tsx
Normal file
23
apps/mobile/src/app/dashboard/(user)/index.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { View } from "react-native";
|
||||
|
||||
import { BuiltWith } from "@turbostarter/ui-mobile/built-with";
|
||||
|
||||
import { ScrollView } from "~/modules/common/styled";
|
||||
import { UserOrganizationInvitationsBanner } from "~/modules/organization/invitations/user/user-organization-invitations";
|
||||
import { OrganizationPicker } from "~/modules/organization/organization-picker";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<ScrollView
|
||||
className="bg-background"
|
||||
contentContainerClassName="items-center bg-background grow gap-6 px-6 py-2"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<UserOrganizationInvitationsBanner />
|
||||
<OrganizationPicker />
|
||||
<View className="pt-4 pb-10">
|
||||
<BuiltWith />
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
31
apps/mobile/src/app/dashboard/(user)/settings/_layout.tsx
Normal file
31
apps/mobile/src/app/dashboard/(user)/settings/_layout.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { router, Stack } from "expo-router";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
|
||||
import { BaseHeader } from "~/modules/common/layout/header";
|
||||
|
||||
export default function SettingsLayout() {
|
||||
const { t } = useTranslation("common");
|
||||
return (
|
||||
<Stack
|
||||
initialRouteName="index"
|
||||
screenOptions={{
|
||||
animation: "fade",
|
||||
animationDuration: 200,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="general" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="account" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
|
||||
<Stack.Screen
|
||||
name="billing"
|
||||
options={{
|
||||
header: () => (
|
||||
<BaseHeader title={t("billing")} onBack={router.back} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { router, Stack } from "expo-router";
|
||||
|
||||
import { isKey, useTranslation } from "@turbostarter/i18n";
|
||||
import { capitalize } from "@turbostarter/shared/utils";
|
||||
|
||||
import { BaseHeader } from "~/modules/common/layout/header";
|
||||
|
||||
export default function AccountLayout() {
|
||||
const { t, i18n } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={({ route }) => {
|
||||
const name = route.name === "index" ? "account" : route.name;
|
||||
|
||||
return {
|
||||
header: () => (
|
||||
<BaseHeader
|
||||
title={isKey(name, i18n, "common") ? t(name) : capitalize(name)}
|
||||
{...(router.canGoBack() && {
|
||||
onBack: () => router.back(),
|
||||
})}
|
||||
/>
|
||||
),
|
||||
animation: "fade",
|
||||
animationDuration: 200,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Alert } from "react-native";
|
||||
import { View } from "react-native";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { capitalize } from "@turbostarter/shared/utils";
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
import { Spin } from "@turbostarter/ui-mobile/spin";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
import { authConfig } from "~/config/auth";
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth";
|
||||
import { SocialIcons } from "~/modules/auth/form/social-providers";
|
||||
import { auth } from "~/modules/auth/lib/api";
|
||||
|
||||
import type { SocialProvider } from "@turbostarter/auth";
|
||||
|
||||
export default function AccountsScreen() {
|
||||
const { t, i18n } = useTranslation(["auth", "common"]);
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading } = useQuery({
|
||||
...auth.queries.accounts.getAll,
|
||||
enabled: !!session?.user.id,
|
||||
});
|
||||
|
||||
const accounts = data ?? [];
|
||||
const socials = accounts.filter((account) =>
|
||||
authConfig.providers.oAuth.includes(account.providerId),
|
||||
);
|
||||
const missing = authConfig.providers.oAuth.filter(
|
||||
(provider) => !socials.some((social) => social.providerId === provider),
|
||||
);
|
||||
|
||||
const connect = useMutation({
|
||||
...auth.mutations.accounts.connect,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(auth.queries.accounts.getAll);
|
||||
},
|
||||
});
|
||||
|
||||
const disconnect = useMutation({
|
||||
...auth.mutations.accounts.disconnect,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(auth.queries.accounts.getAll);
|
||||
},
|
||||
});
|
||||
|
||||
const handleDisconnect = (provider: SocialProvider) => {
|
||||
Alert.alert(
|
||||
t("account.accounts.disconnect.cta", {
|
||||
provider: capitalize(provider),
|
||||
}),
|
||||
t("account.accounts.disconnect.disclaimer", {
|
||||
provider: capitalize(provider),
|
||||
}),
|
||||
[
|
||||
{
|
||||
text: t("cancel"),
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: t("continue"),
|
||||
onPress: () => disconnect.mutate({ providerId: provider }),
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="bg-background flex-1 gap-6 p-6">
|
||||
<Text className="text-muted-foreground font-sans-medium text-base">
|
||||
{t("account.accounts.description")}
|
||||
</Text>
|
||||
|
||||
{isLoading ? (
|
||||
<View className="p-6">
|
||||
<Spin>
|
||||
<Icons.Loader2 className="text-foreground mx-auto size-6" />
|
||||
</Spin>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
{socials.length > 0 && (
|
||||
<View className="border-border overflow-hidden rounded-lg border">
|
||||
{socials.map((social) => {
|
||||
const provider = social.providerId as SocialProvider;
|
||||
const Icon = SocialIcons[provider];
|
||||
|
||||
return (
|
||||
<View
|
||||
key={social.accountId}
|
||||
className="border-border flex-row items-center border-b p-4 last:border-b-0"
|
||||
>
|
||||
<Icon className="text-foreground size-8" />
|
||||
<View className="ml-3 flex-1">
|
||||
<Text className="font-sans-medium capitalize">
|
||||
{social.providerId}
|
||||
</Text>
|
||||
<Text className="text-muted-foreground text-xs">
|
||||
{t("account.accounts.connectedAt", {
|
||||
date: social.updatedAt.toLocaleDateString(
|
||||
i18n.language,
|
||||
),
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={accounts.length === 1 || disconnect.isPending}
|
||||
onPress={() => handleDisconnect(provider)}
|
||||
>
|
||||
{disconnect.isPending &&
|
||||
disconnect.variables.providerId === provider ? (
|
||||
<Spin>
|
||||
<Icons.Loader2 className="text-foreground size-5" />
|
||||
</Spin>
|
||||
) : (
|
||||
<Icons.Trash className="text-foreground" size={20} />
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{missing.length > 0 && (
|
||||
<View className="border-border gap-3 rounded-lg border border-dashed px-5 py-4">
|
||||
<Text className="font-sans-medium text-sm">{t("addNew")}</Text>
|
||||
<View className="bg-border h-px" />
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
{missing.map((provider) => {
|
||||
const Icon = SocialIcons[provider];
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={provider}
|
||||
variant="outline"
|
||||
disabled={connect.isPending}
|
||||
onPress={() =>
|
||||
connect.mutate({
|
||||
provider,
|
||||
callbackURL:
|
||||
pathsConfig.dashboard.user.settings.account
|
||||
.accounts,
|
||||
errorCallbackURL: pathsConfig.setup.auth.error,
|
||||
})
|
||||
}
|
||||
className="h-[44px] flex-row items-center gap-2 px-4"
|
||||
>
|
||||
{connect.isPending &&
|
||||
connect.variables.provider === provider ? (
|
||||
<Spin>
|
||||
<Icons.Loader2 className="size-5" />
|
||||
</Spin>
|
||||
) : (
|
||||
<Icon className="text-foreground size-5" />
|
||||
)}
|
||||
<Text className="capitalize">{provider}</Text>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text className="text-muted-foreground -mt-3 max-w-80 text-sm">
|
||||
{t("account.accounts.info")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
172
apps/mobile/src/app/dashboard/(user)/settings/account/email.tsx
Normal file
172
apps/mobile/src/app/dashboard/(user)/settings/account/email.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import * as Linking from "expo-linking";
|
||||
import { useFocusEffect } from "expo-router";
|
||||
import { useCallback } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Alert, View } from "react-native";
|
||||
|
||||
import { emailSchema } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Badge } from "@turbostarter/ui-mobile/badge";
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormInput,
|
||||
FormDescription,
|
||||
} from "@turbostarter/ui-mobile/form";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
import { Spin } from "@turbostarter/ui-mobile/spin";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth";
|
||||
import { auth } from "~/modules/auth/lib/api";
|
||||
import { ScrollView } from "~/modules/common/styled";
|
||||
|
||||
const EditEmail = () => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
const { data, refetch } = authClient.useSession();
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
void refetch();
|
||||
}, [refetch]),
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(emailSchema),
|
||||
defaultValues: {
|
||||
email: data?.user.email ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const sendVerification = useMutation({
|
||||
...auth.mutations.email.sendVerification,
|
||||
onSuccess: () => {
|
||||
Alert.alert(t("message"), t("account.email.confirm.email.sent"));
|
||||
},
|
||||
});
|
||||
|
||||
const changeEmail = useMutation({
|
||||
...auth.mutations.email.change,
|
||||
onSuccess: () => {
|
||||
Alert.alert(t("message"), t("account.email.change.success"));
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
bounces={false}
|
||||
contentContainerClassName="bg-background flex-1 p-6"
|
||||
>
|
||||
<Form {...form}>
|
||||
<View className="flex-1 gap-6">
|
||||
<View className="gap-2">
|
||||
<View className="flex-row items-center gap-3">
|
||||
<Badge
|
||||
className={cn(
|
||||
data?.user.emailVerified
|
||||
? "bg-success/15 border-transparent"
|
||||
: "bg-destructive/15 border-transparent",
|
||||
)}
|
||||
>
|
||||
<Text
|
||||
className={
|
||||
data?.user.emailVerified
|
||||
? "text-success"
|
||||
: "text-destructive"
|
||||
}
|
||||
>
|
||||
{data?.user.emailVerified ? t("verified") : t("unverified")}
|
||||
</Text>
|
||||
</Badge>
|
||||
{!data?.user.emailVerified && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={() =>
|
||||
sendVerification.mutateAsync({
|
||||
email: data?.user.email ?? "",
|
||||
callbackURL:
|
||||
pathsConfig.dashboard.user.settings.account.email,
|
||||
fetchOptions: {
|
||||
headers: {
|
||||
"x-url": Linking.createURL(
|
||||
pathsConfig.dashboard.user.settings.account.email,
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
disabled={sendVerification.isPending}
|
||||
>
|
||||
<Text>
|
||||
{sendVerification.isPending
|
||||
? t("account.email.confirm.loading")
|
||||
: t("account.email.confirm.cta")}
|
||||
</Text>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
<Text className="text-muted-foreground font-sans-medium text-base">
|
||||
{t("account.email.change.description")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormInput
|
||||
label={t("email")}
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
keyboardType="email-address"
|
||||
editable={!form.formState.isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
<FormDescription>
|
||||
{t("account.email.change.info")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onPress={form.handleSubmit((data) =>
|
||||
changeEmail.mutateAsync({
|
||||
newEmail: data.email,
|
||||
callbackURL: pathsConfig.dashboard.user.settings.account.email,
|
||||
fetchOptions: {
|
||||
headers: {
|
||||
"x-url": Linking.createURL(
|
||||
pathsConfig.dashboard.user.settings.account.email,
|
||||
),
|
||||
},
|
||||
},
|
||||
}),
|
||||
)}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Spin>
|
||||
<Icons.Loader2 className="text-primary-foreground" />
|
||||
</Spin>
|
||||
) : (
|
||||
<Text>{t("save")}</Text>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</Form>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditEmail;
|
||||
131
apps/mobile/src/app/dashboard/(user)/settings/account/index.tsx
Normal file
131
apps/mobile/src/app/dashboard/(user)/settings/account/index.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { router } from "expo-router";
|
||||
import React from "react";
|
||||
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 { SettingsTile } from "~/modules/common/settings-tile";
|
||||
import { ScrollView } from "~/modules/common/styled";
|
||||
import { DeleteAccount } from "~/modules/user/settings/account/delete-account";
|
||||
import { Logout } from "~/modules/user/settings/account/logout";
|
||||
|
||||
const sections = [
|
||||
[
|
||||
() => {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<SettingsTile
|
||||
icon={Icons.IdCard}
|
||||
onPress={() =>
|
||||
router.navigate(pathsConfig.dashboard.user.settings.account.name)
|
||||
}
|
||||
>
|
||||
<Text>{t("name")}</Text>
|
||||
</SettingsTile>
|
||||
);
|
||||
},
|
||||
() => {
|
||||
const { t } = useTranslation("common");
|
||||
return (
|
||||
<SettingsTile
|
||||
icon={Icons.AtSign}
|
||||
onPress={() =>
|
||||
router.navigate(pathsConfig.dashboard.user.settings.account.email)
|
||||
}
|
||||
>
|
||||
<Text>{t("email")}</Text>
|
||||
</SettingsTile>
|
||||
);
|
||||
},
|
||||
() => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
return (
|
||||
<SettingsTile
|
||||
icon={Icons.Workflow}
|
||||
onPress={() =>
|
||||
router.navigate(
|
||||
pathsConfig.dashboard.user.settings.account.accounts,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text>{t("account.accounts.title")}</Text>
|
||||
</SettingsTile>
|
||||
);
|
||||
},
|
||||
() => {
|
||||
const { t } = useTranslation("auth");
|
||||
return (
|
||||
<SettingsTile
|
||||
icon={Icons.Lock}
|
||||
onPress={() =>
|
||||
router.navigate(
|
||||
pathsConfig.dashboard.user.settings.account.password,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text>{t("password")}</Text>
|
||||
</SettingsTile>
|
||||
);
|
||||
},
|
||||
() => {
|
||||
const { t } = useTranslation("auth");
|
||||
return (
|
||||
<SettingsTile
|
||||
icon={Icons.ShieldCheck}
|
||||
onPress={() =>
|
||||
router.navigate(
|
||||
pathsConfig.dashboard.user.settings.account.twoFactor,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text>{t("account.twoFactor.title")}</Text>
|
||||
</SettingsTile>
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
() => {
|
||||
const { t } = useTranslation("auth");
|
||||
return (
|
||||
<SettingsTile
|
||||
icon={Icons.MonitorSmartphone}
|
||||
onPress={() =>
|
||||
router.navigate(
|
||||
pathsConfig.dashboard.user.settings.account.sessions,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text>{t("account.sessions.title")}</Text>
|
||||
</SettingsTile>
|
||||
);
|
||||
},
|
||||
Logout,
|
||||
],
|
||||
[DeleteAccount],
|
||||
];
|
||||
|
||||
export default function Account() {
|
||||
return (
|
||||
<View className="bg-background flex-1">
|
||||
<ScrollView
|
||||
className="bg-background flex-1 py-2"
|
||||
contentContainerClassName="gap-8"
|
||||
bounces={false}
|
||||
>
|
||||
<View className="gap-6">
|
||||
{sections.map((section, index) => (
|
||||
<View key={index}>
|
||||
{section.map((item, index) => (
|
||||
<React.Fragment key={index}>{item()}</React.Fragment>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { View } from "react-native";
|
||||
|
||||
import { updateUserSchema } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormInput,
|
||||
FormDescription,
|
||||
} from "@turbostarter/ui-mobile/form";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
import { Spin } from "@turbostarter/ui-mobile/spin";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
import { authClient } from "~/lib/auth";
|
||||
import { user } from "~/modules/user/lib/api";
|
||||
|
||||
const EditName = () => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
const session = authClient.useSession();
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(updateUserSchema.pick({ name: true })),
|
||||
defaultValues: {
|
||||
name: session.data?.user.name ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const updateUser = useMutation({
|
||||
...user.mutations.update,
|
||||
onSuccess: () => {
|
||||
router.back();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<View className="bg-background flex-1 p-6">
|
||||
<Form {...form}>
|
||||
<View className="flex-1 gap-6">
|
||||
<Text className="text-muted-foreground font-sans-medium text-base">
|
||||
{t("account.name.edit.description")}
|
||||
</Text>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormInput
|
||||
{...field}
|
||||
label={t("name")}
|
||||
autoCapitalize="words"
|
||||
autoComplete="name"
|
||||
editable={!form.formState.isSubmitting}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
|
||||
<FormDescription>{t("account.name.edit.info")}</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onPress={form.handleSubmit((data) => updateUser.mutateAsync(data))}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Spin>
|
||||
<Icons.Loader2 className="text-primary-foreground" />
|
||||
</Spin>
|
||||
) : (
|
||||
<Text>{t("save")}</Text>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</Form>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditName;
|
||||
@@ -0,0 +1,161 @@
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Alert, View } from "react-native";
|
||||
|
||||
import { changePasswordSchema } from "@turbostarter/auth";
|
||||
import { Trans, useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormInput,
|
||||
FormItem,
|
||||
FormDescription,
|
||||
} from "@turbostarter/ui-mobile/form";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
import { Spin } from "@turbostarter/ui-mobile/spin";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth";
|
||||
import { auth } from "~/modules/auth/lib/api";
|
||||
import { Link, ScrollView } from "~/modules/common/styled";
|
||||
|
||||
export default function Password() {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
|
||||
const session = authClient.useSession();
|
||||
const { data: accounts, isLoading } = useQuery({
|
||||
...auth.queries.accounts.getAll,
|
||||
enabled: !!session.data?.user.id,
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(changePasswordSchema),
|
||||
});
|
||||
|
||||
const changePassword = useMutation({
|
||||
...auth.mutations.password.change,
|
||||
onSuccess: () => {
|
||||
Alert.alert(
|
||||
t("account.password.update.title"),
|
||||
t("account.password.update.success"),
|
||||
[
|
||||
{
|
||||
text: t("continue"),
|
||||
onPress: () => {
|
||||
router.back();
|
||||
form.reset();
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const hasPassword = accounts
|
||||
?.map((account) => account.providerId)
|
||||
.includes("credential");
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
bounces={false}
|
||||
contentContainerClassName="bg-background flex-1 p-6"
|
||||
>
|
||||
<Form {...form}>
|
||||
<View className="flex-1 gap-6">
|
||||
<Text className="text-muted-foreground font-sans-medium text-base">
|
||||
{t("account.password.update.description")}
|
||||
</Text>
|
||||
|
||||
{isLoading ? (
|
||||
<View className="bg-muted/50 h-20 animate-pulse" key="loading" />
|
||||
) : (
|
||||
<View className="gap-4" key="password">
|
||||
{hasPassword ? (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormInput
|
||||
label={t("currentPassword")}
|
||||
secureTextEntry
|
||||
autoComplete="current-password"
|
||||
editable={!form.formState.isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="newPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormInput
|
||||
label={t("newPassword")}
|
||||
secureTextEntry
|
||||
autoComplete="new-password"
|
||||
editable={!form.formState.isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
<FormDescription>
|
||||
{t("account.password.update.info")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<View className="border-border items-center justify-center rounded-lg border border-dashed p-6">
|
||||
<Text className="text-muted-foreground text-center">
|
||||
<Trans
|
||||
i18nKey="account.password.update.noPassword"
|
||||
ns="auth"
|
||||
components={{
|
||||
bold: (
|
||||
<Link
|
||||
href={pathsConfig.setup.auth.forgotPassword}
|
||||
className="font-sans-medium underline hover:no-underline"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!isLoading && hasPassword && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="lg"
|
||||
disabled={form.formState.isSubmitting}
|
||||
onPress={form.handleSubmit((data) =>
|
||||
changePassword.mutateAsync({
|
||||
...data,
|
||||
currentPassword: data.password,
|
||||
revokeOtherSessions: true,
|
||||
}),
|
||||
)}
|
||||
>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Spin>
|
||||
<Icons.Loader2 className="text-primary-foreground" />
|
||||
</Spin>
|
||||
) : (
|
||||
<Text className="text-primary-foreground">{t("save")}</Text>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
</Form>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { Fragment } from "react/jsx-runtime";
|
||||
import { Alert, View } from "react-native";
|
||||
import { FlatList, RefreshControl } from "react-native-gesture-handler";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
import { Skeleton } from "@turbostarter/ui-mobile/skeleton";
|
||||
import { Spin } from "@turbostarter/ui-mobile/spin";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth";
|
||||
import { auth } from "~/modules/auth/lib/api";
|
||||
|
||||
export default function Sessions() {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
const session = authClient.useSession();
|
||||
|
||||
const signOut = useMutation({
|
||||
...auth.mutations.signOut,
|
||||
onSuccess: () => {
|
||||
router.replace(pathsConfig.index);
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
data: sessions,
|
||||
isLoading,
|
||||
refetch,
|
||||
isRefetching,
|
||||
} = useQuery({
|
||||
...auth.queries.sessions.getAll,
|
||||
enabled: !!session.data?.user.id,
|
||||
});
|
||||
|
||||
const revoke = useMutation({
|
||||
...auth.mutations.sessions.revoke,
|
||||
onSuccess: async (_, token) => {
|
||||
Alert.alert(t("account.sessions.revoke.success"));
|
||||
await refetch();
|
||||
|
||||
if (token === session.data?.session.token) {
|
||||
await signOut.mutateAsync(undefined);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<View className="bg-background flex-1 gap-6 p-6">
|
||||
<Text className="text-muted-foreground font-sans-medium">
|
||||
{t("account.sessions.description")}
|
||||
</Text>
|
||||
<View className="border-border flex-1 rounded-md border">
|
||||
<FlatList
|
||||
data={sessions}
|
||||
contentContainerClassName={cn({
|
||||
"flex-1": !sessions?.length,
|
||||
"items-center justify-center": !sessions?.length && !isLoading,
|
||||
})}
|
||||
renderItem={({ item }) => (
|
||||
<View className="flex-row justify-between gap-3 px-4 py-3">
|
||||
<View className="flex-1">
|
||||
<Text className="font-sans-medium text-sm" numberOfLines={1}>
|
||||
{item.ipAddress}
|
||||
</Text>
|
||||
<Text
|
||||
className="text-muted-foreground text-sm"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.userAgent}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={revoke.isPending && revoke.variables === item.token}
|
||||
onPress={() => revoke.mutate(item.token)}
|
||||
>
|
||||
{revoke.isPending && revoke.variables === item.token ? (
|
||||
<Spin>
|
||||
<Icons.Loader2 size={16} className="text-foreground" />
|
||||
</Spin>
|
||||
) : (
|
||||
<Icons.Trash className="text-foreground" size={16} />
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
ItemSeparatorComponent={() => <View className="bg-border h-px" />}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefetching}
|
||||
onRefresh={() => refetch()}
|
||||
tintColorClassName="accent-primary"
|
||||
colorsClassName="accent-primary"
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
isLoading ? (
|
||||
<View className="w-full items-start">
|
||||
{Array.from({ length: 15 }).map((_, index, arr) => (
|
||||
<Fragment key={index}>
|
||||
<View className="flex-row items-center justify-between gap-3 px-4 py-3">
|
||||
<View className="gap-1.5">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</View>
|
||||
<Skeleton className="h-10 w-10" />
|
||||
</View>
|
||||
{index !== arr.length - 1 && (
|
||||
<View className="bg-border h-px" />
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<Text>{t("noResults")}</Text>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { useBottomSheet } from "@turbostarter/ui-mobile/bottom-sheet";
|
||||
import { Switch } from "@turbostarter/ui-mobile/switch";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
import { BackupCodesTile } from "~/modules/user/settings/account/two-factor/backup-codes/backup-codes";
|
||||
import { useBackupCodes } from "~/modules/user/settings/account/two-factor/backup-codes/use-backup-codes";
|
||||
import { RequirePassword } from "~/modules/user/settings/account/two-factor/require-password";
|
||||
import {
|
||||
TotpTile,
|
||||
TotpSheet,
|
||||
} from "~/modules/user/settings/account/two-factor/totp/totp";
|
||||
import { useTotp } from "~/modules/user/settings/account/two-factor/totp/use-totp";
|
||||
import { useTwoFactor } from "~/modules/user/settings/account/two-factor/use-two-factor";
|
||||
|
||||
import type { PasswordPayload } from "@turbostarter/auth";
|
||||
|
||||
export default function TwoFactor() {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
const { ref: totpSheetRef } = useBottomSheet();
|
||||
|
||||
const { setUri } = useTotp();
|
||||
const { setCodes } = useBackupCodes();
|
||||
|
||||
const { enabled, enable, disable } = useTwoFactor();
|
||||
|
||||
const onEnable = useCallback(
|
||||
async (data: PasswordPayload) => {
|
||||
const response = await enable.mutateAsync(data);
|
||||
|
||||
setUri(response.totpURI);
|
||||
setCodes(response.backupCodes);
|
||||
totpSheetRef.current?.present();
|
||||
},
|
||||
[enable, setUri, setCodes, totpSheetRef],
|
||||
);
|
||||
|
||||
const onDisable = useCallback(
|
||||
async (data: PasswordPayload) => {
|
||||
await disable.mutateAsync(data);
|
||||
},
|
||||
[disable],
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="bg-background flex-1 p-6">
|
||||
<View className="flex-row items-start justify-between gap-8">
|
||||
<View className="flex-1">
|
||||
<Text className="text-muted-foreground font-sans-medium text-base">
|
||||
{t("account.twoFactor.description")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<TwoFactorSwitch onSubmit={enabled ? onDisable : onEnable} />
|
||||
</View>
|
||||
|
||||
<View className="mt-6 gap-1">
|
||||
<TotpTile />
|
||||
<BackupCodesTile />
|
||||
</View>
|
||||
<TotpSheet ref={totpSheetRef} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const TwoFactorSwitch = ({
|
||||
onSubmit,
|
||||
}: {
|
||||
onSubmit: (data: PasswordPayload) => Promise<void>;
|
||||
}) => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
|
||||
const { enabled } = useTwoFactor();
|
||||
|
||||
const key = useMemo(() => {
|
||||
return enabled ? "disable" : "enable";
|
||||
}, [enabled]);
|
||||
|
||||
return (
|
||||
<RequirePassword
|
||||
onConfirm={onSubmit}
|
||||
title={t(`account.twoFactor.${key}.title`)}
|
||||
description={t(`account.twoFactor.${key}.description`)}
|
||||
cta={t(key)}
|
||||
>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={() => {
|
||||
// Switch is controlled by the RequirePassword component
|
||||
// The actual toggling happens in the onConfirm callback
|
||||
}}
|
||||
/>
|
||||
</RequirePassword>
|
||||
);
|
||||
};
|
||||
31
apps/mobile/src/app/dashboard/(user)/settings/billing.tsx
Normal file
31
apps/mobile/src/app/dashboard/(user)/settings/billing.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as Linking from "expo-linking";
|
||||
import { Pressable } from "react-native";
|
||||
import { View } from "react-native";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
export default function Billing() {
|
||||
const { t } = useTranslation(["common", "marketing"]);
|
||||
|
||||
return (
|
||||
<View className="bg-background flex flex-1 items-center justify-center px-6">
|
||||
<View className="items-center gap-6 text-center">
|
||||
<Text className="font-sans-bold mt-4 text-3xl tracking-tight">
|
||||
{t("workInProgress.title")}
|
||||
</Text>
|
||||
<Text className="text-muted-foreground text-center text-pretty">
|
||||
{t("workInProgress.description", { feature: t("billing") })}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
Linking.openURL("https://github.com/orgs/turbostarter/projects/1")
|
||||
}
|
||||
className="mt-6"
|
||||
>
|
||||
<Text className="text-primary underline">{t("seeRoadmap")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { router, Stack } from "expo-router";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { isKey } from "@turbostarter/i18n";
|
||||
import { capitalize } from "@turbostarter/shared/utils";
|
||||
|
||||
import { BaseHeader } from "~/modules/common/layout/header";
|
||||
|
||||
export default function GeneralLayout() {
|
||||
const { t, i18n } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<Stack
|
||||
initialRouteName="index"
|
||||
screenOptions={({ route }) => {
|
||||
const name = route.name === "index" ? "general" : route.name;
|
||||
|
||||
return {
|
||||
header: () => (
|
||||
<BaseHeader
|
||||
title={isKey(name, i18n, "common") ? t(name) : capitalize(name)}
|
||||
{...(router.canGoBack() && {
|
||||
onBack: () => router.back(),
|
||||
})}
|
||||
/>
|
||||
),
|
||||
animation: "fade",
|
||||
animationDuration: 200,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { router } from "expo-router";
|
||||
import { checkForUpdateAsync, useUpdates } from "expo-updates";
|
||||
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 { SettingsTile } from "~/modules/common/settings-tile";
|
||||
import { I18nSettings } from "~/modules/user/settings/i18n";
|
||||
import { ThemeSettings } from "~/modules/user/settings/theme";
|
||||
|
||||
const CheckForUpdates = () => {
|
||||
const { t } = useTranslation("common");
|
||||
const { isChecking } = useUpdates();
|
||||
|
||||
return (
|
||||
<SettingsTile
|
||||
icon={Icons.RefreshCw}
|
||||
onPress={checkForUpdateAsync}
|
||||
loading={isChecking}
|
||||
disabled={isChecking}
|
||||
>
|
||||
<Text className="mr-auto">{t("checkForUpdates")}</Text>
|
||||
</SettingsTile>
|
||||
);
|
||||
};
|
||||
|
||||
export default function General() {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<View className="bg-background flex-1 py-2">
|
||||
<CheckForUpdates />
|
||||
<SettingsTile
|
||||
icon={Icons.Bell}
|
||||
onPress={() =>
|
||||
router.navigate(
|
||||
pathsConfig.dashboard.user.settings.general.notifications,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text className="mr-auto">{t("notifications")}</Text>
|
||||
</SettingsTile>
|
||||
<ThemeSettings />
|
||||
<I18nSettings />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as Linking from "expo-linking";
|
||||
import { Pressable } from "react-native";
|
||||
import { View } from "react-native";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
export default function Notifications() {
|
||||
const { t } = useTranslation(["common", "marketing"]);
|
||||
|
||||
return (
|
||||
<View className="bg-background flex flex-1 items-center justify-center px-6">
|
||||
<View className="items-center gap-6 text-center">
|
||||
<Text className="font-sans-bold mt-4 text-3xl tracking-tight">
|
||||
{t("workInProgress.title")}
|
||||
</Text>
|
||||
<Text className="text-muted-foreground text-center text-pretty">
|
||||
{t("workInProgress.description", { feature: t("notifications") })}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
Linking.openURL("https://github.com/orgs/turbostarter/projects/1")
|
||||
}
|
||||
className="mt-6"
|
||||
>
|
||||
<Text className="text-primary underline">{t("seeRoadmap")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
113
apps/mobile/src/app/dashboard/(user)/settings/index.tsx
Normal file
113
apps/mobile/src/app/dashboard/(user)/settings/index.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import Constants from "expo-constants";
|
||||
import * as Linking from "expo-linking";
|
||||
import { router } from "expo-router";
|
||||
import * as StoreReview from "expo-store-review";
|
||||
import { Share, View } from "react-native";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
import { appConfig } from "~/config/app";
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth";
|
||||
import { SettingsTile } from "~/modules/common/settings-tile";
|
||||
import { SafeAreaView, ScrollView } from "~/modules/common/styled";
|
||||
import { AccountInfo } from "~/modules/user/settings/account/account-info";
|
||||
|
||||
import type { Session } from "@turbostarter/auth";
|
||||
|
||||
const getSections = (session?: Session | null) =>
|
||||
[
|
||||
[
|
||||
{
|
||||
title: "general",
|
||||
icon: Icons.Settings,
|
||||
onPress: () =>
|
||||
router.navigate(pathsConfig.dashboard.user.settings.general.index),
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
title: "account",
|
||||
icon: Icons.UserRound,
|
||||
onPress: () =>
|
||||
router.navigate(pathsConfig.dashboard.user.settings.account.index),
|
||||
visible: !!session?.session,
|
||||
},
|
||||
{
|
||||
title: "billing",
|
||||
icon: Icons.Wallet,
|
||||
onPress: () =>
|
||||
router.navigate(pathsConfig.dashboard.user.settings.billing),
|
||||
visible: !!session?.session,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
title: "rate",
|
||||
icon: Icons.ThumbsUp,
|
||||
onPress: async () => {
|
||||
const available = await StoreReview.hasAction();
|
||||
|
||||
if (available) {
|
||||
return await StoreReview.requestReview();
|
||||
}
|
||||
|
||||
return Share.share({
|
||||
title: Constants.expoConfig?.name,
|
||||
message: appConfig.url,
|
||||
});
|
||||
},
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
title: "share",
|
||||
icon: Icons.Share2,
|
||||
onPress: () =>
|
||||
Share.share({
|
||||
title: Constants.expoConfig?.name,
|
||||
message: appConfig.url,
|
||||
}),
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
title: "privacy",
|
||||
icon: Icons.Lock,
|
||||
onPress: () => Linking.openURL(`${appConfig.url}/legal/privacy-policy`),
|
||||
visible: true,
|
||||
},
|
||||
],
|
||||
] as const;
|
||||
|
||||
export default function Settings() {
|
||||
const session = authClient.useSession();
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
const sections = getSections(session.data);
|
||||
|
||||
return (
|
||||
<SafeAreaView className="bg-background flex-1">
|
||||
<ScrollView
|
||||
className="bg-background flex-1"
|
||||
contentContainerClassName="gap-8 py-6"
|
||||
bounces={false}
|
||||
>
|
||||
<AccountInfo />
|
||||
|
||||
<View className="gap-6">
|
||||
{sections.map((section, index) => (
|
||||
<View key={index}>
|
||||
{section
|
||||
.filter((item) => item.visible)
|
||||
.map((item) => (
|
||||
<SettingsTile {...item} key={item.title}>
|
||||
<Text>{t(item.title)}</Text>
|
||||
</SettingsTile>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
27
apps/mobile/src/app/dashboard/_layout.tsx
Normal file
27
apps/mobile/src/app/dashboard/_layout.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Redirect, Stack } from "expo-router";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth";
|
||||
import { Spinner } from "~/modules/common/spinner";
|
||||
|
||||
export default function DashboardLayout() {
|
||||
const session = authClient.useSession();
|
||||
|
||||
if (session.isPending) {
|
||||
return <Spinner modal={false} />;
|
||||
}
|
||||
|
||||
if (!session.data) {
|
||||
return <Redirect href={pathsConfig.index} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
animation: "fade",
|
||||
animationDuration: 200,
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
83
apps/mobile/src/app/dashboard/organization/_layout.tsx
Normal file
83
apps/mobile/src/app/dashboard/organization/_layout.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Tabs } from "expo-router";
|
||||
import { Easing } from "react-native";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
|
||||
import { UserHeader } from "~/modules/common/layout/header";
|
||||
import { TabBarLabel } from "~/modules/common/styled";
|
||||
|
||||
export default function OrganizationLayout() {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
initialRouteName="index"
|
||||
screenOptions={{
|
||||
tabBarStyle: {
|
||||
paddingTop: 6,
|
||||
},
|
||||
animation: "fade",
|
||||
transitionSpec: {
|
||||
animation: "timing",
|
||||
config: {
|
||||
duration: 200,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
header: () => <UserHeader />,
|
||||
title: t("home"),
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<Icons.House
|
||||
size={22}
|
||||
className={cn("text-muted-foreground", {
|
||||
"text-primary": focused,
|
||||
})}
|
||||
/>
|
||||
),
|
||||
tabBarLabel: TabBarLabel,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: t("settings"),
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<Icons.Settings
|
||||
size={22}
|
||||
className={cn("text-muted-foreground", {
|
||||
"text-primary": focused,
|
||||
})}
|
||||
/>
|
||||
),
|
||||
tabBarLabel: TabBarLabel,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tabs.Screen
|
||||
name="members"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: t("members"),
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<Icons.UsersRound
|
||||
size={22}
|
||||
className={cn("text-muted-foreground", {
|
||||
"text-primary": focused,
|
||||
})}
|
||||
/>
|
||||
),
|
||||
tabBarLabel: TabBarLabel,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
26
apps/mobile/src/app/dashboard/organization/index.tsx
Normal file
26
apps/mobile/src/app/dashboard/organization/index.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { View } from "react-native";
|
||||
|
||||
import { BuiltWith } from "@turbostarter/ui-mobile/built-with";
|
||||
|
||||
import { ScrollView } from "~/modules/common/styled";
|
||||
import { AreaChart } from "~/modules/home/charts/area";
|
||||
import { BarChart } from "~/modules/home/charts/bar";
|
||||
import { PieChart } from "~/modules/home/charts/pie";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<ScrollView
|
||||
className="bg-background"
|
||||
contentContainerClassName="gap-4 items-center bg-background px-6 py-2"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<BarChart />
|
||||
<PieChart />
|
||||
<AreaChart />
|
||||
|
||||
<View className="pt-4 pb-10">
|
||||
<BuiltWith />
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
65
apps/mobile/src/app/dashboard/organization/members.tsx
Normal file
65
apps/mobile/src/app/dashboard/organization/members.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@turbostarter/ui-mobile/tabs";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
import { authClient } from "~/lib/auth";
|
||||
import { SafeAreaView } from "~/modules/common/styled";
|
||||
import { InvitationsList } from "~/modules/organization/invitations/list/invitations-list";
|
||||
import { toMemberRole } from "~/modules/organization/lib/utils";
|
||||
import { InviteMemberBottomSheet } from "~/modules/organization/members/invite-member";
|
||||
import { MembersList } from "~/modules/organization/members/list/members-list";
|
||||
|
||||
export default function Members() {
|
||||
const { t } = useTranslation(["common", "organization"]);
|
||||
const [tab, setTab] = useState("members");
|
||||
|
||||
const activeMember = authClient.useActiveMember();
|
||||
|
||||
const hasInvitePermission = authClient.organization.checkRolePermission({
|
||||
permission: {
|
||||
invitation: ["create"],
|
||||
},
|
||||
role: toMemberRole(activeMember.data?.role),
|
||||
});
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
className="bg-background flex-1 gap-4 p-6"
|
||||
edges={["top", "left", "right"]}
|
||||
>
|
||||
<Tabs value={tab} onValueChange={setTab} className="flex-1">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="members" className="grow">
|
||||
<Text>{t("members.title")}</Text>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="invitations" className="grow">
|
||||
<Text>{t("invitations.title")}</Text>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="members" className="flex-1">
|
||||
<MembersList />
|
||||
</TabsContent>
|
||||
<TabsContent value="invitations" className="flex-1">
|
||||
<InvitationsList />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<InviteMemberBottomSheet>
|
||||
<Button disabled={!hasInvitePermission} size="lg">
|
||||
<Icons.UserRoundPlus size={20} className="text-primary-foreground" />
|
||||
<Text>{t("invite")}</Text>
|
||||
</Button>
|
||||
</InviteMemberBottomSheet>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
export default function SettingsLayout() {
|
||||
return (
|
||||
<Stack
|
||||
initialRouteName="index"
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
animation: "fade",
|
||||
animationDuration: 200,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
106
apps/mobile/src/app/dashboard/organization/settings/index.tsx
Normal file
106
apps/mobile/src/app/dashboard/organization/settings/index.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import Constants from "expo-constants";
|
||||
import * as Linking from "expo-linking";
|
||||
import { router } from "expo-router";
|
||||
import * as StoreReview from "expo-store-review";
|
||||
import { Share, View } from "react-native";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
import { appConfig } from "~/config/app";
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { SettingsTile } from "~/modules/common/settings-tile";
|
||||
import { SafeAreaView, ScrollView } from "~/modules/common/styled";
|
||||
import { organization } from "~/modules/organization/lib/api";
|
||||
import { OrganizationInfo } from "~/modules/organization/settings/organization-info";
|
||||
|
||||
const sections = [
|
||||
[
|
||||
{
|
||||
title: "organization",
|
||||
icon: Icons.Building,
|
||||
onPress: () =>
|
||||
router.navigate(
|
||||
pathsConfig.dashboard.organization.settings.organization.index,
|
||||
),
|
||||
visible: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
title: "account",
|
||||
icon: Icons.UserRound,
|
||||
onPress: () => {
|
||||
void organization.mutations.setActive.mutationFn({
|
||||
organizationId: null,
|
||||
});
|
||||
router.navigate(pathsConfig.dashboard.user.settings.index);
|
||||
},
|
||||
visible: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
title: "rate",
|
||||
icon: Icons.ThumbsUp,
|
||||
onPress: async () => {
|
||||
const available = await StoreReview.hasAction();
|
||||
|
||||
if (available) {
|
||||
return await StoreReview.requestReview();
|
||||
}
|
||||
|
||||
return Share.share({
|
||||
title: Constants.expoConfig?.name,
|
||||
message: appConfig.url,
|
||||
});
|
||||
},
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
title: "share",
|
||||
icon: Icons.Share2,
|
||||
onPress: () =>
|
||||
Share.share({
|
||||
title: Constants.expoConfig?.name,
|
||||
message: appConfig.url,
|
||||
}),
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
title: "privacy",
|
||||
icon: Icons.Lock,
|
||||
onPress: () => Linking.openURL(`${appConfig.url}/legal/privacy-policy`),
|
||||
visible: true,
|
||||
},
|
||||
],
|
||||
] as const;
|
||||
|
||||
export default function Settings() {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<SafeAreaView className="bg-background flex-1">
|
||||
<ScrollView
|
||||
className="bg-background flex-1"
|
||||
contentContainerClassName="gap-8 py-6"
|
||||
bounces={false}
|
||||
>
|
||||
<OrganizationInfo />
|
||||
|
||||
<View className="gap-6">
|
||||
{sections.map((section, index) => (
|
||||
<View key={index}>
|
||||
{section.map((item) => (
|
||||
<SettingsTile {...item} key={item.title}>
|
||||
<Text>{t(item.title)}</Text>
|
||||
</SettingsTile>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { router, Stack } from "expo-router";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { isKey } from "@turbostarter/i18n";
|
||||
import { capitalize } from "@turbostarter/shared/utils";
|
||||
|
||||
import { BaseHeader } from "~/modules/common/layout/header";
|
||||
|
||||
export default function GeneralLayout() {
|
||||
const { t, i18n } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<Stack
|
||||
initialRouteName="index"
|
||||
screenOptions={({ route }) => {
|
||||
const name = route.name === "index" ? "organization" : route.name;
|
||||
|
||||
return {
|
||||
header: () => (
|
||||
<BaseHeader
|
||||
title={isKey(name, i18n, "common") ? t(name) : capitalize(name)}
|
||||
{...(router.canGoBack() && {
|
||||
onBack: () => router.back(),
|
||||
})}
|
||||
/>
|
||||
),
|
||||
animation: "fade",
|
||||
animationDuration: 200,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { router } 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 { authClient } from "~/lib/auth";
|
||||
import { SettingsTile } from "~/modules/common/settings-tile";
|
||||
import { toMemberRole } from "~/modules/organization/lib/utils";
|
||||
import { DeleteOrganization } from "~/modules/organization/settings/delete-organization";
|
||||
import { LeaveOrganization } from "~/modules/organization/settings/leave-organization";
|
||||
|
||||
export default function Organization() {
|
||||
const { t } = useTranslation("common");
|
||||
const { data: activeMember } = authClient.useActiveMember();
|
||||
|
||||
const hasUpdatePermission = authClient.organization.checkRolePermission({
|
||||
permission: {
|
||||
organization: ["update"],
|
||||
},
|
||||
role: toMemberRole(activeMember?.role),
|
||||
});
|
||||
|
||||
return (
|
||||
<View className="bg-background flex-1 gap-6 py-2">
|
||||
{hasUpdatePermission && (
|
||||
<SettingsTile
|
||||
icon={Icons.IdCard}
|
||||
onPress={() =>
|
||||
router.navigate(
|
||||
pathsConfig.dashboard.organization.settings.organization.name,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text>{t("name")}</Text>
|
||||
</SettingsTile>
|
||||
)}
|
||||
<LeaveOrganization />
|
||||
<DeleteOrganization />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { View } from "react-native";
|
||||
|
||||
import { updateOrganizationSchema } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormInput,
|
||||
FormDescription,
|
||||
} from "@turbostarter/ui-mobile/form";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
import { Spin } from "@turbostarter/ui-mobile/spin";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
import { authClient } from "~/lib/auth";
|
||||
import { organization } from "~/modules/organization/lib/api";
|
||||
import { toMemberRole } from "~/modules/organization/lib/utils";
|
||||
|
||||
const EditName = () => {
|
||||
const { t } = useTranslation(["common", "organization"]);
|
||||
const { data: activeOrganization, refetch } =
|
||||
authClient.useActiveOrganization();
|
||||
const { data: activeMember } = authClient.useActiveMember();
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(
|
||||
updateOrganizationSchema.pick({ name: true }),
|
||||
),
|
||||
defaultValues: {
|
||||
name: activeOrganization?.name,
|
||||
},
|
||||
});
|
||||
|
||||
const hasUpdatePermission = authClient.organization.checkRolePermission({
|
||||
permission: {
|
||||
organization: ["update"],
|
||||
},
|
||||
role: toMemberRole(activeMember?.role),
|
||||
});
|
||||
|
||||
const updateOrganization = useMutation({
|
||||
...organization.mutations.update,
|
||||
onSuccess: async () => {
|
||||
await refetch();
|
||||
router.back();
|
||||
},
|
||||
});
|
||||
|
||||
if (!activeOrganization || !hasUpdatePermission) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="bg-background flex-1 p-6">
|
||||
<Form {...form}>
|
||||
<View className="flex-1 gap-6">
|
||||
<Text className="text-muted-foreground font-sans-medium">
|
||||
{t("name.edit.description")}
|
||||
</Text>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormInput
|
||||
{...field}
|
||||
label={t("common:name")}
|
||||
autoCapitalize="words"
|
||||
autoComplete="name"
|
||||
editable={!form.formState.isSubmitting}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
|
||||
<FormDescription>{t("name.edit.info")}</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onPress={form.handleSubmit((data) =>
|
||||
updateOrganization.mutateAsync({
|
||||
data,
|
||||
organizationId: activeOrganization.id,
|
||||
}),
|
||||
)}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Spin>
|
||||
<Icons.Loader2 className="text-primary-foreground" />
|
||||
</Spin>
|
||||
) : (
|
||||
<Text>{t("save")}</Text>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</Form>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditName;
|
||||
29
apps/mobile/src/app/index.tsx
Normal file
29
apps/mobile/src/app/index.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Redirect } from "expo-router";
|
||||
|
||||
import { useSetupSteps } from "~/app/(setup)/steps/_layout";
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth";
|
||||
import { Spinner } from "~/modules/common/spinner";
|
||||
|
||||
export default function Index() {
|
||||
const { data, isPending } = authClient.useSession();
|
||||
const { step } = useSetupSteps();
|
||||
|
||||
if (isPending) {
|
||||
return <Spinner modal={false} />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <Redirect href={pathsConfig.setup.welcome} />;
|
||||
}
|
||||
|
||||
if (step) {
|
||||
return <Redirect href={step} />;
|
||||
}
|
||||
|
||||
if (data.session.activeOrganizationId) {
|
||||
return <Redirect href={pathsConfig.dashboard.organization.index} />;
|
||||
}
|
||||
|
||||
return <Redirect href={pathsConfig.dashboard.user.index} />;
|
||||
}
|
||||
Reference in New Issue
Block a user