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} />;
|
||||
}
|
||||
15
apps/mobile/src/assets/styles/globals.css
Normal file
15
apps/mobile/src/assets/styles/globals.css
Normal file
@@ -0,0 +1,15 @@
|
||||
@import "@turbostarter/ui/globals.css";
|
||||
@import "uniwind";
|
||||
|
||||
@source "../../app";
|
||||
@source "../../modules";
|
||||
@source "../../../../../packages/ui/shared/";
|
||||
@source "../../../../../packages/ui/mobile/";
|
||||
|
||||
@theme inline {
|
||||
--font-sans: "Geist_400Regular";
|
||||
--font-sans-medium: "Geist_500Medium";
|
||||
--font-sans-semibold: "Geist_600SemiBold";
|
||||
--font-sans-bold: "Geist_700Bold";
|
||||
--font-mono: "GeistMono_400Regular";
|
||||
}
|
||||
10
apps/mobile/src/config/app.ts
Normal file
10
apps/mobile/src/config/app.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import env from "env.config";
|
||||
|
||||
export const appConfig = {
|
||||
locale: env.EXPO_PUBLIC_DEFAULT_LOCALE,
|
||||
url: env.EXPO_PUBLIC_SITE_URL,
|
||||
theme: {
|
||||
mode: env.EXPO_PUBLIC_THEME_MODE,
|
||||
color: env.EXPO_PUBLIC_THEME_COLOR,
|
||||
},
|
||||
} as const;
|
||||
21
apps/mobile/src/config/auth.ts
Normal file
21
apps/mobile/src/config/auth.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import env from "env.config";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
import { SocialProvider, authConfigSchema } from "@turbostarter/auth";
|
||||
|
||||
import type { AuthConfig } from "@turbostarter/auth";
|
||||
|
||||
export const authConfig = authConfigSchema.parse({
|
||||
providers: {
|
||||
password: env.EXPO_PUBLIC_AUTH_PASSWORD,
|
||||
magicLink: env.EXPO_PUBLIC_AUTH_MAGIC_LINK,
|
||||
anonymous: env.EXPO_PUBLIC_AUTH_ANONYMOUS,
|
||||
oAuth: [
|
||||
Platform.select({
|
||||
android: SocialProvider.GOOGLE,
|
||||
ios: SocialProvider.APPLE,
|
||||
}),
|
||||
SocialProvider.GITHUB,
|
||||
],
|
||||
},
|
||||
}) satisfies AuthConfig;
|
||||
62
apps/mobile/src/config/paths.ts
Normal file
62
apps/mobile/src/config/paths.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
const STEPS_PREFIX = "/steps";
|
||||
const DASHBOARD_PREFIX = "/dashboard";
|
||||
const ORGANIZATION_DASHBOARD_PREFIX = "/dashboard/organization";
|
||||
|
||||
const AUTH_PREFIX = `/auth`;
|
||||
|
||||
const pathsConfig = {
|
||||
index: "/",
|
||||
setup: {
|
||||
welcome: "/welcome",
|
||||
auth: {
|
||||
login: `${AUTH_PREFIX}/login`,
|
||||
register: `${AUTH_PREFIX}/register`,
|
||||
forgotPassword: `${AUTH_PREFIX}/password/forgot`,
|
||||
updatePassword: `${AUTH_PREFIX}/password/update`,
|
||||
error: `${AUTH_PREFIX}/error`,
|
||||
join: `${AUTH_PREFIX}/join`,
|
||||
},
|
||||
steps: {
|
||||
start: `${STEPS_PREFIX}/start`,
|
||||
required: `${STEPS_PREFIX}/required`,
|
||||
skip: `${STEPS_PREFIX}/skip`,
|
||||
final: `${STEPS_PREFIX}/final`,
|
||||
},
|
||||
},
|
||||
dashboard: {
|
||||
user: {
|
||||
index: DASHBOARD_PREFIX,
|
||||
ai: `${DASHBOARD_PREFIX}/ai`,
|
||||
settings: {
|
||||
index: `${DASHBOARD_PREFIX}/settings`,
|
||||
general: {
|
||||
index: `${DASHBOARD_PREFIX}/settings/general`,
|
||||
notifications: `${DASHBOARD_PREFIX}/settings/general/notifications`,
|
||||
},
|
||||
account: {
|
||||
index: `${DASHBOARD_PREFIX}/settings/account`,
|
||||
name: `${DASHBOARD_PREFIX}/settings/account/name`,
|
||||
email: `${DASHBOARD_PREFIX}/settings/account/email`,
|
||||
password: `${DASHBOARD_PREFIX}/settings/account/password`,
|
||||
accounts: `${DASHBOARD_PREFIX}/settings/account/accounts`,
|
||||
twoFactor: `${DASHBOARD_PREFIX}/settings/account/two-factor`,
|
||||
sessions: `${DASHBOARD_PREFIX}/settings/account/sessions`,
|
||||
},
|
||||
billing: `${DASHBOARD_PREFIX}/settings/billing`,
|
||||
},
|
||||
},
|
||||
organization: {
|
||||
index: ORGANIZATION_DASHBOARD_PREFIX,
|
||||
settings: {
|
||||
index: `${ORGANIZATION_DASHBOARD_PREFIX}/settings`,
|
||||
organization: {
|
||||
index: `${ORGANIZATION_DASHBOARD_PREFIX}/settings/organization`,
|
||||
name: `${ORGANIZATION_DASHBOARD_PREFIX}/settings/organization/name`,
|
||||
},
|
||||
},
|
||||
members: `${ORGANIZATION_DASHBOARD_PREFIX}/members`,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export { pathsConfig, AUTH_PREFIX, STEPS_PREFIX, DASHBOARD_PREFIX };
|
||||
21
apps/mobile/src/lib/api/index.tsx
Normal file
21
apps/mobile/src/lib/api/index.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { hc } from "hono/client";
|
||||
|
||||
import { config } from "@turbostarter/i18n";
|
||||
|
||||
import { authClient } from "~/lib/auth";
|
||||
import { useI18nConfig } from "~/lib/providers/i18n";
|
||||
|
||||
import { getBaseUrl } from "./utils";
|
||||
|
||||
import type { AppRouter } from "@turbostarter/api";
|
||||
|
||||
export const { api } = hc<AppRouter>(getBaseUrl(), {
|
||||
headers: () => ({
|
||||
cookie: `${config.cookie}=${useI18nConfig.getState().config.locale};${authClient.getCookie()}`,
|
||||
"x-client-platform": "mobile",
|
||||
}),
|
||||
init: {
|
||||
/* https://github.com/better-auth/better-auth/issues/2970 */
|
||||
credentials: "omit",
|
||||
},
|
||||
});
|
||||
23
apps/mobile/src/lib/api/utils.ts
Normal file
23
apps/mobile/src/lib/api/utils.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import env from "env.config";
|
||||
import Constants from "expo-constants";
|
||||
|
||||
import { logger } from "@turbostarter/shared/logger";
|
||||
|
||||
export const getBaseUrl = () => {
|
||||
/**
|
||||
* Gets the IP address of your host-machine. If it cannot automatically find it,
|
||||
* you'll have to manually set it. NOTE: Port 3000 should work for most but confirm
|
||||
* you don't have anything else running on it, or you'd have to change it.
|
||||
*
|
||||
* **NOTE**: This is only for development. In production, you'll want to set the
|
||||
* baseUrl to your production API URL.
|
||||
*/
|
||||
const debuggerHost = Constants.expoConfig?.hostUri;
|
||||
const localhost = debuggerHost?.split(":")[0];
|
||||
|
||||
if (!localhost) {
|
||||
logger.warn("Failed to get localhost. Pointing to production server...");
|
||||
return env.EXPO_PUBLIC_SITE_URL;
|
||||
}
|
||||
return `http://${localhost}:3000`;
|
||||
};
|
||||
26
apps/mobile/src/lib/auth/index.ts
Normal file
26
apps/mobile/src/lib/auth/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
|
||||
import { createClient } from "@turbostarter/auth/client/mobile";
|
||||
import { config } from "@turbostarter/i18n";
|
||||
|
||||
import { getBaseUrl } from "~/lib/api/utils";
|
||||
import { useI18nConfig } from "~/lib/providers/i18n";
|
||||
|
||||
export const authClient = createClient({
|
||||
baseURL: getBaseUrl(),
|
||||
disableDefaultFetchPlugins: true,
|
||||
mobile: {
|
||||
storage: SecureStore,
|
||||
cookiePrefix: "turbostarter",
|
||||
},
|
||||
lastLoginMethod: {
|
||||
storage: SecureStore,
|
||||
},
|
||||
fetchOptions: {
|
||||
headers: {
|
||||
Cookie: `${config.cookie}=${useI18nConfig.getState().config.locale}`,
|
||||
"x-client-platform": "mobile",
|
||||
},
|
||||
throw: true,
|
||||
},
|
||||
});
|
||||
28
apps/mobile/src/lib/polyfills.ts
Normal file
28
apps/mobile/src/lib/polyfills.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
import structuredClone from "@ungap/structured-clone";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
if (Platform.OS !== "web") {
|
||||
const setupPolyfills = async () => {
|
||||
const { polyfillGlobal } = await import(
|
||||
// @ts-expect-error - polyfillGlobal is not typed
|
||||
"react-native/Libraries/Utilities/PolyfillFunctions"
|
||||
);
|
||||
|
||||
const { TextEncoderStream, TextDecoderStream } = await import(
|
||||
"@stardazed/streams-text-encoding"
|
||||
);
|
||||
|
||||
if (!("structuredClone" in global)) {
|
||||
polyfillGlobal("structuredClone", () => structuredClone);
|
||||
}
|
||||
|
||||
polyfillGlobal("TextEncoderStream", () => TextEncoderStream);
|
||||
polyfillGlobal("TextDecoderStream", () => TextDecoderStream);
|
||||
};
|
||||
|
||||
void setupPolyfills();
|
||||
}
|
||||
|
||||
export {};
|
||||
28
apps/mobile/src/lib/providers/analytics.tsx
Normal file
28
apps/mobile/src/lib/providers/analytics.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { identify, Provider, reset } from "@turbostarter/analytics-mobile";
|
||||
|
||||
import { authClient } from "~/lib/auth";
|
||||
|
||||
export const AnalyticsProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const session = authClient.useSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (session.isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.data?.user) {
|
||||
const { id, email, name } = session.data.user;
|
||||
identify(id, { email, name });
|
||||
} else {
|
||||
reset();
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
return <Provider>{children}</Provider>;
|
||||
};
|
||||
43
apps/mobile/src/lib/providers/i18n.tsx
Normal file
43
apps/mobile/src/lib/providers/i18n.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { getLocales } from "expo-localization";
|
||||
import { memo } from "react";
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
|
||||
import { config, I18nProvider as I18nClientProvider } from "@turbostarter/i18n";
|
||||
|
||||
import { appConfig } from "~/config/app";
|
||||
|
||||
export const useI18nConfig = create<{
|
||||
config: {
|
||||
locale?: string;
|
||||
};
|
||||
setConfig: (config: { locale?: string }) => void;
|
||||
}>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
config: { locale: getLocales()[0]?.languageCode ?? config.defaultLocale },
|
||||
setConfig: (config) => set({ config }),
|
||||
}),
|
||||
{
|
||||
name: "i18n-config",
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
interface I18nProviderProps {
|
||||
readonly children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const I18nProvider = memo<I18nProviderProps>(({ children }) => {
|
||||
const config = useI18nConfig((state) => state.config);
|
||||
|
||||
return (
|
||||
<I18nClientProvider locale={config.locale} defaultLocale={appConfig.locale}>
|
||||
{children}
|
||||
</I18nClientProvider>
|
||||
);
|
||||
});
|
||||
|
||||
I18nProvider.displayName = "I18nProvider";
|
||||
25
apps/mobile/src/lib/providers/monitoring.tsx
Normal file
25
apps/mobile/src/lib/providers/monitoring.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { initialize, identify } from "@turbostarter/monitoring-mobile";
|
||||
|
||||
import { authClient } from "~/lib/auth";
|
||||
|
||||
initialize();
|
||||
|
||||
export const MonitoringProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const session = authClient.useSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (session.isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
identify(session.data?.user ?? null);
|
||||
}, [session]);
|
||||
|
||||
return children;
|
||||
};
|
||||
52
apps/mobile/src/lib/providers/providers.tsx
Normal file
52
apps/mobile/src/lib/providers/providers.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import { PortalHost } from "@rn-primitives/portal";
|
||||
import dayjs from "dayjs";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { memo } from "react";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
|
||||
import { I18nProvider } from "~/lib/providers/i18n";
|
||||
import { ThemeProvider } from "~/lib/providers/theme";
|
||||
import { QueryClientProvider } from "~/lib/query";
|
||||
import { Verification } from "~/modules/auth/verification";
|
||||
|
||||
import { AnalyticsProvider } from "./analytics";
|
||||
import { MonitoringProvider } from "./monitoring";
|
||||
|
||||
dayjs.extend(duration);
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface ProvidersProps {
|
||||
readonly children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Providers = memo<ProvidersProps>(({ children }) => {
|
||||
return (
|
||||
<GestureHandlerRootView>
|
||||
<QueryClientProvider>
|
||||
<I18nProvider>
|
||||
<SafeAreaProvider>
|
||||
<ThemeProvider>
|
||||
<KeyboardProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<MonitoringProvider>
|
||||
<AnalyticsProvider>
|
||||
{children}
|
||||
<Verification />
|
||||
<PortalHost />
|
||||
</AnalyticsProvider>
|
||||
</MonitoringProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</KeyboardProvider>
|
||||
</ThemeProvider>
|
||||
</SafeAreaProvider>
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
});
|
||||
|
||||
Providers.displayName = "Providers";
|
||||
44
apps/mobile/src/lib/providers/theme.tsx
Normal file
44
apps/mobile/src/lib/providers/theme.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
DarkTheme,
|
||||
DefaultTheme,
|
||||
ThemeProvider as NavigationThemeProvider,
|
||||
} from "@react-navigation/native";
|
||||
import * as NavigationBar from "expo-navigation-bar";
|
||||
import { memo } from "react";
|
||||
import { StatusBar, View } from "react-native";
|
||||
|
||||
import { ThemeMode } from "@turbostarter/ui";
|
||||
|
||||
import { useTheme } from "~/modules/common/hooks/use-theme";
|
||||
import { isAndroid } from "~/utils/device";
|
||||
|
||||
interface ThemeProviderProps {
|
||||
readonly children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ThemeProvider = memo<ThemeProviderProps>(({ children }) => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
if (isAndroid) {
|
||||
void NavigationBar.setButtonStyleAsync(
|
||||
resolvedTheme === ThemeMode.DARK ? ThemeMode.LIGHT : ThemeMode.DARK,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavigationThemeProvider
|
||||
value={resolvedTheme === ThemeMode.DARK ? DarkTheme : DefaultTheme}
|
||||
>
|
||||
<View className="bg-background flex-1">{children}</View>
|
||||
<StatusBar
|
||||
barStyle={
|
||||
resolvedTheme === ThemeMode.DARK ? "light-content" : "dark-content"
|
||||
}
|
||||
translucent
|
||||
backgroundColor="transparent"
|
||||
/>
|
||||
</NavigationThemeProvider>
|
||||
);
|
||||
});
|
||||
|
||||
ThemeProvider.displayName = "ThemeProvider";
|
||||
49
apps/mobile/src/lib/query.tsx
Normal file
49
apps/mobile/src/lib/query.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useReactQueryDevTools } from "@dev-plugins/react-query";
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider as TanstackQueryClientProvider,
|
||||
} from "@tanstack/react-query";
|
||||
import { onlineManager } from "@tanstack/react-query";
|
||||
import * as Network from "expo-network";
|
||||
import { useState } from "react";
|
||||
import { Alert } from "react-native";
|
||||
|
||||
import { logger } from "@turbostarter/shared/logger";
|
||||
|
||||
import { useRefetchOnAppFocus } from "~/modules/common/hooks/use-refetch-on-app-focus";
|
||||
|
||||
onlineManager.setEventListener((setOnline) => {
|
||||
const eventSubscription = Network.addNetworkStateListener((state) => {
|
||||
setOnline(!!state.isConnected);
|
||||
});
|
||||
return () => eventSubscription.remove();
|
||||
});
|
||||
|
||||
export function QueryClientProvider(props: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
mutations: {
|
||||
onError: (error: Error | { error: Error }) => {
|
||||
if ("error" in error) {
|
||||
error = error.error;
|
||||
}
|
||||
|
||||
logger.error(error);
|
||||
Alert.alert(error.message);
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
useRefetchOnAppFocus();
|
||||
useReactQueryDevTools(queryClient);
|
||||
|
||||
return (
|
||||
<TanstackQueryClientProvider client={queryClient}>
|
||||
{props.children}
|
||||
</TanstackQueryClientProvider>
|
||||
);
|
||||
}
|
||||
66
apps/mobile/src/modules/auth/form/anonymous.tsx
Normal file
66
apps/mobile/src/modules/auth/form/anonymous.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
|
||||
import { AuthProvider } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
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 { pathsConfig } from "~/config/paths";
|
||||
|
||||
import { auth } from "../lib/api";
|
||||
|
||||
import { useAuthFormStore } from "./store";
|
||||
|
||||
import type { Route } from "expo-router";
|
||||
|
||||
interface AnonymousLoginProps {
|
||||
readonly redirectTo?: Route;
|
||||
}
|
||||
|
||||
export const AnonymousLogin = ({
|
||||
redirectTo = pathsConfig.index,
|
||||
}: AnonymousLoginProps) => {
|
||||
const { t } = useTranslation(["auth", "common"]);
|
||||
const { provider, setProvider, isSubmitting, setIsSubmitting } =
|
||||
useAuthFormStore();
|
||||
|
||||
const signIn = useMutation({
|
||||
...auth.mutations.signIn.anonymous,
|
||||
onMutate: () => {
|
||||
setProvider(AuthProvider.ANONYMOUS);
|
||||
setIsSubmitting(true);
|
||||
},
|
||||
onSuccess: () => {
|
||||
router.navigate(redirectTo);
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-row gap-2"
|
||||
size="lg"
|
||||
disabled={isSubmitting}
|
||||
onPress={() => signIn.mutate(undefined)}
|
||||
>
|
||||
{isSubmitting && provider === AuthProvider.ANONYMOUS ? (
|
||||
<Spin>
|
||||
<Icons.Loader2 className="text-foreground size-5" />
|
||||
</Spin>
|
||||
) : (
|
||||
<>
|
||||
<Icons.UserRound className="text-foreground" size={16} />
|
||||
<Text>{t("login.anonymous.cta")}</Text>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
5
apps/mobile/src/modules/auth/form/login/constants.ts
Normal file
5
apps/mobile/src/modules/auth/form/login/constants.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AuthProvider } from "@turbostarter/auth";
|
||||
|
||||
export const LOGIN_OPTIONS = [AuthProvider.PASSWORD, AuthProvider.MAGIC_LINK];
|
||||
|
||||
export type LoginOption = (typeof LOGIN_OPTIONS)[number];
|
||||
138
apps/mobile/src/modules/auth/form/login/form.tsx
Normal file
138
apps/mobile/src/modules/auth/form/login/form.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { Suspense, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
import { AuthProvider } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Badge } from "@turbostarter/ui-mobile/badge";
|
||||
import {
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TabsContent,
|
||||
} from "@turbostarter/ui-mobile/tabs";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth";
|
||||
import { Link } from "~/modules/common/styled";
|
||||
|
||||
import { MagicLinkLoginForm } from "./magic-link";
|
||||
import { PasswordLoginForm } from "./password";
|
||||
|
||||
import type { LoginOption } from "./constants";
|
||||
import type { Route } from "expo-router";
|
||||
|
||||
const LOGIN_OPTIONS_DETAILS = {
|
||||
[AuthProvider.PASSWORD]: {
|
||||
lastUsedMethodId: "email",
|
||||
component: PasswordLoginForm,
|
||||
label: "password",
|
||||
},
|
||||
[AuthProvider.MAGIC_LINK]: {
|
||||
lastUsedMethodId: AuthProvider.MAGIC_LINK,
|
||||
component: MagicLinkLoginForm,
|
||||
label: "login.magicLink.label",
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface LoginFormProps {
|
||||
readonly options: LoginOption[];
|
||||
readonly redirectTo?: Route;
|
||||
readonly email?: string;
|
||||
readonly onTwoFactorRedirect?: () => void;
|
||||
}
|
||||
|
||||
export const LoginForm = ({
|
||||
options,
|
||||
redirectTo,
|
||||
email,
|
||||
onTwoFactorRedirect,
|
||||
}: LoginFormProps) => {
|
||||
const { t } = useTranslation(["auth", "common"]);
|
||||
const [mainOption] = options;
|
||||
|
||||
const [value, setValue] = useState(mainOption);
|
||||
|
||||
if (!options.length || !value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (options.length === 1) {
|
||||
const Component = LOGIN_OPTIONS_DETAILS[value].component;
|
||||
return (
|
||||
<Component
|
||||
onTwoFactorRedirect={onTwoFactorRedirect}
|
||||
redirectTo={redirectTo}
|
||||
email={email}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={value}
|
||||
onValueChange={(val) => setValue(val as LoginOption)}
|
||||
className="flex w-full flex-col items-center justify-center gap-6"
|
||||
>
|
||||
<TabsList className="w-full flex-row">
|
||||
{options.map((provider) => (
|
||||
<TabsTrigger
|
||||
key={provider}
|
||||
value={provider}
|
||||
className="relative grow"
|
||||
>
|
||||
<Text>{t(LOGIN_OPTIONS_DETAILS[provider].label)}</Text>
|
||||
|
||||
{authClient.isLastUsedLoginMethod(
|
||||
LOGIN_OPTIONS_DETAILS[provider].lastUsedMethodId,
|
||||
) && (
|
||||
<Badge className="absolute -top-3 -right-4 shadow-sm">
|
||||
<Text>{t("lastUsed")}</Text>
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{options.map((provider) => {
|
||||
const Component = LOGIN_OPTIONS_DETAILS[provider].component;
|
||||
return (
|
||||
<TabsContent key={provider} value={provider} className="w-full">
|
||||
<Suspense>
|
||||
<Component
|
||||
onTwoFactorRedirect={onTwoFactorRedirect}
|
||||
redirectTo={redirectTo}
|
||||
email={email}
|
||||
/>
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
export const LoginCta = () => {
|
||||
const { t } = useTranslation("auth");
|
||||
const localParams = useLocalSearchParams();
|
||||
const searchParams = new URLSearchParams(
|
||||
localParams as Record<string, string>,
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="items-center justify-center pt-2">
|
||||
<View className="flex-row">
|
||||
<Text className="text-muted-foreground text-sm">
|
||||
{t("register.alreadyHaveAccount")}
|
||||
</Text>
|
||||
<Link
|
||||
href={`${pathsConfig.setup.auth.login}?${searchParams.toString()}`}
|
||||
className="text-muted-foreground hover:text-primary pl-2 font-sans text-sm underline"
|
||||
>
|
||||
{t("login.cta")}
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
106
apps/mobile/src/modules/auth/form/login/magic-link.tsx
Normal file
106
apps/mobile/src/modules/auth/form/login/magic-link.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { memo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Alert, View } from "react-native";
|
||||
|
||||
import { AuthProvider } from "@turbostarter/auth";
|
||||
import { magicLinkLoginSchema } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormInput,
|
||||
FormItem,
|
||||
} 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 { useAuthFormStore } from "~/modules/auth/form/store";
|
||||
|
||||
import { auth } from "../../lib/api";
|
||||
|
||||
import type { Route } from "expo-router";
|
||||
|
||||
interface MagicLinkLoginFormProps {
|
||||
readonly redirectTo?: Route;
|
||||
readonly email?: string;
|
||||
}
|
||||
|
||||
export const MagicLinkLoginForm = memo<MagicLinkLoginFormProps>(
|
||||
({ redirectTo = pathsConfig.index, email }) => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
const { provider, setProvider, isSubmitting, setIsSubmitting } =
|
||||
useAuthFormStore();
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(magicLinkLoginSchema),
|
||||
defaultValues: {
|
||||
email: email ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const signIn = useMutation({
|
||||
...auth.mutations.signIn.magicLink,
|
||||
onMutate: () => {
|
||||
setProvider(AuthProvider.MAGIC_LINK);
|
||||
setIsSubmitting(true);
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
onSuccess: () => {
|
||||
Alert.alert(
|
||||
t("login.magicLink.success.title"),
|
||||
t("login.magicLink.success.description"),
|
||||
);
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<View className="gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormInput
|
||||
label={t("email")}
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
editable={!isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onPress={form.handleSubmit((data) =>
|
||||
signIn.mutateAsync({
|
||||
...data,
|
||||
callbackURL: redirectTo,
|
||||
}),
|
||||
)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting && provider === AuthProvider.MAGIC_LINK ? (
|
||||
<Spin>
|
||||
<Icons.Loader2 className="text-primary-foreground size-5" />
|
||||
</Spin>
|
||||
) : (
|
||||
<Text>{t("login.magicLink.cta")}</Text>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</Form>
|
||||
);
|
||||
},
|
||||
);
|
||||
149
apps/mobile/src/modules/auth/form/login/password.tsx
Normal file
149
apps/mobile/src/modules/auth/form/login/password.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { memo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { View } from "react-native";
|
||||
|
||||
import { AuthProvider } from "@turbostarter/auth";
|
||||
import { passwordLoginSchema } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import {
|
||||
Form,
|
||||
FormCheckbox,
|
||||
FormField,
|
||||
FormInput,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} 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 { useAuthFormStore } from "~/modules/auth/form/store";
|
||||
import { Link } from "~/modules/common/styled";
|
||||
|
||||
import { auth } from "../../lib/api";
|
||||
|
||||
import type { Route } from "expo-router";
|
||||
|
||||
interface PasswordLoginFormProps {
|
||||
readonly redirectTo?: Route;
|
||||
readonly email?: string;
|
||||
readonly onTwoFactorRedirect?: () => void;
|
||||
}
|
||||
|
||||
export const PasswordLoginForm = memo<PasswordLoginFormProps>(
|
||||
({ redirectTo = pathsConfig.index, email, onTwoFactorRedirect }) => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
|
||||
const { provider, setProvider, isSubmitting, setIsSubmitting } =
|
||||
useAuthFormStore();
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(passwordLoginSchema),
|
||||
defaultValues: {
|
||||
rememberMe: true,
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
const signIn = useMutation({
|
||||
...auth.mutations.signIn.email,
|
||||
onMutate: () => {
|
||||
setProvider(AuthProvider.PASSWORD);
|
||||
setIsSubmitting(true);
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
onSuccess: (ctx) => {
|
||||
if ("twoFactorRedirect" in ctx) {
|
||||
return onTwoFactorRedirect?.();
|
||||
}
|
||||
|
||||
router.navigate(redirectTo);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<View className="gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormInput
|
||||
label={t("email")}
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
editable={!isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<FormLabel nativeID="password">{t("password")}</FormLabel>
|
||||
|
||||
<Link
|
||||
href={pathsConfig.setup.auth.forgotPassword}
|
||||
className="text-muted-foreground self-end font-sans text-sm underline underline-offset-4"
|
||||
>
|
||||
{t("account.password.forgot.label")}
|
||||
</Link>
|
||||
</View>
|
||||
<FormInput
|
||||
secureTextEntry
|
||||
autoComplete="password"
|
||||
editable={!isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="rememberMe"
|
||||
render={({ field }) => (
|
||||
<FormCheckbox
|
||||
name="rememberMe"
|
||||
label={t("rememberMe")}
|
||||
disabled={isSubmitting}
|
||||
value={!!field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onPress={form.handleSubmit((data) => signIn.mutate(data))}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting && provider === AuthProvider.PASSWORD ? (
|
||||
<Spin>
|
||||
<Icons.Loader2 className="text-primary-foreground" />
|
||||
</Spin>
|
||||
) : (
|
||||
<Text>{t("login.cta")}</Text>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</Form>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
PasswordLoginForm.displayName = "PasswordLoginForm";
|
||||
96
apps/mobile/src/modules/auth/form/password/forgot.tsx
Normal file
96
apps/mobile/src/modules/auth/form/password/forgot.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import * as Linking from "expo-linking";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Alert, View } from "react-native";
|
||||
|
||||
import { forgotPasswordSchema } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormInput,
|
||||
FormItem,
|
||||
} 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 { Link } from "~/modules/common/styled";
|
||||
|
||||
import { auth } from "../../lib/api";
|
||||
|
||||
export const ForgotPasswordForm = () => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(forgotPasswordSchema),
|
||||
});
|
||||
|
||||
const forgetPassword = useMutation({
|
||||
...auth.mutations.password.forget,
|
||||
onSuccess: () => {
|
||||
Alert.alert(
|
||||
t("account.password.forgot.success.title"),
|
||||
t("account.password.forgot.success.description"),
|
||||
);
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<View className="gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormInput
|
||||
label={t("email")}
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
editable={!form.formState.isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onPress={form.handleSubmit((data) =>
|
||||
forgetPassword.mutateAsync({
|
||||
...data,
|
||||
redirectTo: Linking.createURL(
|
||||
pathsConfig.setup.auth.updatePassword,
|
||||
),
|
||||
}),
|
||||
)}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Spin>
|
||||
<Icons.Loader2 className="text-primary-foreground size-5" />
|
||||
</Spin>
|
||||
) : (
|
||||
<Text>{t("account.password.forgot.cta")}</Text>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<View className="items-center justify-center pt-2">
|
||||
<Link
|
||||
replace
|
||||
href={pathsConfig.setup.auth.login}
|
||||
className="text-muted-foreground active:text-primary pl-2 font-sans text-sm underline"
|
||||
>
|
||||
{t("account.password.forgot.back")}
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
91
apps/mobile/src/modules/auth/form/password/update.tsx
Normal file
91
apps/mobile/src/modules/auth/form/password/update.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { memo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { View } from "react-native";
|
||||
|
||||
import { updatePasswordSchema } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormInput,
|
||||
FormItem,
|
||||
} 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 { auth } from "../../lib/api";
|
||||
|
||||
interface UpdatePasswordFormProps {
|
||||
readonly token?: string;
|
||||
}
|
||||
|
||||
export const UpdatePasswordForm = memo<UpdatePasswordFormProps>(({ token }) => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(updatePasswordSchema),
|
||||
});
|
||||
|
||||
const resetPassword = useMutation({
|
||||
...auth.mutations.password.reset,
|
||||
onSuccess: () => {
|
||||
router.setParams({
|
||||
token: undefined,
|
||||
});
|
||||
router.replace(pathsConfig.setup.auth.login);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<View className="gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormInput
|
||||
label={t("password")}
|
||||
secureTextEntry
|
||||
autoComplete="new-password"
|
||||
editable={!form.formState.isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onPress={form.handleSubmit((data) =>
|
||||
resetPassword.mutateAsync({
|
||||
newPassword: data.password,
|
||||
token,
|
||||
}),
|
||||
)}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Spin>
|
||||
<Icons.Loader2 className="text-primary-foreground" />
|
||||
</Spin>
|
||||
) : (
|
||||
<Text>{t("account.password.update.cta")}</Text>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</Form>
|
||||
);
|
||||
});
|
||||
|
||||
UpdatePasswordForm.displayName = "UpdatePasswordForm";
|
||||
157
apps/mobile/src/modules/auth/form/register-form.tsx
Normal file
157
apps/mobile/src/modules/auth/form/register-form.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Alert, View } from "react-native";
|
||||
|
||||
import { AuthProvider, generateName, registerSchema } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormInput,
|
||||
} 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 { Link } from "~/modules/common/styled";
|
||||
|
||||
import { auth } from "../lib/api";
|
||||
|
||||
import { useAuthFormStore } from "./store";
|
||||
|
||||
import type { Route } from "expo-router";
|
||||
|
||||
interface RegisterFormProps {
|
||||
readonly redirectTo?: Route;
|
||||
readonly email?: string;
|
||||
}
|
||||
|
||||
export const RegisterForm = ({
|
||||
redirectTo = pathsConfig.index,
|
||||
email,
|
||||
}: RegisterFormProps) => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
const { provider, setProvider, isSubmitting, setIsSubmitting } =
|
||||
useAuthFormStore();
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(registerSchema),
|
||||
defaultValues: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
const signUp = useMutation({
|
||||
...auth.mutations.signUp.email,
|
||||
onMutate: () => {
|
||||
setProvider(AuthProvider.PASSWORD);
|
||||
setIsSubmitting(true);
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
onSuccess: () => {
|
||||
Alert.alert(
|
||||
t("register.success.title"),
|
||||
t("register.success.description"),
|
||||
[
|
||||
{
|
||||
text: t("continue"),
|
||||
onPress: () => {
|
||||
router.navigate(pathsConfig.setup.auth.login);
|
||||
form.reset();
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<View className="gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormInput
|
||||
label={t("email")}
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
editable={!isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormInput
|
||||
label={t("password")}
|
||||
secureTextEntry
|
||||
autoComplete="password"
|
||||
editable={!isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onPress={form.handleSubmit((data) =>
|
||||
signUp.mutateAsync({
|
||||
...data,
|
||||
name: generateName(data.email),
|
||||
callbackURL: redirectTo,
|
||||
}),
|
||||
)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting && provider === AuthProvider.PASSWORD ? (
|
||||
<Spin>
|
||||
<Icons.Loader2 className="text-primary-foreground size-5" />
|
||||
</Spin>
|
||||
) : (
|
||||
<Text>{t("register.cta")}</Text>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export const RegisterCta = () => {
|
||||
const { t } = useTranslation("auth");
|
||||
const localParams = useLocalSearchParams();
|
||||
const searchParams = new URLSearchParams(
|
||||
localParams as Record<string, string>,
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="items-center justify-center pt-2">
|
||||
<View className="flex-row">
|
||||
<Text className="text-muted-foreground text-sm">
|
||||
{t("login.noAccount")}
|
||||
</Text>
|
||||
<Link
|
||||
href={`${pathsConfig.setup.auth.register}?${searchParams.toString()}`}
|
||||
className="text-muted-foreground active:text-primary pl-2 font-sans text-sm underline"
|
||||
>
|
||||
{t("register.cta")}
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
206
apps/mobile/src/modules/auth/form/social-providers.tsx
Normal file
206
apps/mobile/src/modules/auth/form/social-providers.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
GoogleSignin,
|
||||
isCancelledResponse,
|
||||
isSuccessResponse,
|
||||
} from "@react-native-google-signin/google-signin";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import env from "env.config";
|
||||
import * as AppleAuthentication from "expo-apple-authentication";
|
||||
import { router } from "expo-router";
|
||||
import { memo } from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
import { SocialProvider as SocialProviderType } from "@turbostarter/auth";
|
||||
import { Trans, useTranslation } from "@turbostarter/i18n";
|
||||
import { Badge } from "@turbostarter/ui-mobile/badge";
|
||||
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 { useAuthFormStore } from "~/modules/auth/form/store";
|
||||
import { isAndroid, isIOS } from "~/utils/device";
|
||||
|
||||
import { auth } from "../lib/api";
|
||||
|
||||
import type { AuthProvider } from "@turbostarter/auth";
|
||||
import type { Icon } from "@turbostarter/ui-mobile/icons";
|
||||
import type { Route } from "expo-router";
|
||||
|
||||
interface SocialProvidersProps {
|
||||
readonly providers: SocialProviderType[];
|
||||
readonly redirectTo?: Route;
|
||||
}
|
||||
|
||||
export const SocialIcons: Record<SocialProviderType, Icon> = {
|
||||
[SocialProviderType.GITHUB]: Icons.Github,
|
||||
[SocialProviderType.GOOGLE]: Icons.Google,
|
||||
[SocialProviderType.APPLE]: Icons.Apple,
|
||||
};
|
||||
|
||||
if (
|
||||
authConfig.providers.oAuth.includes(SocialProviderType.GOOGLE) &&
|
||||
isAndroid
|
||||
) {
|
||||
GoogleSignin.configure({
|
||||
webClientId: env.EXPO_PUBLIC_GOOGLE_CLIENT_ID,
|
||||
});
|
||||
}
|
||||
|
||||
const SocialProvider = ({
|
||||
provider,
|
||||
onClick,
|
||||
actualProvider,
|
||||
isSubmitting,
|
||||
}: {
|
||||
provider: SocialProviderType;
|
||||
isSubmitting: boolean;
|
||||
onClick: () => void;
|
||||
actualProvider: AuthProvider;
|
||||
}) => {
|
||||
const { t } = useTranslation("common");
|
||||
const Icon = SocialIcons[provider];
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={provider}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="relative w-full flex-row justify-center gap-2.5"
|
||||
onPress={onClick}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting && actualProvider === provider ? (
|
||||
<Spin>
|
||||
<Icons.Loader2 className="text-foreground size-5" />
|
||||
</Spin>
|
||||
) : (
|
||||
<>
|
||||
<View className="size-5">
|
||||
<Icon className="text-foreground" />
|
||||
</View>
|
||||
<Text>
|
||||
<Trans
|
||||
ns="auth"
|
||||
i18nKey="login.social"
|
||||
values={{ provider }}
|
||||
components={{
|
||||
capitalize: <Text className="capitalize" />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{authClient.isLastUsedLoginMethod(provider) && (
|
||||
<Badge className="absolute -top-2 -right-3 shadow-sm">
|
||||
<Text>{t("lastUsed")}</Text>
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const SocialProviders = memo<SocialProvidersProps>(
|
||||
({ providers, redirectTo = pathsConfig.index }) => {
|
||||
const {
|
||||
provider: actualProvider,
|
||||
setProvider,
|
||||
isSubmitting,
|
||||
setIsSubmitting,
|
||||
} = useAuthFormStore();
|
||||
|
||||
const signIn = useMutation({
|
||||
...auth.mutations.signIn.social,
|
||||
onMutate: ({ provider }) => {
|
||||
setProvider(provider as SocialProviderType);
|
||||
setIsSubmitting(true);
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
const session = await authClient.getSession({
|
||||
fetchOptions: { throw: true },
|
||||
});
|
||||
|
||||
if (session?.session) {
|
||||
router.navigate(redirectTo);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const getParams = async (provider: SocialProviderType) => {
|
||||
const shared = {
|
||||
provider,
|
||||
callbackURL: redirectTo,
|
||||
errorCallbackURL: pathsConfig.setup.auth.error,
|
||||
};
|
||||
|
||||
if (provider === SocialProviderType.APPLE && isIOS) {
|
||||
const credential = await AppleAuthentication.signInAsync({
|
||||
requestedScopes: [
|
||||
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
|
||||
AppleAuthentication.AppleAuthenticationScope.EMAIL,
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
...shared,
|
||||
...(credential.identityToken
|
||||
? { idToken: { token: credential.identityToken } }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (provider === SocialProviderType.GOOGLE && isAndroid) {
|
||||
await GoogleSignin.hasPlayServices();
|
||||
const response = await GoogleSignin.signIn();
|
||||
|
||||
if (isCancelledResponse(response)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokens = await GoogleSignin.getTokens();
|
||||
|
||||
return {
|
||||
...shared,
|
||||
...(isSuccessResponse(response)
|
||||
? {
|
||||
idToken: {
|
||||
token: tokens.idToken,
|
||||
accessToken: tokens.accessToken,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
return shared;
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="flex w-full items-stretch justify-center gap-2">
|
||||
{Object.values(providers).map((provider) => (
|
||||
<SocialProvider
|
||||
key={provider}
|
||||
provider={provider}
|
||||
onClick={async () => {
|
||||
const params = await getParams(provider);
|
||||
if (params) {
|
||||
await signIn.mutateAsync(params);
|
||||
}
|
||||
}}
|
||||
actualProvider={actualProvider}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SocialProviders.displayName = "SocialProviders";
|
||||
15
apps/mobile/src/modules/auth/form/store/index.ts
Normal file
15
apps/mobile/src/modules/auth/form/store/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
import { AuthProvider } from "@turbostarter/auth";
|
||||
|
||||
export const useAuthFormStore = create<{
|
||||
provider: AuthProvider;
|
||||
setProvider: (provider: AuthProvider) => void;
|
||||
isSubmitting: boolean;
|
||||
setIsSubmitting: (isSubmitting: boolean) => void;
|
||||
}>((set) => ({
|
||||
provider: AuthProvider.PASSWORD,
|
||||
setProvider: (provider) => set({ provider }),
|
||||
isSubmitting: false,
|
||||
setIsSubmitting: (isSubmitting) => set({ isSubmitting }),
|
||||
}));
|
||||
114
apps/mobile/src/modules/auth/form/two-factor/backup-code.tsx
Normal file
114
apps/mobile/src/modules/auth/form/two-factor/backup-code.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { memo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { View } from "react-native";
|
||||
|
||||
import { backupCodeVerificationSchema, SecondFactor } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import {
|
||||
Form,
|
||||
FormCheckbox,
|
||||
FormField,
|
||||
FormInput,
|
||||
FormItem,
|
||||
} 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 { auth } from "../../lib/api";
|
||||
|
||||
import type { CtaProps, FormProps } from ".";
|
||||
|
||||
const BackupCodeForm = memo<FormProps>(({ redirectTo = pathsConfig.index }) => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(backupCodeVerificationSchema),
|
||||
defaultValues: {
|
||||
code: "",
|
||||
trustDevice: false,
|
||||
},
|
||||
});
|
||||
|
||||
const verifyBackupCode = useMutation({
|
||||
...auth.mutations.twoFactor.backupCodes.verify,
|
||||
onSuccess: () => {
|
||||
router.replace(redirectTo);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<View className="flex flex-col gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormInput
|
||||
autoFocus
|
||||
placeholder={t("login.twoFactor.backupCode.placeholder")}
|
||||
autoCapitalize="none"
|
||||
autoComplete="one-time-code"
|
||||
editable={!form.formState.isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="trustDevice"
|
||||
render={({ field }) => (
|
||||
<FormCheckbox
|
||||
name="trustDevice"
|
||||
label={t("login.twoFactor.trustDevice")}
|
||||
value={field.value ?? false}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={form.formState.isSubmitting}
|
||||
onPress={form.handleSubmit((data) =>
|
||||
verifyBackupCode.mutateAsync(data),
|
||||
)}
|
||||
>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Spin>
|
||||
<Icons.Loader2 className="text-primary-foreground" />
|
||||
</Spin>
|
||||
) : (
|
||||
<Text>{t("verify")}</Text>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</Form>
|
||||
);
|
||||
});
|
||||
|
||||
const BackupCodeCta = memo<CtaProps>(({ onFactorChange }) => {
|
||||
const { t } = useTranslation("auth");
|
||||
return (
|
||||
<View className="flex items-center justify-center pt-2">
|
||||
<Text
|
||||
onPress={() => onFactorChange(SecondFactor.BACKUP_CODE)}
|
||||
className="text-muted-foreground font-sans-medium cursor-pointer pl-2 text-sm underline underline-offset-4"
|
||||
>
|
||||
{t("login.twoFactor.backupCode.cta")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
export { BackupCodeForm, BackupCodeCta };
|
||||
30
apps/mobile/src/modules/auth/form/two-factor/index.tsx
Normal file
30
apps/mobile/src/modules/auth/form/two-factor/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { SecondFactor } from "@turbostarter/auth";
|
||||
|
||||
import { BackupCodeForm, BackupCodeCta } from "./backup-code";
|
||||
import { TotpForm, TotpCta } from "./totp";
|
||||
|
||||
import type { Route } from "expo-router";
|
||||
|
||||
export interface FormProps {
|
||||
readonly redirectTo?: Route;
|
||||
}
|
||||
|
||||
export interface CtaProps {
|
||||
readonly onFactorChange: (factor: SecondFactor) => void;
|
||||
}
|
||||
|
||||
const TwoFactorForm: Record<
|
||||
SecondFactor,
|
||||
(props: FormProps) => React.ReactNode
|
||||
> = {
|
||||
[SecondFactor.TOTP]: TotpForm,
|
||||
[SecondFactor.BACKUP_CODE]: BackupCodeForm,
|
||||
};
|
||||
|
||||
const TwoFactorCta: Record<SecondFactor, (props: CtaProps) => React.ReactNode> =
|
||||
{
|
||||
[SecondFactor.TOTP]: TotpCta,
|
||||
[SecondFactor.BACKUP_CODE]: BackupCodeCta,
|
||||
};
|
||||
|
||||
export { TwoFactorForm, TwoFactorCta };
|
||||
132
apps/mobile/src/modules/auth/form/two-factor/totp.tsx
Normal file
132
apps/mobile/src/modules/auth/form/two-factor/totp.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { memo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { View } from "react-native";
|
||||
|
||||
import { otpVerificationSchema, SecondFactor } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import {
|
||||
Form,
|
||||
FormCheckbox,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@turbostarter/ui-mobile/form";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from "@turbostarter/ui-mobile/input-otp";
|
||||
import { Spin } from "@turbostarter/ui-mobile/spin";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
|
||||
import { auth } from "../../lib/api";
|
||||
|
||||
import type { CtaProps, FormProps } from ".";
|
||||
|
||||
const TotpForm = memo<FormProps>(({ redirectTo = pathsConfig.index }) => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(otpVerificationSchema),
|
||||
defaultValues: {
|
||||
code: "",
|
||||
trustDevice: false,
|
||||
},
|
||||
});
|
||||
|
||||
const verifyTotp = useMutation({
|
||||
...auth.mutations.twoFactor.totp.verify,
|
||||
onSuccess: () => {
|
||||
router.replace(redirectTo);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<View className="flex flex-col items-start gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onComplete={() =>
|
||||
form.handleSubmit((data) => verifyTotp.mutateAsync(data))()
|
||||
}
|
||||
render={({ slots }) => (
|
||||
<InputOTPGroup>
|
||||
{slots.map((slot, index) => (
|
||||
<InputOTPSlot
|
||||
key={index}
|
||||
index={index}
|
||||
max={6}
|
||||
{...slot}
|
||||
/>
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
)}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="trustDevice"
|
||||
render={({ field }) => (
|
||||
<FormCheckbox
|
||||
name="trustDevice"
|
||||
label={t("login.twoFactor.trustDevice")}
|
||||
value={field.value ?? false}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={form.formState.isSubmitting}
|
||||
onPress={form.handleSubmit((data) => verifyTotp.mutateAsync(data))}
|
||||
>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Spin>
|
||||
<Icons.Loader2 className="text-primary-foreground" />
|
||||
</Spin>
|
||||
) : (
|
||||
<Text>{t("verify")}</Text>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</Form>
|
||||
);
|
||||
});
|
||||
|
||||
const TotpCta = memo<CtaProps>(({ onFactorChange }) => {
|
||||
const { t } = useTranslation("auth");
|
||||
return (
|
||||
<View className="flex items-center justify-center pt-2">
|
||||
<Text
|
||||
onPress={() => onFactorChange(SecondFactor.TOTP)}
|
||||
className="text-muted-foreground font-sans-medium cursor-pointer pl-2 text-sm underline underline-offset-4"
|
||||
>
|
||||
{t("login.twoFactor.totp.cta")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
export { TotpForm, TotpCta };
|
||||
16
apps/mobile/src/modules/auth/layout/base.tsx
Normal file
16
apps/mobile/src/modules/auth/layout/base.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ScrollView } from "~/modules/common/styled";
|
||||
import { KeyboardAvoidingView } from "~/modules/common/styled";
|
||||
|
||||
export const AuthLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<KeyboardAvoidingView className="bg-background flex-1" behavior="padding">
|
||||
<ScrollView
|
||||
bounces={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerClassName="gap-5 px-6 pt-4 pb-10"
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
};
|
||||
21
apps/mobile/src/modules/auth/layout/divider.tsx
Normal file
21
apps/mobile/src/modules/auth/layout/divider.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { View } from "react-native";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
export const AuthDivider = () => {
|
||||
const { t } = useTranslation("auth");
|
||||
|
||||
return (
|
||||
<View className="relative w-full">
|
||||
<View className="absolute top-1/2 left-0 flex h-2 w-full items-center">
|
||||
<View className="border-input w-full border-t" />
|
||||
</View>
|
||||
<View className="bg-background relative justify-center self-center">
|
||||
<Text className="text-muted-foreground px-4 text-sm">
|
||||
{t("divider")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
20
apps/mobile/src/modules/auth/layout/header.tsx
Normal file
20
apps/mobile/src/modules/auth/layout/header.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { memo } from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
interface AuthHeaderProps {
|
||||
readonly title: React.ReactNode;
|
||||
readonly description: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AuthHeader = memo<AuthHeaderProps>(({ title, description }) => {
|
||||
return (
|
||||
<View className="gap-1">
|
||||
<Text className="font-sans-bold text-3xl tracking-tighter">{title}</Text>
|
||||
<Text className="text-muted-foreground text-sm">{description}</Text>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
AuthHeader.displayName = "AuthHeader";
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@turbostarter/ui-mobile/alert";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
|
||||
export const InvitationDisclaimer = () => {
|
||||
const { t } = useTranslation("organization");
|
||||
|
||||
return (
|
||||
<Alert icon={Icons.MailPlus} variant="primary">
|
||||
<AlertTitle>{t("invitations.disclaimer.title")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("invitations.disclaimer.description")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
187
apps/mobile/src/modules/auth/lib/api.ts
Normal file
187
apps/mobile/src/modules/auth/lib/api.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { authClient } from "~/lib/auth";
|
||||
|
||||
const KEY = "auth";
|
||||
|
||||
const queries = {
|
||||
sessions: {
|
||||
getAll: {
|
||||
queryKey: [KEY, "sessions"],
|
||||
queryFn: () =>
|
||||
authClient.listSessions({
|
||||
fetchOptions: {
|
||||
throw: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
getAll: {
|
||||
queryKey: [KEY, "accounts"],
|
||||
queryFn: () => authClient.listAccounts({ fetchOptions: { throw: true } }),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mutations = {
|
||||
signIn: {
|
||||
email: {
|
||||
mutationKey: [KEY, "signIn", "email"],
|
||||
mutationFn: (params: Parameters<typeof authClient.signIn.email>[0]) =>
|
||||
authClient.signIn.email(params),
|
||||
},
|
||||
magicLink: {
|
||||
mutationKey: [KEY, "signIn", "magicLink"],
|
||||
mutationFn: (params: Parameters<typeof authClient.signIn.magicLink>[0]) =>
|
||||
authClient.signIn.magicLink(params),
|
||||
},
|
||||
anonymous: {
|
||||
mutationKey: [KEY, "signIn", "anonymous"],
|
||||
mutationFn: (
|
||||
params?: Parameters<typeof authClient.signIn.anonymous>[0],
|
||||
) => authClient.signIn.anonymous(params),
|
||||
},
|
||||
social: {
|
||||
mutationKey: [KEY, "signIn", "social"],
|
||||
mutationFn: async (
|
||||
params: Parameters<typeof authClient.signIn.social>[0],
|
||||
) => {
|
||||
await authClient.signIn.social(params);
|
||||
await authClient.getSession();
|
||||
},
|
||||
},
|
||||
},
|
||||
magicLink: {
|
||||
verify: {
|
||||
mutationKey: [KEY, "magicLink", "verify"],
|
||||
mutationFn: (
|
||||
query: Parameters<typeof authClient.magicLink.verify>[0]["query"],
|
||||
) => authClient.magicLink.verify({ query }),
|
||||
},
|
||||
},
|
||||
password: {
|
||||
forget: {
|
||||
mutationKey: [KEY, "password", "forget"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.requestPasswordReset>[0],
|
||||
) => authClient.requestPasswordReset(params),
|
||||
},
|
||||
reset: {
|
||||
mutationKey: [KEY, "password", "update"],
|
||||
mutationFn: (params: Parameters<typeof authClient.resetPassword>[0]) =>
|
||||
authClient.resetPassword(params),
|
||||
},
|
||||
change: {
|
||||
mutationKey: [KEY, "password", "change"],
|
||||
mutationFn: (params: Parameters<typeof authClient.changePassword>[0]) =>
|
||||
authClient.changePassword(params),
|
||||
},
|
||||
},
|
||||
signOut: {
|
||||
mutationKey: [KEY, "signOut"],
|
||||
mutationFn: (params: Parameters<typeof authClient.signOut>[0]) =>
|
||||
authClient.signOut(params),
|
||||
},
|
||||
signUp: {
|
||||
email: {
|
||||
mutationKey: [KEY, "signUp", "email"],
|
||||
mutationFn: (params: Parameters<typeof authClient.signUp.email>[0]) =>
|
||||
authClient.signUp.email(params),
|
||||
},
|
||||
},
|
||||
twoFactor: {
|
||||
enable: {
|
||||
mutationKey: [KEY, "twoFactor", "enable"],
|
||||
mutationFn: (params: Parameters<typeof authClient.twoFactor.enable>[0]) =>
|
||||
authClient.twoFactor.enable({
|
||||
...params,
|
||||
fetchOptions: { throw: true },
|
||||
}),
|
||||
},
|
||||
disable: {
|
||||
mutationKey: [KEY, "twoFactor", "disable"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.twoFactor.disable>[0],
|
||||
) => authClient.twoFactor.disable(params),
|
||||
},
|
||||
backupCodes: {
|
||||
generate: {
|
||||
mutationKey: [KEY, "twoFactor", "backupCodes", "generate"],
|
||||
mutationFn: (
|
||||
params: Parameters<
|
||||
typeof authClient.twoFactor.generateBackupCodes
|
||||
>[0],
|
||||
) =>
|
||||
authClient.twoFactor.generateBackupCodes({
|
||||
...params,
|
||||
fetchOptions: { throw: true },
|
||||
}),
|
||||
},
|
||||
verify: {
|
||||
mutationKey: [KEY, "twoFactor", "backupCodes", "verify"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.twoFactor.verifyBackupCode>[0],
|
||||
) => authClient.twoFactor.verifyBackupCode(params),
|
||||
},
|
||||
},
|
||||
totp: {
|
||||
getUri: {
|
||||
mutationKey: [KEY, "twoFactor", "totp", "getUri"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.twoFactor.getTotpUri>[0],
|
||||
) =>
|
||||
authClient.twoFactor.getTotpUri({
|
||||
...params,
|
||||
fetchOptions: { throw: true },
|
||||
}),
|
||||
},
|
||||
verify: {
|
||||
mutationKey: [KEY, "twoFactor", "totp", "verify"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.twoFactor.verifyTotp>[0],
|
||||
) => authClient.twoFactor.verifyTotp(params),
|
||||
},
|
||||
},
|
||||
},
|
||||
email: {
|
||||
sendVerification: {
|
||||
mutationKey: [KEY, "email", "sendVerification"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.sendVerificationEmail>[0],
|
||||
) => authClient.sendVerificationEmail(params),
|
||||
},
|
||||
change: {
|
||||
mutationKey: [KEY, "email", "change"],
|
||||
mutationFn: (params: Parameters<typeof authClient.changeEmail>[0]) =>
|
||||
authClient.changeEmail(params),
|
||||
},
|
||||
verify: {
|
||||
mutationKey: [KEY, "email", "confirm"],
|
||||
mutationFn: (
|
||||
query: Parameters<typeof authClient.verifyEmail>[0]["query"],
|
||||
) => authClient.verifyEmail({ query }),
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
connect: {
|
||||
mutationKey: [KEY, "accounts", "connect"],
|
||||
mutationFn: (params: Parameters<typeof authClient.linkSocial>[0]) =>
|
||||
authClient.linkSocial(params),
|
||||
},
|
||||
disconnect: {
|
||||
mutationKey: [KEY, "accounts", "disconnect"],
|
||||
mutationFn: (params: Parameters<typeof authClient.unlinkAccount>[0]) =>
|
||||
authClient.unlinkAccount(params),
|
||||
},
|
||||
},
|
||||
sessions: {
|
||||
revoke: {
|
||||
mutationKey: [KEY, "sessions", "revoke"],
|
||||
mutationFn: (token: string) => authClient.revokeSession({ token }),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const auth = {
|
||||
queries,
|
||||
mutations,
|
||||
};
|
||||
130
apps/mobile/src/modules/auth/login.tsx
Normal file
130
apps/mobile/src/modules/auth/login.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { memo, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
import { SecondFactor } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
|
||||
import { authConfig } from "~/config/auth";
|
||||
import { AnonymousLogin } from "~/modules/auth/form/anonymous";
|
||||
import { LOGIN_OPTIONS } from "~/modules/auth/form/login/constants";
|
||||
import { LoginForm } from "~/modules/auth/form/login/form";
|
||||
import { RegisterCta } from "~/modules/auth/form/register-form";
|
||||
import { SocialProviders } from "~/modules/auth/form/social-providers";
|
||||
import { TwoFactorForm, TwoFactorCta } from "~/modules/auth/form/two-factor";
|
||||
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";
|
||||
import type { LoginOption } from "~/modules/auth/form/login/constants";
|
||||
|
||||
const LoginStep = {
|
||||
FORM: "form",
|
||||
TWO_FACTOR: "twoFactor",
|
||||
} as const;
|
||||
|
||||
type LoginStep = (typeof LoginStep)[keyof typeof LoginStep];
|
||||
|
||||
interface LoginFlowProps {
|
||||
readonly redirectTo?: Route;
|
||||
readonly invitationId?: string;
|
||||
readonly email?: string;
|
||||
}
|
||||
|
||||
export const LoginFlow = ({
|
||||
redirectTo,
|
||||
invitationId,
|
||||
email,
|
||||
}: LoginFlowProps) => {
|
||||
const [step, setStep] = useState<LoginStep>(LoginStep.FORM);
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
{(() => {
|
||||
switch (step) {
|
||||
case LoginStep.FORM:
|
||||
return (
|
||||
<Login
|
||||
redirectTo={redirectTo}
|
||||
invitationId={invitationId}
|
||||
email={email}
|
||||
onTwoFactorRedirect={() => setStep(LoginStep.TWO_FACTOR)}
|
||||
/>
|
||||
);
|
||||
case LoginStep.TWO_FACTOR:
|
||||
return <TwoFactor redirectTo={redirectTo} />;
|
||||
}
|
||||
})()}
|
||||
</AuthLayout>
|
||||
);
|
||||
};
|
||||
|
||||
interface LoginProps extends LoginFlowProps {
|
||||
readonly onTwoFactorRedirect?: () => void;
|
||||
}
|
||||
|
||||
const Login = memo<LoginProps>(
|
||||
({ redirectTo, invitationId, email, onTwoFactorRedirect }) => {
|
||||
const { t } = useTranslation("auth");
|
||||
const options = Object.entries(authConfig.providers)
|
||||
.filter(
|
||||
([provider, enabled]) =>
|
||||
enabled && LOGIN_OPTIONS.includes(provider as LoginOption),
|
||||
)
|
||||
.map(([provider]) => provider as LoginOption);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthHeader
|
||||
title={t("login.header.title")}
|
||||
description={t("login.header.description")}
|
||||
/>
|
||||
{invitationId && <InvitationDisclaimer />}
|
||||
|
||||
<SocialProviders
|
||||
providers={authConfig.providers.oAuth}
|
||||
redirectTo={redirectTo}
|
||||
/>
|
||||
{authConfig.providers.oAuth.length > 0 && options.length > 0 && (
|
||||
<AuthDivider />
|
||||
)}
|
||||
|
||||
<View className="gap-2">
|
||||
<LoginForm
|
||||
options={options}
|
||||
redirectTo={redirectTo}
|
||||
email={email}
|
||||
onTwoFactorRedirect={onTwoFactorRedirect}
|
||||
/>
|
||||
{authConfig.providers.anonymous && <AnonymousLogin />}
|
||||
</View>
|
||||
|
||||
<RegisterCta />
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const TwoFactor = ({ redirectTo }: LoginFlowProps) => {
|
||||
const [factor, setFactor] = useState<SecondFactor>(SecondFactor.TOTP);
|
||||
const { t } = useTranslation("auth");
|
||||
|
||||
const Form = TwoFactorForm[factor];
|
||||
const Cta =
|
||||
factor === SecondFactor.TOTP
|
||||
? TwoFactorCta[SecondFactor.BACKUP_CODE]
|
||||
: TwoFactorCta[SecondFactor.TOTP];
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthHeader
|
||||
title={t(`login.twoFactor.${factor}.header.title`)}
|
||||
description={t(`login.twoFactor.${factor}.header.description`)}
|
||||
/>
|
||||
|
||||
<Form redirectTo={redirectTo} />
|
||||
<Cta onFactorChange={setFactor} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
136
apps/mobile/src/modules/auth/verification.tsx
Normal file
136
apps/mobile/src/modules/auth/verification.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { router, useGlobalSearchParams } from "expo-router";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { VerificationType } from "@turbostarter/auth";
|
||||
|
||||
import { useSetupSteps } from "~/app/(setup)/steps/_layout";
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { Spinner } from "~/modules/common/spinner";
|
||||
import { user } from "~/modules/user/lib/api";
|
||||
|
||||
import { auth } from "./lib/api";
|
||||
|
||||
import type { Route } from "expo-router";
|
||||
|
||||
const useVerificationMutations = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
}: {
|
||||
onSuccess?: () => void;
|
||||
onError?: () => void;
|
||||
}) => {
|
||||
const { reset } = useSetupSteps();
|
||||
const signOut = useMutation({
|
||||
...auth.mutations.signOut,
|
||||
onSuccess: () => {
|
||||
reset();
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
[VerificationType.MAGIC_LINK]: useMutation({
|
||||
...auth.mutations.magicLink.verify,
|
||||
onSuccess,
|
||||
onError,
|
||||
}),
|
||||
[VerificationType.CONFIRM_EMAIL]: useMutation({
|
||||
...auth.mutations.email.verify,
|
||||
onSuccess,
|
||||
onError,
|
||||
}),
|
||||
[VerificationType.DELETE_ACCOUNT]: useMutation({
|
||||
...user.mutations.delete,
|
||||
onSuccess: async () => {
|
||||
await signOut.mutateAsync(undefined);
|
||||
onSuccess?.();
|
||||
},
|
||||
onError,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const VerificationController = ({
|
||||
token,
|
||||
type,
|
||||
callbackURL,
|
||||
redirectTo,
|
||||
errorCallbackURL,
|
||||
}: {
|
||||
token: string;
|
||||
type: VerificationType;
|
||||
callbackURL?: Route;
|
||||
redirectTo?: Route;
|
||||
errorCallbackURL?: Route;
|
||||
}) => {
|
||||
const resetParams = () => {
|
||||
router.setParams({
|
||||
token: undefined,
|
||||
type: undefined,
|
||||
redirectTo: undefined,
|
||||
callbackURL: undefined,
|
||||
errorCallbackURL: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const mutations = useVerificationMutations({
|
||||
onSuccess: () => {
|
||||
router.navigate(redirectTo ?? callbackURL ?? pathsConfig.index);
|
||||
resetParams();
|
||||
},
|
||||
...(errorCallbackURL
|
||||
? {
|
||||
onError: () => {
|
||||
router.navigate(errorCallbackURL);
|
||||
resetParams();
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
const { mutate, isPending } = mutations[type];
|
||||
|
||||
useEffect(() => {
|
||||
if (token && !isPending) {
|
||||
mutate({
|
||||
token,
|
||||
});
|
||||
}
|
||||
}, [token, isPending, mutate, callbackURL, errorCallbackURL]);
|
||||
|
||||
if (isPending) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const Verification = () => {
|
||||
const {
|
||||
token,
|
||||
type,
|
||||
callbackURL = pathsConfig.index,
|
||||
redirectTo,
|
||||
errorCallbackURL,
|
||||
} = useGlobalSearchParams<{
|
||||
token?: string;
|
||||
type?: VerificationType;
|
||||
callbackURL?: Route;
|
||||
redirectTo?: Route;
|
||||
errorCallbackURL?: Route;
|
||||
}>();
|
||||
|
||||
if (!token || !type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<VerificationController
|
||||
token={token}
|
||||
type={type}
|
||||
callbackURL={callbackURL}
|
||||
redirectTo={redirectTo}
|
||||
errorCallbackURL={errorCallbackURL}
|
||||
/>
|
||||
);
|
||||
};
|
||||
5
apps/mobile/src/modules/billing/hooks/use-customer.ts
Normal file
5
apps/mobile/src/modules/billing/hooks/use-customer.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { billing } from "~/modules/billing/lib/api";
|
||||
|
||||
export const useCustomer = () => useQuery(billing.queries.customer.get);
|
||||
18
apps/mobile/src/modules/billing/lib/api.ts
Normal file
18
apps/mobile/src/modules/billing/lib/api.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
|
||||
import { api } from "~/lib/api";
|
||||
|
||||
const KEY = "billing";
|
||||
|
||||
const queries = {
|
||||
customer: {
|
||||
get: {
|
||||
queryKey: [KEY, "customer"],
|
||||
queryFn: () => handle(api.billing.customer.$get)(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const billing = {
|
||||
queries,
|
||||
};
|
||||
379
apps/mobile/src/modules/common/avatar-form.tsx
Normal file
379
apps/mobile/src/modules/common/avatar-form.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
import { useMutation, useMutationState } from "@tanstack/react-query";
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import { FormProvider, useForm, useFormContext } from "react-hook-form";
|
||||
import { Alert, Text, View } from "react-native";
|
||||
import * as z from "zod";
|
||||
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@turbostarter/ui-mobile/avatar";
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
import { Spin } from "@turbostarter/ui-mobile/spin";
|
||||
|
||||
import { api } from "~/lib/api";
|
||||
import { useImagePicker } from "~/modules/common/hooks/use-image-picker";
|
||||
|
||||
import type { ImagePickerAsset } from "expo-image-picker";
|
||||
|
||||
interface AvatarFormProps {
|
||||
readonly id: string;
|
||||
readonly image?: string | null;
|
||||
readonly update: (image: string | null) => Promise<unknown>;
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const ACCEPTED_IMAGE_TYPES = [
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
];
|
||||
|
||||
const mutations = {
|
||||
upload: {
|
||||
mutationKey: ["avatar", "upload"] as const,
|
||||
mutationFn: async ({
|
||||
avatar,
|
||||
id,
|
||||
image,
|
||||
update,
|
||||
}: AvatarFormProps & { avatar?: ImagePickerAsset }) => {
|
||||
if (!avatar) throw new Error("No file selected");
|
||||
|
||||
const guessedExtensionFromMime = avatar.mimeType?.split("/").pop();
|
||||
const guessedExtensionFromUri = avatar.uri.split(".").pop();
|
||||
const extension =
|
||||
guessedExtensionFromMime ?? guessedExtensionFromUri ?? "jpg";
|
||||
const uuid = String(Date.now());
|
||||
const path = `avatars/${id}-${uuid}.${extension}`;
|
||||
|
||||
const blob = await fetch(avatar.uri).then((r) => r.blob());
|
||||
|
||||
const { url: uploadUrl } = await handle(api.storage.upload.$get)({
|
||||
query: { path },
|
||||
});
|
||||
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: "PUT",
|
||||
body: blob,
|
||||
headers: {
|
||||
"Content-Type": avatar.mimeType ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
const { url: publicUrl } = await handle(api.storage.public.$get)({
|
||||
query: { path },
|
||||
});
|
||||
|
||||
await update(publicUrl);
|
||||
|
||||
return { publicUrl, oldImage: image };
|
||||
},
|
||||
},
|
||||
remove: {
|
||||
mutationKey: ["avatar", "remove"] as const,
|
||||
mutationFn: async ({ image, update }: Omit<AvatarFormProps, "id">) => {
|
||||
const path = image?.split("/").pop();
|
||||
if (!path) return;
|
||||
|
||||
const { url: deleteUrl } = await handle(api.storage.delete.$get)({
|
||||
query: { path: `avatars/${path}` },
|
||||
});
|
||||
|
||||
await update(null);
|
||||
|
||||
void fetch(deleteUrl, { method: "DELETE" });
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const useAvatarFormSchema = () => {
|
||||
const assetSchema = z.object({
|
||||
uri: z.string().min(1),
|
||||
mimeType: z.string().optional(),
|
||||
fileSize: z.number().optional(),
|
||||
width: z.number().optional(),
|
||||
height: z.number().optional(),
|
||||
});
|
||||
|
||||
return z.object({
|
||||
avatar: assetSchema
|
||||
.refine((file) => (file.fileSize ?? MAX_FILE_SIZE) <= MAX_FILE_SIZE, {
|
||||
message: "error.tooBig.file.inclusive",
|
||||
path: ["avatar"],
|
||||
})
|
||||
.refine(
|
||||
(file) =>
|
||||
!file.mimeType || ACCEPTED_IMAGE_TYPES.includes(file.mimeType),
|
||||
{
|
||||
message: "error.file.type",
|
||||
path: ["avatar"],
|
||||
},
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
interface AvatarFormContextValue extends AvatarFormProps {
|
||||
previewUrl: string | null;
|
||||
setPreviewUrl: (previewUrl: string | null) => void;
|
||||
}
|
||||
|
||||
const AvatarFormContext = createContext<AvatarFormContextValue | null>(null);
|
||||
|
||||
const useAvatarFormContext = () => {
|
||||
const context = useContext(AvatarFormContext);
|
||||
if (!context) {
|
||||
throw new Error("useAvatarFormContext must be used within a AvatarForm!");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
const AvatarForm = ({
|
||||
id,
|
||||
image,
|
||||
update,
|
||||
children,
|
||||
}: AvatarFormProps & { children: React.ReactNode }) => {
|
||||
const [previewUrl, setPreviewUrl] = useState(image ?? null);
|
||||
const _avatarSchema = useAvatarFormSchema();
|
||||
const form = useForm<z.infer<typeof _avatarSchema>>();
|
||||
|
||||
return (
|
||||
<AvatarFormContext.Provider
|
||||
value={{ id, image, update, previewUrl, setPreviewUrl }}
|
||||
>
|
||||
<FormProvider {...form}>{children}</FormProvider>
|
||||
</AvatarFormContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const AvatarFormUploadButton = ({
|
||||
className,
|
||||
onUpload,
|
||||
disabled,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button> & { onUpload?: () => void }) => {
|
||||
const { t } = useTranslation(["common", "validation"]);
|
||||
const { pick } = useImagePicker();
|
||||
const { image, setPreviewUrl, id, update } = useAvatarFormContext();
|
||||
const avatarSchema = useAvatarFormSchema();
|
||||
|
||||
const { setError, clearErrors } =
|
||||
useFormContext<z.infer<typeof avatarSchema>>();
|
||||
|
||||
const upload = useMutation({
|
||||
...mutations.upload,
|
||||
onError: (error) => {
|
||||
setPreviewUrl(image ?? null);
|
||||
Alert.alert(
|
||||
t("common:error.title"),
|
||||
error.message || t("common:error.general"),
|
||||
);
|
||||
},
|
||||
onSuccess: async ({ publicUrl, oldImage }) => {
|
||||
clearErrors();
|
||||
setPreviewUrl(publicUrl);
|
||||
|
||||
if (oldImage) {
|
||||
const oldPath = oldImage.split("/").pop();
|
||||
if (oldPath) {
|
||||
const { url: deleteUrl } = await handle(api.storage.delete.$get)({
|
||||
query: { path: `avatars/${oldPath}` },
|
||||
});
|
||||
void fetch(deleteUrl, { method: "DELETE" });
|
||||
}
|
||||
}
|
||||
|
||||
onUpload?.();
|
||||
},
|
||||
});
|
||||
|
||||
const [removeStatus] = useMutationState({
|
||||
filters: { mutationKey: mutations.remove.mutationKey },
|
||||
select: (mutation) => mutation.state.status,
|
||||
});
|
||||
|
||||
const handlePick = async () => {
|
||||
const asset = await pick();
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = avatarSchema.safeParse({ avatar: asset });
|
||||
if (!result.success) {
|
||||
const firstIssue = result.error.issues[0];
|
||||
const firstMsg = firstIssue?.message ?? "";
|
||||
setError("avatar", { message: firstMsg });
|
||||
Alert.alert(t("common:error.title"), firstMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
setPreviewUrl(asset.uri);
|
||||
upload.mutate({
|
||||
avatar: asset,
|
||||
id,
|
||||
image,
|
||||
update,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
hitSlop={4}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"dark:bg-background active:bg-muted absolute -right-2 -bottom-2.5 rounded-full",
|
||||
className,
|
||||
)}
|
||||
onPress={handlePick}
|
||||
disabled={disabled ?? (upload.isPending || removeStatus === "pending")}
|
||||
{...props}
|
||||
>
|
||||
<Icons.Pencil size={14} className="text-foreground" />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const AvatarFormPreview = ({
|
||||
className,
|
||||
fallback,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Avatar> & { fallback?: React.ReactNode }) => {
|
||||
const { previewUrl } = useAvatarFormContext();
|
||||
const _avatarSchema = useAvatarFormSchema();
|
||||
const { formState } = useFormContext<z.infer<typeof _avatarSchema>>();
|
||||
|
||||
const mutationStatuses = useMutationState({
|
||||
filters: {
|
||||
predicate: (mutation) =>
|
||||
mutation.options.mutationKey === mutations.upload.mutationKey ||
|
||||
mutation.options.mutationKey === mutations.remove.mutationKey,
|
||||
},
|
||||
select: (mutation) => mutation.state.status,
|
||||
});
|
||||
|
||||
const hasError =
|
||||
Boolean(formState.errors.avatar) ||
|
||||
mutationStatuses.some((s) => s === "error");
|
||||
|
||||
return (
|
||||
<View className="relative">
|
||||
<Avatar
|
||||
className={cn(
|
||||
"size-26",
|
||||
hasError ? "ring-destructive ring-2 ring-offset-2" : "",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{previewUrl && <AvatarImage source={{ uri: previewUrl }} />}
|
||||
{mutationStatuses.some((status) => status === "pending") && (
|
||||
<>
|
||||
<View className="bg-background absolute inset-0 rounded-full opacity-50" />
|
||||
<View className="absolute inset-0 items-center justify-center rounded-full">
|
||||
<Spin>
|
||||
<Icons.Loader2 className="text-muted-foreground" size={28} />
|
||||
</Spin>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
<AvatarFallback>
|
||||
{fallback ?? (
|
||||
<Icons.UserRound
|
||||
width={50}
|
||||
height={50}
|
||||
className="text-foreground"
|
||||
/>
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const AvatarFormRemoveButton = ({
|
||||
className,
|
||||
onRemove,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button> & { onRemove?: () => void }) => {
|
||||
const { image, update, previewUrl, setPreviewUrl } = useAvatarFormContext();
|
||||
const { clearErrors } = useFormContext();
|
||||
|
||||
const [uploadStatus] = useMutationState({
|
||||
filters: {
|
||||
mutationKey: mutations.upload.mutationKey,
|
||||
},
|
||||
select: (mutation) => mutation.state.status,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
...mutations.remove,
|
||||
onMutate: () => {
|
||||
setPreviewUrl(null);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setPreviewUrl(null);
|
||||
onRemove?.();
|
||||
},
|
||||
});
|
||||
|
||||
if (!previewUrl || uploadStatus === "pending") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"dark:bg-background active:bg-muted absolute -top-2 -right-2 rounded-full",
|
||||
className,
|
||||
)}
|
||||
disabled={remove.isPending}
|
||||
onPress={() => {
|
||||
clearErrors();
|
||||
remove.mutate({ image, update });
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<Icons.X size={16} strokeWidth={2} className="text-foreground" />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const AvatarFormErrorMessage = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Text>) => {
|
||||
const _avatarSchema = useAvatarFormSchema();
|
||||
const { formState } = useFormContext<z.infer<typeof _avatarSchema>>();
|
||||
|
||||
if (!formState.errors.avatar) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Text className={cn("text-destructive text-xs", className)} {...props}>
|
||||
{formState.errors.avatar.message}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
AvatarForm,
|
||||
AvatarFormPreview,
|
||||
AvatarFormRemoveButton,
|
||||
AvatarFormUploadButton,
|
||||
AvatarFormErrorMessage,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { logger } from "@turbostarter/shared/logger";
|
||||
|
||||
export const useCopyToClipboard = () => {
|
||||
const [copiedText, setCopiedText] = useState<string | null>(null);
|
||||
|
||||
const copy = useCallback(async (text: string): Promise<boolean> => {
|
||||
try {
|
||||
await Clipboard.setStringAsync(text);
|
||||
setCopiedText(text);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error("Failed to copy to clipboard:", error);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return [copiedText, copy] as const;
|
||||
};
|
||||
36
apps/mobile/src/modules/common/hooks/use-image-picker.tsx
Normal file
36
apps/mobile/src/modules/common/hooks/use-image-picker.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as ExpoImagePicker from "expo-image-picker";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { logger } from "@turbostarter/shared/logger";
|
||||
|
||||
export const useImagePicker = () => {
|
||||
const pick = useCallback(async () => {
|
||||
try {
|
||||
const result = await ExpoImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ["images"],
|
||||
allowsEditing: true,
|
||||
quality: 0.6,
|
||||
});
|
||||
|
||||
if (result.canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingResult = await ExpoImagePicker.getPendingResultAsync();
|
||||
|
||||
const image =
|
||||
result.assets[0] ??
|
||||
(pendingResult && "assets" in pendingResult
|
||||
? pendingResult.assets?.[0]
|
||||
: null);
|
||||
|
||||
return image;
|
||||
} catch (e) {
|
||||
logger.error("Error on image pick: ", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
pick,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { focusManager } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { AppState, Platform } from "react-native";
|
||||
|
||||
import type { AppStateStatus } from "react-native";
|
||||
|
||||
export function useRefetchOnAppFocus() {
|
||||
useEffect(() => {
|
||||
const onAppStateChange = (status: AppStateStatus) => {
|
||||
if (Platform.OS !== "web") {
|
||||
focusManager.setFocused(status === "active");
|
||||
}
|
||||
};
|
||||
|
||||
const subscription = AppState.addEventListener("change", onAppStateChange);
|
||||
|
||||
return () => subscription.remove();
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import React from "react";
|
||||
|
||||
export function useRefetchOnFocus<T>(refetch: () => Promise<T>) {
|
||||
const firstTimeRef = React.useRef(true);
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
if (firstTimeRef.current) {
|
||||
firstTimeRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
void refetch();
|
||||
}, [refetch]),
|
||||
);
|
||||
}
|
||||
75
apps/mobile/src/modules/common/hooks/use-theme.tsx
Normal file
75
apps/mobile/src/modules/common/hooks/use-theme.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useColorScheme } from "react-native";
|
||||
import { Uniwind } from "uniwind";
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
|
||||
import { ThemeMode, themes } from "@turbostarter/ui";
|
||||
|
||||
import { appConfig } from "~/config/app";
|
||||
|
||||
import type { ColorVariable, ThemeConfig } from "@turbostarter/ui";
|
||||
|
||||
const useThemeConfig = create<{
|
||||
config: ThemeConfig;
|
||||
setConfig: (config: ThemeConfig) => void;
|
||||
}>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
config: appConfig.theme,
|
||||
setConfig: (config) => set({ config }),
|
||||
}),
|
||||
{
|
||||
name: "theme-config",
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const useTheme = () => {
|
||||
const colorScheme = useColorScheme();
|
||||
const { config, setConfig } = useThemeConfig();
|
||||
|
||||
const isDark = useMemo(
|
||||
() =>
|
||||
config.mode === ThemeMode.DARK ||
|
||||
(config.mode === ThemeMode.SYSTEM && colorScheme === ThemeMode.DARK),
|
||||
[config.mode, colorScheme],
|
||||
);
|
||||
|
||||
const resolvedTheme = useMemo(
|
||||
() => (isDark ? ThemeMode.DARK : ThemeMode.LIGHT),
|
||||
[isDark],
|
||||
);
|
||||
|
||||
const updateTheme = useCallback(() => {
|
||||
Uniwind.setTheme(config.mode);
|
||||
|
||||
const colors = themes[config.color][resolvedTheme];
|
||||
Uniwind.updateCSSVariables(
|
||||
resolvedTheme,
|
||||
Object.entries(colors).reduce(
|
||||
(acc, [key, value]: [string, ColorVariable]) => {
|
||||
const [l, c, h, a] = value;
|
||||
acc[`--${key}`] =
|
||||
a !== undefined
|
||||
? `oklch(${l} ${c} ${h} / ${a * 100}%)`
|
||||
: `oklch(${l} ${c} ${h})`;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
),
|
||||
);
|
||||
}, [resolvedTheme, config]);
|
||||
|
||||
useEffect(() => {
|
||||
updateTheme();
|
||||
}, [updateTheme]);
|
||||
|
||||
return {
|
||||
config,
|
||||
setConfig,
|
||||
resolvedTheme,
|
||||
};
|
||||
};
|
||||
71
apps/mobile/src/modules/common/layout/header.tsx
Normal file
71
apps/mobile/src/modules/common/layout/header.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Platform, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
import { AccountSwitcher } from "~/modules/organization/account-switcher";
|
||||
|
||||
interface BaseHeaderProps {
|
||||
readonly onBack?: () => void;
|
||||
readonly title?: string;
|
||||
}
|
||||
|
||||
export const BaseHeader = ({ onBack, title }: BaseHeaderProps) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<View
|
||||
className="bg-background"
|
||||
style={{
|
||||
paddingTop: Platform.select({
|
||||
ios: insets.top + 8,
|
||||
android: insets.top + 16,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<View className="h-12 w-full flex-row items-center justify-center gap-3 pb-1">
|
||||
{onBack && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onPress={() => onBack()}
|
||||
className="absolute bottom-2 left-6"
|
||||
>
|
||||
<Icons.ChevronLeft
|
||||
width={22}
|
||||
height={22}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{title && (
|
||||
<Text className="font-sans-medium mt-0.5 leading-none">{title}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const UserHeader = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<View
|
||||
className="bg-background w-full flex-row items-center justify-between pr-6 pb-1 pl-4"
|
||||
style={{
|
||||
paddingTop: Platform.select({
|
||||
ios: insets.top,
|
||||
android: insets.top + 8,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<AccountSwitcher />
|
||||
<Button size="icon" variant="ghost">
|
||||
<Icons.Bell size={20} className="text-muted-foreground" />
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
69
apps/mobile/src/modules/common/settings-tile.tsx
Normal file
69
apps/mobile/src/modules/common/settings-tile.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Pressable } from "react-native";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
import { Spin } from "@turbostarter/ui-mobile/spin";
|
||||
import { TextClassContext } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
interface SettingsTileProps {
|
||||
readonly icon: React.ElementType;
|
||||
readonly children: React.ReactNode;
|
||||
readonly onPress?: () => void;
|
||||
readonly destructive?: boolean;
|
||||
readonly loading?: boolean;
|
||||
readonly disabled?: boolean;
|
||||
}
|
||||
|
||||
export const SettingsTile = ({
|
||||
icon: Icon,
|
||||
onPress,
|
||||
children,
|
||||
destructive,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
}: SettingsTileProps) => {
|
||||
return (
|
||||
<Pressable
|
||||
hitSlop={4}
|
||||
className={cn(
|
||||
"bg-background active:bg-accent dark:active:bg-accent/50 flex-row items-center justify-between gap-4 px-6 py-3.5 transition-colors",
|
||||
{
|
||||
"opacity-50": disabled,
|
||||
},
|
||||
)}
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon
|
||||
width={24}
|
||||
height={24}
|
||||
className={cn("text-muted-foreground", {
|
||||
"text-destructive": destructive,
|
||||
})}
|
||||
/>
|
||||
<TextClassContext.Provider
|
||||
value={cn("mr-auto text-base", {
|
||||
"text-destructive": destructive,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</TextClassContext.Provider>
|
||||
|
||||
{loading ? (
|
||||
<Spin>
|
||||
<Icons.Loader2
|
||||
className="text-muted-foreground"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</Spin>
|
||||
) : (
|
||||
<Icons.ChevronRight
|
||||
className="text-muted-foreground"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
39
apps/mobile/src/modules/common/spinner.tsx
Normal file
39
apps/mobile/src/modules/common/spinner.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Portal } from "@rn-primitives/portal";
|
||||
import { Fragment } from "react";
|
||||
import { ActivityIndicator, StyleSheet, View } from "react-native";
|
||||
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens";
|
||||
|
||||
import { isIOS } from "~/utils/device";
|
||||
|
||||
const FullWindowOverlay = isIOS ? RNFullWindowOverlay : Fragment;
|
||||
|
||||
interface SpinnerProps {
|
||||
readonly modal?: boolean;
|
||||
}
|
||||
|
||||
export const Spinner = ({ modal = true }: SpinnerProps) => {
|
||||
if (!modal) {
|
||||
return (
|
||||
<View
|
||||
style={StyleSheet.absoluteFill}
|
||||
className="bg-background flex-1 items-center justify-center"
|
||||
>
|
||||
<ActivityIndicator size="large" colorClassName="accent-primary" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal name="spinner">
|
||||
<FullWindowOverlay>
|
||||
<View
|
||||
style={StyleSheet.absoluteFill}
|
||||
className="flex-1 items-center justify-center"
|
||||
>
|
||||
<View style={StyleSheet.absoluteFill} className="bg-background/80" />
|
||||
<ActivityIndicator size="large" colorClassName="accent-primary" />
|
||||
</View>
|
||||
</FullWindowOverlay>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
39
apps/mobile/src/modules/common/styled.tsx
Normal file
39
apps/mobile/src/modules/common/styled.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { BlurView as NativeBlurView } from "expo-blur";
|
||||
import { GlassView as NativeGlassView } from "expo-glass-effect";
|
||||
import { Link as NativeLink } from "expo-router";
|
||||
import { ScrollView as NativeScrollView } from "react-native-gesture-handler";
|
||||
import { KeyboardAvoidingView as NativeKeyboardAvoidingView } from "react-native-keyboard-controller";
|
||||
import { SafeAreaView as NativeSafeAreaView } from "react-native-safe-area-context";
|
||||
import { withUniwind } from "uniwind";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
import { WIDTH } from "~/utils/device";
|
||||
|
||||
export const KeyboardAvoidingView = withUniwind(NativeKeyboardAvoidingView);
|
||||
export const Link = withUniwind(NativeLink);
|
||||
export const ScrollView = withUniwind(NativeScrollView);
|
||||
export const SafeAreaView = withUniwind(NativeSafeAreaView);
|
||||
export const BlurView = withUniwind(NativeBlurView);
|
||||
export const GlassView = withUniwind(NativeGlassView);
|
||||
|
||||
export const TabBarLabel = ({
|
||||
children,
|
||||
focused,
|
||||
}: {
|
||||
children: string;
|
||||
focused: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<Text
|
||||
className={cn(
|
||||
"text-muted-foreground text-xs",
|
||||
focused && "text-primary",
|
||||
WIDTH > 640 && "ml-3 text-sm",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
298
apps/mobile/src/modules/common/updates.tsx
Normal file
298
apps/mobile/src/modules/common/updates.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import {
|
||||
useUpdates,
|
||||
reloadAsync,
|
||||
fetchUpdateAsync,
|
||||
checkForUpdateAsync,
|
||||
} from "expo-updates";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { AppState, Platform, View } from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
|
||||
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 { Progress } from "@turbostarter/ui-mobile/progress";
|
||||
import { Spin } from "@turbostarter/ui-mobile/spin";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
import { BlurView, GlassView } from "./styled";
|
||||
|
||||
import type { ViewProps } from "react-native";
|
||||
|
||||
const UpdatesContext = createContext<{
|
||||
visible: boolean;
|
||||
setVisible: (visible: boolean) => void;
|
||||
}>({
|
||||
visible: false,
|
||||
setVisible: () => void 0,
|
||||
});
|
||||
|
||||
const Wrapper = ({ className, style, ...props }: ViewProps) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const sharedClassName =
|
||||
"absolute left-0 mx-5 gap-3 overflow-hidden rounded-lg border-border border p-5";
|
||||
|
||||
const offset = insets.top + (Platform.select({ ios: 4, android: 8 }) ?? 0);
|
||||
const sharedStyle = {
|
||||
top: offset,
|
||||
};
|
||||
|
||||
if (isLiquidGlassAvailable()) {
|
||||
return (
|
||||
<GlassView
|
||||
className={cn(sharedClassName, className)}
|
||||
style={[sharedStyle, style]}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BlurView
|
||||
className={cn(sharedClassName, className)}
|
||||
style={[sharedStyle, style]}
|
||||
intensity={200}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Available = () => {
|
||||
const { t } = useTranslation(["marketing", "common"]);
|
||||
const { isUpdatePending } = useUpdates();
|
||||
const { setVisible } = useContext(UpdatesContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
<View className="gap-0.5">
|
||||
<Text className="font-sans-medium text-lg leading-tight">
|
||||
{t("update.available.title")}
|
||||
</Text>
|
||||
<Text className="text-muted-foreground text-sm">
|
||||
{t("update.available.description")}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="grow"
|
||||
onPress={() => setVisible(false)}
|
||||
>
|
||||
<Text>{t("dismiss")}</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="grow"
|
||||
onPress={() => {
|
||||
if (isUpdatePending) {
|
||||
return reloadAsync();
|
||||
}
|
||||
|
||||
return fetchUpdateAsync();
|
||||
}}
|
||||
>
|
||||
<Text>{t("install")}</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Installing = () => {
|
||||
const { t } = useTranslation(["marketing", "common"]);
|
||||
const { downloadProgress, isUpdatePending } = useUpdates();
|
||||
|
||||
useEffect(() => {
|
||||
if (isUpdatePending && (downloadProgress ?? 0) >= 1) {
|
||||
void reloadAsync();
|
||||
}
|
||||
}, [isUpdatePending, downloadProgress]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<View className="gap-0.5">
|
||||
<View className="flex-row items-center justify-between gap-2">
|
||||
<Text className="font-sans-medium text-lg leading-tight">
|
||||
{t("update.installing.title")}
|
||||
</Text>
|
||||
<Spin>
|
||||
<Icons.Loader2 className="text-primary" size={16} />
|
||||
</Spin>
|
||||
</View>
|
||||
<Text className="text-muted-foreground text-sm">
|
||||
{t("update.installing.description")}
|
||||
</Text>
|
||||
</View>
|
||||
<Progress value={(downloadProgress ?? 0) * 100} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const DownloadError = () => {
|
||||
const { t } = useTranslation("common");
|
||||
const { isUpdatePending, downloadError } = useUpdates();
|
||||
const { setVisible } = useContext(UpdatesContext);
|
||||
|
||||
if (!downloadError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<View className="gap-0.5">
|
||||
<Text className="font-sans-medium text-lg leading-tight">
|
||||
{t("error.general")}
|
||||
</Text>
|
||||
<Text className="text-muted-foreground text-sm">
|
||||
{downloadError.message}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="grow"
|
||||
onPress={() => setVisible(false)}
|
||||
>
|
||||
<Text>{t("dismiss")}</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="grow"
|
||||
onPress={() => {
|
||||
if (isUpdatePending) {
|
||||
return reloadAsync();
|
||||
}
|
||||
|
||||
return fetchUpdateAsync();
|
||||
}}
|
||||
>
|
||||
<Text>{t("tryAgain")}</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Content = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { visible, setVisible } = useContext(UpdatesContext);
|
||||
const { isUpdateAvailable, isUpdatePending, isDownloading, downloadError } =
|
||||
useUpdates();
|
||||
|
||||
const positionY = useSharedValue(-1000);
|
||||
const startY = useSharedValue(0);
|
||||
const height = useSharedValue(0);
|
||||
const offset = insets.top + (Platform.select({ ios: 4, android: 8 }) ?? 0);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [{ translateY: withSpring(positionY.value) }],
|
||||
};
|
||||
});
|
||||
|
||||
const pan = Gesture.Pan()
|
||||
.onBegin(() => {
|
||||
startY.value = positionY.value;
|
||||
})
|
||||
.onChange((event) => {
|
||||
const next = startY.value + event.translationY;
|
||||
positionY.value = Math.min(0, next);
|
||||
})
|
||||
.onFinalize((event) => {
|
||||
const threshold = Math.max(64, (height.value + offset) * 0.4);
|
||||
const shouldDismiss =
|
||||
positionY.value < -threshold || event.velocityY < -500;
|
||||
if (shouldDismiss) {
|
||||
positionY.value = withSpring(-(height.value + offset));
|
||||
scheduleOnRN(setVisible, false);
|
||||
} else {
|
||||
positionY.value = withSpring(0);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
positionY.value = withSpring(0);
|
||||
} else {
|
||||
positionY.value = withSpring(
|
||||
height.value > 0 ? -(height.value + offset) : -1000,
|
||||
);
|
||||
}
|
||||
}, [visible, positionY, offset, height]);
|
||||
|
||||
if (!isUpdateAvailable || !isUpdatePending) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={pan}>
|
||||
<Animated.View style={[animatedStyle, { zIndex: 50 }]}>
|
||||
<Wrapper
|
||||
onLayout={(e) => {
|
||||
const h = e.nativeEvent.layout.height;
|
||||
height.value = h;
|
||||
if (!visible) {
|
||||
positionY.value = -(h + offset);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<Installing />
|
||||
) : downloadError ? (
|
||||
<DownloadError />
|
||||
) : (
|
||||
<Available />
|
||||
)}
|
||||
</Wrapper>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
);
|
||||
};
|
||||
|
||||
export const Updates = () => {
|
||||
const { isUpdateAvailable, isUpdatePending } = useUpdates();
|
||||
const [visible, setVisible] = useState(isUpdateAvailable || isUpdatePending);
|
||||
|
||||
useEffect(() => {
|
||||
if (isUpdateAvailable || isUpdatePending) {
|
||||
setVisible(true);
|
||||
}
|
||||
}, [isUpdateAvailable, isUpdatePending]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener("change", (state) => {
|
||||
if (state !== "active") {
|
||||
return;
|
||||
}
|
||||
|
||||
void checkForUpdateAsync()
|
||||
.then(({ isAvailable }) => {
|
||||
setVisible(isAvailable);
|
||||
})
|
||||
.catch(() => {
|
||||
setVisible(false);
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<UpdatesContext.Provider value={{ visible, setVisible }}>
|
||||
<Content />
|
||||
</UpdatesContext.Provider>
|
||||
);
|
||||
};
|
||||
297
apps/mobile/src/modules/home/charts/area.tsx
Normal file
297
apps/mobile/src/modules/home/charts/area.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import { LinearGradient, matchFont, vec } from "@shopify/react-native-skia";
|
||||
import { useState } from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import { CartesianChart, StackedArea } from "victory-native";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
} from "@turbostarter/ui-mobile/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@turbostarter/ui-mobile/select";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
const chartData = [
|
||||
{ date: "2024-04-01", desktop: 222, mobile: 150 },
|
||||
{ date: "2024-04-02", desktop: 97, mobile: 180 },
|
||||
{ date: "2024-04-03", desktop: 167, mobile: 120 },
|
||||
{ date: "2024-04-04", desktop: 242, mobile: 260 },
|
||||
{ date: "2024-04-05", desktop: 373, mobile: 290 },
|
||||
{ date: "2024-04-06", desktop: 301, mobile: 340 },
|
||||
{ date: "2024-04-07", desktop: 245, mobile: 180 },
|
||||
{ date: "2024-04-08", desktop: 409, mobile: 320 },
|
||||
{ date: "2024-04-09", desktop: 59, mobile: 110 },
|
||||
{ date: "2024-04-10", desktop: 261, mobile: 190 },
|
||||
{ date: "2024-04-11", desktop: 327, mobile: 350 },
|
||||
{ date: "2024-04-12", desktop: 292, mobile: 210 },
|
||||
{ date: "2024-04-13", desktop: 342, mobile: 380 },
|
||||
{ date: "2024-04-14", desktop: 137, mobile: 220 },
|
||||
{ date: "2024-04-15", desktop: 120, mobile: 170 },
|
||||
{ date: "2024-04-16", desktop: 138, mobile: 190 },
|
||||
{ date: "2024-04-17", desktop: 446, mobile: 360 },
|
||||
{ date: "2024-04-18", desktop: 364, mobile: 410 },
|
||||
{ date: "2024-04-19", desktop: 243, mobile: 180 },
|
||||
{ date: "2024-04-20", desktop: 89, mobile: 150 },
|
||||
{ date: "2024-04-21", desktop: 137, mobile: 200 },
|
||||
{ date: "2024-04-22", desktop: 224, mobile: 170 },
|
||||
{ date: "2024-04-23", desktop: 138, mobile: 230 },
|
||||
{ date: "2024-04-24", desktop: 387, mobile: 290 },
|
||||
{ date: "2024-04-25", desktop: 215, mobile: 250 },
|
||||
{ date: "2024-04-26", desktop: 75, mobile: 130 },
|
||||
{ date: "2024-04-27", desktop: 383, mobile: 420 },
|
||||
{ date: "2024-04-28", desktop: 122, mobile: 180 },
|
||||
{ date: "2024-04-29", desktop: 315, mobile: 240 },
|
||||
{ date: "2024-04-30", desktop: 454, mobile: 380 },
|
||||
{ date: "2024-05-01", desktop: 165, mobile: 220 },
|
||||
{ date: "2024-05-02", desktop: 293, mobile: 310 },
|
||||
{ date: "2024-05-03", desktop: 247, mobile: 190 },
|
||||
{ date: "2024-05-04", desktop: 385, mobile: 420 },
|
||||
{ date: "2024-05-05", desktop: 481, mobile: 390 },
|
||||
{ date: "2024-05-06", desktop: 498, mobile: 520 },
|
||||
{ date: "2024-05-07", desktop: 388, mobile: 300 },
|
||||
{ date: "2024-05-08", desktop: 149, mobile: 210 },
|
||||
{ date: "2024-05-09", desktop: 227, mobile: 180 },
|
||||
{ date: "2024-05-10", desktop: 293, mobile: 330 },
|
||||
{ date: "2024-05-11", desktop: 335, mobile: 270 },
|
||||
{ date: "2024-05-12", desktop: 197, mobile: 240 },
|
||||
{ date: "2024-05-13", desktop: 197, mobile: 160 },
|
||||
{ date: "2024-05-14", desktop: 448, mobile: 490 },
|
||||
{ date: "2024-05-15", desktop: 473, mobile: 380 },
|
||||
{ date: "2024-05-16", desktop: 338, mobile: 400 },
|
||||
{ date: "2024-05-17", desktop: 499, mobile: 420 },
|
||||
{ date: "2024-05-18", desktop: 315, mobile: 350 },
|
||||
{ date: "2024-05-19", desktop: 235, mobile: 180 },
|
||||
{ date: "2024-05-20", desktop: 177, mobile: 230 },
|
||||
{ date: "2024-05-21", desktop: 82, mobile: 140 },
|
||||
{ date: "2024-05-22", desktop: 81, mobile: 120 },
|
||||
{ date: "2024-05-23", desktop: 252, mobile: 290 },
|
||||
{ date: "2024-05-24", desktop: 294, mobile: 220 },
|
||||
{ date: "2024-05-25", desktop: 201, mobile: 250 },
|
||||
{ date: "2024-05-26", desktop: 213, mobile: 170 },
|
||||
{ date: "2024-05-27", desktop: 420, mobile: 460 },
|
||||
{ date: "2024-05-28", desktop: 233, mobile: 190 },
|
||||
{ date: "2024-05-29", desktop: 78, mobile: 130 },
|
||||
{ date: "2024-05-30", desktop: 340, mobile: 280 },
|
||||
{ date: "2024-05-31", desktop: 178, mobile: 230 },
|
||||
{ date: "2024-06-01", desktop: 178, mobile: 200 },
|
||||
{ date: "2024-06-02", desktop: 470, mobile: 410 },
|
||||
{ date: "2024-06-03", desktop: 103, mobile: 160 },
|
||||
{ date: "2024-06-04", desktop: 439, mobile: 380 },
|
||||
{ date: "2024-06-05", desktop: 88, mobile: 140 },
|
||||
{ date: "2024-06-06", desktop: 294, mobile: 250 },
|
||||
{ date: "2024-06-07", desktop: 323, mobile: 370 },
|
||||
{ date: "2024-06-08", desktop: 385, mobile: 320 },
|
||||
{ date: "2024-06-09", desktop: 438, mobile: 480 },
|
||||
{ date: "2024-06-10", desktop: 155, mobile: 200 },
|
||||
{ date: "2024-06-11", desktop: 92, mobile: 150 },
|
||||
{ date: "2024-06-12", desktop: 492, mobile: 420 },
|
||||
{ date: "2024-06-13", desktop: 81, mobile: 130 },
|
||||
{ date: "2024-06-14", desktop: 426, mobile: 380 },
|
||||
{ date: "2024-06-15", desktop: 307, mobile: 350 },
|
||||
{ date: "2024-06-16", desktop: 371, mobile: 310 },
|
||||
{ date: "2024-06-17", desktop: 475, mobile: 520 },
|
||||
{ date: "2024-06-18", desktop: 107, mobile: 170 },
|
||||
{ date: "2024-06-19", desktop: 341, mobile: 290 },
|
||||
{ date: "2024-06-20", desktop: 408, mobile: 450 },
|
||||
{ date: "2024-06-21", desktop: 169, mobile: 210 },
|
||||
{ date: "2024-06-22", desktop: 317, mobile: 270 },
|
||||
{ date: "2024-06-23", desktop: 480, mobile: 530 },
|
||||
{ date: "2024-06-24", desktop: 132, mobile: 180 },
|
||||
{ date: "2024-06-25", desktop: 141, mobile: 190 },
|
||||
{ date: "2024-06-26", desktop: 434, mobile: 380 },
|
||||
{ date: "2024-06-27", desktop: 448, mobile: 490 },
|
||||
{ date: "2024-06-28", desktop: 149, mobile: 200 },
|
||||
{ date: "2024-06-29", desktop: 103, mobile: 160 },
|
||||
{ date: "2024-06-30", desktop: 446, mobile: 400 },
|
||||
];
|
||||
|
||||
export function AreaChart() {
|
||||
const { t, i18n } = useTranslation(["common", "dashboard"]);
|
||||
const mutedForeground = useCSSVariable("--muted-foreground");
|
||||
const color1 = useCSSVariable("--chart-1");
|
||||
const color4 = useCSSVariable("--chart-4");
|
||||
|
||||
const [timeRange, setTimeRange] = useState("90d");
|
||||
|
||||
const filteredData = chartData.filter((item) => {
|
||||
const date = new Date(item.date);
|
||||
const referenceDate = new Date("2024-06-30");
|
||||
let daysToSubtract = 90;
|
||||
if (timeRange === "30d") {
|
||||
daysToSubtract = 30;
|
||||
} else if (timeRange === "7d") {
|
||||
daysToSubtract = 7;
|
||||
}
|
||||
const startDate = new Date(referenceDate);
|
||||
startDate.setDate(startDate.getDate() - daysToSubtract);
|
||||
return date >= startDate;
|
||||
});
|
||||
|
||||
const chartConfig = {
|
||||
mobile: {
|
||||
label: t("mobile"),
|
||||
color: color4,
|
||||
},
|
||||
desktop: {
|
||||
label: t("desktop"),
|
||||
color: color1,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const options = [
|
||||
{
|
||||
value: "90d",
|
||||
label: t("common:lastMonths", { count: 3 }),
|
||||
},
|
||||
{
|
||||
value: "30d",
|
||||
label: t("common:lastMonths", { count: 1 }),
|
||||
},
|
||||
{
|
||||
value: "7d",
|
||||
label: t("common:lastDays", { count: 7 }),
|
||||
},
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="items-center gap-4">
|
||||
<View className="items-center">
|
||||
<CardTitle className="text-lg leading-tight">
|
||||
{t("chart.area")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("chart.showing")}</CardDescription>
|
||||
</View>
|
||||
|
||||
<Select
|
||||
value={{
|
||||
value: timeRange,
|
||||
label:
|
||||
options.find((option) => option.value === timeRange)?.label ?? "",
|
||||
}}
|
||||
onValueChange={(option) => setTimeRange(option?.value ?? "90d")}
|
||||
>
|
||||
<SelectTrigger className="w-[160px] rounded-lg sm:ml-auto">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
options.find((option) => option.value === timeRange)?.label ??
|
||||
""
|
||||
}
|
||||
className="text-foreground"
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl" align="start" sideOffset={4}>
|
||||
{options.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
label={option.label}
|
||||
className="rounded-lg"
|
||||
>
|
||||
<Text> {option.label}</Text>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="h-[250px] px-4">
|
||||
<CartesianChart
|
||||
data={filteredData}
|
||||
xKey="date"
|
||||
yKeys={["desktop", "mobile"]}
|
||||
padding={{ bottom: 12 }}
|
||||
domainPadding={{ top: 200 }}
|
||||
xAxis={{
|
||||
font: matchFont({
|
||||
fontFamily: Platform.select({
|
||||
android: "helvetica",
|
||||
ios: "Helvetica Neue",
|
||||
}),
|
||||
fontSize: 12,
|
||||
}),
|
||||
labelOffset: 4,
|
||||
lineWidth: 0,
|
||||
formatXLabel: (value) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString(i18n.language, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
},
|
||||
labelColor: mutedForeground?.toString(),
|
||||
}}
|
||||
>
|
||||
{({ points, chartBounds }) => (
|
||||
<>
|
||||
<StackedArea
|
||||
points={[points.desktop, points.mobile]}
|
||||
y0={chartBounds.bottom}
|
||||
curveType="basis"
|
||||
animate={{ type: "timing" }}
|
||||
areaOptions={({ rowIndex, lowestY, highestY }) => {
|
||||
const color =
|
||||
Object.values(chartConfig)[rowIndex]?.color ?? color1;
|
||||
|
||||
switch (rowIndex) {
|
||||
case 0:
|
||||
return {
|
||||
children: (
|
||||
<LinearGradient
|
||||
start={vec(0, highestY - 50)}
|
||||
end={vec(0, lowestY)}
|
||||
colors={[
|
||||
color?.toString() ?? "",
|
||||
`${color?.toString() ?? ""}4d`,
|
||||
]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
case 1:
|
||||
return {
|
||||
children: (
|
||||
<LinearGradient
|
||||
start={vec(0, highestY - 100)}
|
||||
end={vec(0, lowestY)}
|
||||
colors={[
|
||||
color?.toString() ?? "",
|
||||
`${color?.toString() ?? ""}4d`,
|
||||
]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CartesianChart>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="mx-auto -mt-2 flex-row items-center gap-4">
|
||||
{Object.values(chartConfig).map((config) => (
|
||||
<View key={config.color} className="flex-row items-center gap-2">
|
||||
<View
|
||||
className="size-3 rounded-sm"
|
||||
style={{ backgroundColor: config.color?.toString() }}
|
||||
/>
|
||||
<Text className="text-sm">{config.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
123
apps/mobile/src/modules/home/charts/bar.tsx
Normal file
123
apps/mobile/src/modules/home/charts/bar.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { matchFont } from "@shopify/react-native-skia";
|
||||
import { Platform, View } from "react-native";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import { Bar, CartesianChart } from "victory-native";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardContent,
|
||||
} from "@turbostarter/ui-mobile/card";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
const useChartData = () => {
|
||||
const color1 = useCSSVariable("--chart-1");
|
||||
const color2 = useCSSVariable("--chart-2");
|
||||
const color3 = useCSSVariable("--chart-3");
|
||||
const color4 = useCSSVariable("--chart-4");
|
||||
const color5 = useCSSVariable("--chart-5");
|
||||
|
||||
return [
|
||||
{
|
||||
browser: "chrome",
|
||||
label: "Chrome",
|
||||
visitors: 187,
|
||||
color: color1,
|
||||
},
|
||||
{
|
||||
browser: "safari",
|
||||
label: "Safari",
|
||||
visitors: 200,
|
||||
color: color2,
|
||||
},
|
||||
{
|
||||
browser: "firefox",
|
||||
label: "Firefox",
|
||||
visitors: 275,
|
||||
color: color3,
|
||||
},
|
||||
{ browser: "edge", label: "Edge", visitors: 173, color: color4 },
|
||||
{ browser: "other", label: "Opera", visitors: 90, color: color5 },
|
||||
];
|
||||
};
|
||||
|
||||
export function BarChart() {
|
||||
const { t } = useTranslation(["common", "dashboard"]);
|
||||
const mutedForeground = useCSSVariable("--muted-foreground");
|
||||
|
||||
const chartData = useChartData();
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="gap-0">
|
||||
<CardTitle className="text-lg leading-tight">
|
||||
{t("chart.bar")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("chart.period")}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="h-[200px] px-5">
|
||||
<CartesianChart
|
||||
data={chartData}
|
||||
xKey="browser"
|
||||
yKeys={["visitors"]}
|
||||
domainPadding={{ left: 35, right: 35, bottom: 25 }}
|
||||
padding={{ bottom: 12 }}
|
||||
xAxis={{
|
||||
font: matchFont({
|
||||
fontFamily: Platform.select({
|
||||
android: "helvetica",
|
||||
ios: "Helvetica Neue",
|
||||
}),
|
||||
fontSize: 12,
|
||||
}),
|
||||
lineWidth: 0,
|
||||
formatXLabel: (value) =>
|
||||
chartData.find((data) => data.browser === value)?.label ?? value,
|
||||
labelOffset: 6,
|
||||
labelColor: mutedForeground?.toString(),
|
||||
}}
|
||||
>
|
||||
{({ points, chartBounds }) => {
|
||||
return points.visitors.map((point) => {
|
||||
return (
|
||||
<Bar
|
||||
key={point.xValue}
|
||||
barCount={points.visitors.length}
|
||||
chartBounds={chartBounds}
|
||||
points={[point]}
|
||||
innerPadding={0.15}
|
||||
roundedCorners={{
|
||||
topLeft: 10,
|
||||
topRight: 10,
|
||||
}}
|
||||
color={
|
||||
chartData.find((data) => data.browser === point.xValue)
|
||||
?.color
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}}
|
||||
</CartesianChart>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex-col items-start">
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Text className="font-sans-medium text-sm">
|
||||
{t("chart.trending")}
|
||||
</Text>
|
||||
<Icons.TrendingUp size={16} className="text-foreground" />
|
||||
</View>
|
||||
<Text className="text-muted-foreground text-sm">
|
||||
{t("chart.showing")}
|
||||
</Text>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
168
apps/mobile/src/modules/home/charts/pie.tsx
Normal file
168
apps/mobile/src/modules/home/charts/pie.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import dayjs from "dayjs";
|
||||
import { useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import { Pie, PolarChart } from "victory-native";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import {
|
||||
CardDescription,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
} from "@turbostarter/ui-mobile/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@turbostarter/ui-mobile/select";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
const useChart = () => {
|
||||
const color1 = useCSSVariable("--chart-1");
|
||||
const color2 = useCSSVariable("--chart-2");
|
||||
const color3 = useCSSVariable("--chart-3");
|
||||
const color4 = useCSSVariable("--chart-4");
|
||||
const color5 = useCSSVariable("--chart-5");
|
||||
|
||||
const data = [
|
||||
{ month: "may", desktop: 209, color: color5?.toString() ?? "" },
|
||||
{ month: "april", desktop: 173, color: color4?.toString() ?? "" },
|
||||
{ month: "march", desktop: 237, color: color3?.toString() ?? "" },
|
||||
{ month: "february", desktop: 305, color: color2?.toString() ?? "" },
|
||||
{ month: "january", desktop: 186, color: color1?.toString() ?? "" },
|
||||
];
|
||||
|
||||
const config = {
|
||||
january: {
|
||||
label: dayjs().month(0).format("MMMM"),
|
||||
color: color1,
|
||||
},
|
||||
february: {
|
||||
label: dayjs().month(1).format("MMMM"),
|
||||
color: color2,
|
||||
},
|
||||
march: {
|
||||
label: dayjs().month(2).format("MMMM"),
|
||||
color: color3,
|
||||
},
|
||||
april: {
|
||||
label: dayjs().month(3).format("MMMM"),
|
||||
color: color4,
|
||||
},
|
||||
may: {
|
||||
label: dayjs().month(4).format("MMMM"),
|
||||
color: color5,
|
||||
},
|
||||
};
|
||||
|
||||
return { data, config };
|
||||
};
|
||||
|
||||
export function PieChart() {
|
||||
const { t, i18n } = useTranslation(["common", "dashboard"]);
|
||||
const backgroundColor = useCSSVariable("--background");
|
||||
|
||||
const { data, config } = useChart();
|
||||
const [activeMonth, setActiveMonth] = useState(
|
||||
data.at(-1)?.month ?? "january",
|
||||
);
|
||||
|
||||
const months = data.map((item) => item.month).reverse();
|
||||
|
||||
return (
|
||||
<Card className="w-full pb-2">
|
||||
<CardHeader className="flex-row items-start justify-between gap-0.5">
|
||||
<View>
|
||||
<CardTitle className="text-lg leading-tight">
|
||||
{t("chart.pie")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("chart.period")}</CardDescription>
|
||||
</View>
|
||||
|
||||
<Select
|
||||
value={{
|
||||
value: activeMonth,
|
||||
label: config[activeMonth as keyof typeof config].label,
|
||||
}}
|
||||
onValueChange={(option) => setActiveMonth(option?.value ?? "january")}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="ml-auto rounded-lg"
|
||||
aria-label={t("selectMonth")}
|
||||
>
|
||||
<View
|
||||
className="size-3 shrink-0 rounded-sm"
|
||||
style={{
|
||||
backgroundColor:
|
||||
config[activeMonth as keyof typeof config].color?.toString(),
|
||||
}}
|
||||
/>
|
||||
<SelectValue
|
||||
placeholder={t("selectMonth")}
|
||||
className="text-foreground"
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start" className="rounded-xl" sideOffset={4}>
|
||||
{months.map((month) => {
|
||||
const monthConfig = config[month as keyof typeof config];
|
||||
return (
|
||||
<SelectItem key={month} value={month} label={monthConfig.label}>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<View
|
||||
className={cn("size-3 shrink-0 rounded-sm")}
|
||||
style={{
|
||||
backgroundColor: monthConfig.color?.toString(),
|
||||
}}
|
||||
/>
|
||||
<Text className="text-sm">{monthConfig.label}</Text>
|
||||
</View>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="relative h-[250px]">
|
||||
<PolarChart
|
||||
data={data}
|
||||
labelKey="month"
|
||||
valueKey="desktop"
|
||||
colorKey="color"
|
||||
>
|
||||
<Pie.Chart innerRadius="50%">
|
||||
{({ slice }) => (
|
||||
<>
|
||||
<Pie.Slice key={slice.value} />
|
||||
{activeMonth === slice.label && (
|
||||
<Pie.SliceAngularInset
|
||||
angularInset={{
|
||||
angularStrokeWidth: 8,
|
||||
angularStrokeColor: backgroundColor?.toString() ?? "",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Pie.Chart>
|
||||
</PolarChart>
|
||||
|
||||
<View className="absolute inset-0 items-center justify-center">
|
||||
<Text className="font-sans-bold text-foreground -mt-3 text-4xl">
|
||||
{data
|
||||
.find((data) => data.month === activeMonth)
|
||||
?.desktop.toLocaleString(i18n.language)}
|
||||
</Text>
|
||||
<Text className="text-muted-foreground text-sm leading-none">
|
||||
{t("visitors")}
|
||||
</Text>
|
||||
</View>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
207
apps/mobile/src/modules/organization/account-switcher.tsx
Normal file
207
apps/mobile/src/modules/organization/account-switcher.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@turbostarter/ui-mobile/avatar";
|
||||
import { useBottomSheet } from "@turbostarter/ui-mobile/bottom-sheet";
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@turbostarter/ui-mobile/dropdown-menu";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
import { Skeleton } from "@turbostarter/ui-mobile/skeleton";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth";
|
||||
import { useCustomer } from "~/modules/billing/hooks/use-customer";
|
||||
import { Spinner } from "~/modules/common/spinner";
|
||||
import { organization } from "~/modules/organization/lib/api";
|
||||
|
||||
import { CreateOrganizationBottomSheet } from "./create-organization";
|
||||
|
||||
export const AccountSwitcher = () => {
|
||||
const { t } = useTranslation(["common", "auth", "organization"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const session = authClient.useSession();
|
||||
const organizations = authClient.useListOrganizations();
|
||||
const activeOrganization = authClient.useActiveOrganization();
|
||||
const activeMember = authClient.useActiveMember();
|
||||
const customer = useCustomer();
|
||||
|
||||
const createOrganizationBottomSheet = useBottomSheet();
|
||||
const setActiveOrganization = useMutation({
|
||||
...organization.mutations.setActive,
|
||||
onSuccess: async (_, variables) => {
|
||||
await activeOrganization.refetch();
|
||||
await activeMember.refetch();
|
||||
if (variables?.organizationId || variables?.organizationSlug) {
|
||||
router.replace(pathsConfig.dashboard.organization.index);
|
||||
} else {
|
||||
router.replace(pathsConfig.dashboard.user.index);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu onOpenChange={setOpen} className="flex-1">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn("h-14 flex-row items-center gap-3 self-start px-2", {
|
||||
"bg-accent": open,
|
||||
})}
|
||||
>
|
||||
<Avatar
|
||||
alt={
|
||||
activeOrganization.data?.name ?? session.data?.user.name ?? ""
|
||||
}
|
||||
>
|
||||
{activeOrganization.data ? (
|
||||
<>
|
||||
<AvatarImage
|
||||
source={{ uri: activeOrganization.data.logo ?? undefined }}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
<Text className="text-muted-foreground text-sm">
|
||||
{activeOrganization.data.name.charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
</AvatarFallback>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AvatarImage
|
||||
source={{ uri: session.data?.user.image ?? undefined }}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
<Icons.UserRound
|
||||
size={20}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
</AvatarFallback>
|
||||
</>
|
||||
)}
|
||||
</Avatar>
|
||||
|
||||
<View className="shrink">
|
||||
<Text
|
||||
className="font-sans-medium self-start text-base leading-tight"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{activeOrganization.data
|
||||
? activeOrganization.data.name
|
||||
: t("account.personal")}
|
||||
</Text>
|
||||
{customer.isPending ? (
|
||||
<Skeleton className="mt-1.5 h-3 w-20" />
|
||||
) : (
|
||||
<Text className="text-muted-foreground font-sans leading-tight capitalize">
|
||||
{(customer.data?.plan ?? "free").toLowerCase()}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Icons.ChevronsUpDown
|
||||
width={16}
|
||||
height={16}
|
||||
className="text-muted-foreground ml-2"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent sideOffset={4}>
|
||||
<DropdownMenuItem
|
||||
onPress={() =>
|
||||
setActiveOrganization.mutate({ organizationId: null })
|
||||
}
|
||||
>
|
||||
<Avatar alt={session.data?.user.name ?? ""} className="size-6">
|
||||
<AvatarImage
|
||||
source={{ uri: session.data?.user.image ?? undefined }}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
<Icons.UserRound size={14} className="text-muted-foreground" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<Text>{t("account.personal")}</Text>
|
||||
|
||||
{!activeOrganization.data ? (
|
||||
<Icons.Check
|
||||
className="text-muted-foreground ml-auto"
|
||||
size={16}
|
||||
/>
|
||||
) : (
|
||||
<View className="size-4" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{!!organizations.data?.length && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="text-muted-foreground tracking-tight">{`${t("organizations")} (${organizations.data.length})`}</DropdownMenuLabel>
|
||||
{organizations.data.map((organization) => (
|
||||
<DropdownMenuItem
|
||||
key={organization.id}
|
||||
onPress={() =>
|
||||
setActiveOrganization.mutate({
|
||||
organizationId: organization.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Avatar className="size-6" alt={organization.name}>
|
||||
<AvatarImage
|
||||
source={{ uri: organization.logo ?? undefined }}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
<Text className="text-muted-foreground text-sm">
|
||||
{organization.name.charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<Text numberOfLines={1}>{organization.name}</Text>
|
||||
|
||||
{activeOrganization.data?.id === organization.id ? (
|
||||
<Icons.Check
|
||||
className="text-muted-foreground ml-auto"
|
||||
size={16}
|
||||
/>
|
||||
) : (
|
||||
<View className="size-4" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onPress={createOrganizationBottomSheet.open}>
|
||||
<View className="border-border flex size-6 items-center justify-center rounded-md border bg-transparent">
|
||||
<Icons.Plus size={16} className="text-muted-foreground" />
|
||||
</View>
|
||||
<Text>{t("create.cta")}</Text>
|
||||
<View className="size-4" />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<CreateOrganizationBottomSheet ref={createOrganizationBottomSheet.ref} />
|
||||
{setActiveOrganization.isPending && <Spinner />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
142
apps/mobile/src/modules/organization/create-organization.tsx
Normal file
142
apps/mobile/src/modules/organization/create-organization.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
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 { createOrganizationSchema } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import {
|
||||
BottomSheet,
|
||||
BottomSheetContent,
|
||||
BottomSheetCloseTrigger,
|
||||
BottomSheetOpenTrigger,
|
||||
BottomSheetTitle,
|
||||
BottomSheetDescription,
|
||||
BottomSheetHeader,
|
||||
BottomSheetScrollView,
|
||||
useBottomSheet,
|
||||
} from "@turbostarter/ui-mobile/bottom-sheet";
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import { Form, FormField, FormInput } 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 { organization } from "./lib/api";
|
||||
|
||||
import type { CreateOrganizationPayload } from "@turbostarter/auth";
|
||||
import type { BottomSheetContentRef } from "@turbostarter/ui-mobile/bottom-sheet";
|
||||
|
||||
export const CreateOrganizationBottomSheet = ({
|
||||
children,
|
||||
ref,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
ref?: React.RefObject<BottomSheetContentRef | null>;
|
||||
}) => {
|
||||
const { t } = useTranslation(["common", "organization"]);
|
||||
const sheet = useBottomSheet();
|
||||
|
||||
const activeOrganization = authClient.useActiveOrganization();
|
||||
const activeMember = authClient.useActiveMember();
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(createOrganizationSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
},
|
||||
});
|
||||
|
||||
const getSlug = useMutation(organization.mutations.getSlug);
|
||||
const setActive = useMutation({
|
||||
...organization.mutations.setActive,
|
||||
onSuccess: async () => {
|
||||
await activeOrganization.refetch();
|
||||
await activeMember.refetch();
|
||||
},
|
||||
});
|
||||
const create = useMutation(organization.mutations.create);
|
||||
|
||||
const onSubmit = async (data: CreateOrganizationPayload) => {
|
||||
const { slug } = await getSlug.mutateAsync({
|
||||
query: data,
|
||||
});
|
||||
|
||||
const organization = await create.mutateAsync({
|
||||
...data,
|
||||
slug,
|
||||
});
|
||||
|
||||
await setActive.mutateAsync({ organizationId: organization.id });
|
||||
|
||||
ref?.current?.dismiss();
|
||||
sheet.close();
|
||||
router.replace(pathsConfig.dashboard.organization.index);
|
||||
};
|
||||
|
||||
return (
|
||||
<BottomSheet>
|
||||
{children && (
|
||||
<BottomSheetOpenTrigger asChild>{children}</BottomSheetOpenTrigger>
|
||||
)}
|
||||
|
||||
<BottomSheetContent
|
||||
ref={ref ?? sheet.ref}
|
||||
stackBehavior="replace"
|
||||
name="create-organization"
|
||||
>
|
||||
<BottomSheetScrollView>
|
||||
<BottomSheetHeader>
|
||||
<BottomSheetTitle>{t("create.title")}</BottomSheetTitle>
|
||||
<BottomSheetDescription>
|
||||
{t("create.description")}
|
||||
</BottomSheetDescription>
|
||||
</BottomSheetHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormInput
|
||||
{...field}
|
||||
autoFocus
|
||||
label={t("common:name")}
|
||||
description={t("create.info")}
|
||||
editable={!form.formState.isSubmitting}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<View className="gap-2">
|
||||
<BottomSheetCloseTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Text>{t("cancel")}</Text>
|
||||
</Button>
|
||||
</BottomSheetCloseTrigger>
|
||||
<Button
|
||||
onPress={form.handleSubmit(onSubmit)}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Spin>
|
||||
<Icons.Loader2
|
||||
className="text-primary-foreground"
|
||||
size={16}
|
||||
/>
|
||||
</Spin>
|
||||
) : (
|
||||
<Text>{t("create.cta")}</Text>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</Form>
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheetContent>
|
||||
</BottomSheet>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { router } from "expo-router";
|
||||
import { useTranslation } from "node_modules/@turbostarter/i18n/src/client";
|
||||
import { View } from "react-native";
|
||||
|
||||
import { Trans } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { AuthLayout } from "~/modules/auth/layout/base";
|
||||
import { AuthHeader } from "~/modules/auth/layout/header";
|
||||
|
||||
interface InvitationEmailMismatchProps {
|
||||
readonly invitationId: string;
|
||||
readonly email: string;
|
||||
}
|
||||
|
||||
export const InvitationEmailMismatch = ({
|
||||
invitationId,
|
||||
email,
|
||||
}: InvitationEmailMismatchProps) => {
|
||||
const { t } = useTranslation("organization");
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("invitationId", invitationId);
|
||||
searchParams.set("email", email);
|
||||
searchParams.set(
|
||||
"redirectTo",
|
||||
`${pathsConfig.setup.auth.join}?${searchParams.toString()}`,
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<AuthHeader
|
||||
title={t("invitations.emailMismatch.title")}
|
||||
description={
|
||||
<Trans
|
||||
i18nKey="invitations.emailMismatch.description"
|
||||
ns="organization"
|
||||
values={{ email }}
|
||||
components={{ bold: <Text className="font-sans-medium text-sm" /> }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<View className="gap-2">
|
||||
<Button
|
||||
onPress={() =>
|
||||
router.replace(
|
||||
`${pathsConfig.setup.auth.login}?${searchParams.toString()}`,
|
||||
)
|
||||
}
|
||||
size="lg"
|
||||
>
|
||||
<Text>{t("invitations.emailMismatch.cta", { email })}</Text>
|
||||
</Button>
|
||||
<Button
|
||||
onPress={() => router.replace(pathsConfig.index)}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
>
|
||||
<Text>{t("invitations.emailMismatch.skip")}</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</AuthLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { router } from "expo-router";
|
||||
import { useTranslation } from "node_modules/@turbostarter/i18n/src/client";
|
||||
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { AuthLayout } from "~/modules/auth/layout/base";
|
||||
import { AuthHeader } from "~/modules/auth/layout/header";
|
||||
|
||||
export const InvitationExpired = () => {
|
||||
const { t } = useTranslation("organization");
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<AuthHeader
|
||||
title={t("invitations.expired.title")}
|
||||
description={t("invitations.expired.description")}
|
||||
/>
|
||||
<Button
|
||||
onPress={() => router.replace(pathsConfig.index)}
|
||||
size="lg"
|
||||
variant="outline"
|
||||
>
|
||||
<Text>{t("invitations.expired.cta")}</Text>
|
||||
</Button>
|
||||
</AuthLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import dayjs from "dayjs";
|
||||
import { View } from "react-native";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
} from "@turbostarter/ui-mobile/avatar";
|
||||
import { Badge } from "@turbostarter/ui-mobile/badge";
|
||||
import { Card } from "@turbostarter/ui-mobile/card";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
import type { Invitation } from "@turbostarter/auth";
|
||||
|
||||
interface InvitationSummaryCardProps {
|
||||
readonly invitation: Invitation;
|
||||
readonly organization: {
|
||||
slug: string | null;
|
||||
name: string;
|
||||
logo: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export const InvitationSummaryCard = ({
|
||||
invitation,
|
||||
organization,
|
||||
}: InvitationSummaryCardProps) => {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<Card className="flex-row items-center gap-4 p-4">
|
||||
<Avatar className="size-10" alt={organization.name}>
|
||||
<AvatarImage source={{ uri: organization.logo ?? undefined }} />
|
||||
<AvatarFallback>
|
||||
<Text className="text-muted-foreground text-xl uppercase">
|
||||
{organization.name.charAt(0)}
|
||||
</Text>
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<View>
|
||||
<Text className="font-sans-medium leading-tight" numberOfLines={1}>
|
||||
{organization.name}
|
||||
</Text>
|
||||
<Text className="text-muted-foreground text-sm">
|
||||
{t("expires")} {dayjs(invitation.expiresAt).fromNow()}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Badge variant="outline" className="ml-auto">
|
||||
<Text>{t(invitation.role)}</Text>
|
||||
</Badge>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
141
apps/mobile/src/modules/organization/invitations/invitation.tsx
Normal file
141
apps/mobile/src/modules/organization/invitations/invitation.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { router } from "expo-router";
|
||||
import { View } from "react-native";
|
||||
|
||||
import { Trans, useTranslation } from "@turbostarter/i18n";
|
||||
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 { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth";
|
||||
import { AuthLayout } from "~/modules/auth/layout/base";
|
||||
import { AuthHeader } from "~/modules/auth/layout/header";
|
||||
import { Link } from "~/modules/common/styled";
|
||||
import { organization } from "~/modules/organization/lib/api";
|
||||
import { user } from "~/modules/user/lib/api";
|
||||
|
||||
import { InvitationSummaryCard } from "./invitation-summary-card";
|
||||
|
||||
import type { Invitation as InvitationType } from "@turbostarter/auth";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface InvitationProps {
|
||||
readonly invitation: InvitationType & {
|
||||
inviterEmail: string;
|
||||
};
|
||||
readonly organization: {
|
||||
slug: string | null;
|
||||
name: string;
|
||||
logo: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export const Invitation = (props: InvitationProps) => {
|
||||
const { t } = useTranslation(["common", "organization"]);
|
||||
|
||||
const activeOrganization = authClient.useActiveOrganization();
|
||||
const activeMember = authClient.useActiveMember();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const setActive = useMutation({
|
||||
...organization.mutations.setActive,
|
||||
onSuccess: async () => {
|
||||
await activeOrganization.refetch();
|
||||
await activeMember.refetch();
|
||||
},
|
||||
});
|
||||
const acceptInvitation = useMutation({
|
||||
...organization.mutations.invitations.accept,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(user.queries.invitations.getAll);
|
||||
await setActive.mutateAsync({
|
||||
organizationId: props.invitation.organizationId,
|
||||
});
|
||||
router.replace(pathsConfig.index);
|
||||
},
|
||||
});
|
||||
const rejectInvitation = useMutation({
|
||||
...organization.mutations.invitations.reject,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(user.queries.invitations.getAll);
|
||||
|
||||
router.replace(pathsConfig.index);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<AuthHeader
|
||||
title={t("invitations.invitation.title", {
|
||||
organizationName: props.organization.name,
|
||||
})}
|
||||
description={
|
||||
<Trans
|
||||
i18nKey="invitations.invitation.description"
|
||||
ns="organization"
|
||||
values={{
|
||||
inviterEmail: props.invitation.inviterEmail,
|
||||
organizationName: props.organization.name,
|
||||
}}
|
||||
components={{ bold: <Text className="font-sans-medium text-sm" /> }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<InvitationSummaryCard
|
||||
invitation={props.invitation}
|
||||
organization={props.organization}
|
||||
/>
|
||||
|
||||
<View className="flex-row gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="grow"
|
||||
disabled={rejectInvitation.isPending || acceptInvitation.isPending}
|
||||
onPress={() =>
|
||||
rejectInvitation.mutate({ invitationId: props.invitation.id })
|
||||
}
|
||||
>
|
||||
{rejectInvitation.isPending ? (
|
||||
<Spin>
|
||||
<Icons.Loader2 className="text-foreground" size={16} />
|
||||
</Spin>
|
||||
) : (
|
||||
<Icons.X className="text-foreground" size={16} />
|
||||
)}
|
||||
<Text>{t("reject")}</Text>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="grow"
|
||||
onPress={() =>
|
||||
acceptInvitation.mutate({ invitationId: props.invitation.id })
|
||||
}
|
||||
disabled={rejectInvitation.isPending || acceptInvitation.isPending}
|
||||
>
|
||||
{acceptInvitation.isPending ? (
|
||||
<Spin>
|
||||
<Icons.Loader2 className="text-primary-foreground" size={16} />
|
||||
</Spin>
|
||||
) : (
|
||||
<Icons.Check className="text-primary-foreground" size={16} />
|
||||
)}
|
||||
<Text>{t("accept")}</Text>
|
||||
</Button>
|
||||
</View>
|
||||
<Link
|
||||
href={pathsConfig.index}
|
||||
className="text-muted-foreground font-sans-medium self-center text-sm underline underline-offset-4"
|
||||
>
|
||||
{t("invitations.invitation.skip")}
|
||||
</Link>
|
||||
</AuthLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useEffect } from "react";
|
||||
import { View } from "react-native";
|
||||
import { create } from "zustand";
|
||||
|
||||
import { InvitationStatus, MemberRole } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { useDebounceCallback } from "@turbostarter/shared/hooks";
|
||||
import { pickBy } from "@turbostarter/shared/utils";
|
||||
import {
|
||||
BottomSheet,
|
||||
BottomSheetCloseTrigger,
|
||||
BottomSheetContent,
|
||||
BottomSheetOpenTrigger,
|
||||
BottomSheetView,
|
||||
} from "@turbostarter/ui-mobile/bottom-sheet";
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import { Checkbox } from "@turbostarter/ui-mobile/checkbox";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
import { Input } from "@turbostarter/ui-mobile/input";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
interface FiltersState {
|
||||
filters: Record<string, string | string[] | null>;
|
||||
setFilter: (key: string, value: string | string[] | null) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
const useFiltersStore = create<FiltersState>((set) => ({
|
||||
filters: {},
|
||||
setFilter: (key, value) =>
|
||||
set((state) => ({
|
||||
filters: {
|
||||
...state.filters,
|
||||
[key]: value,
|
||||
},
|
||||
})),
|
||||
reset: () =>
|
||||
set((state) => {
|
||||
const { email } = state.filters;
|
||||
const next: Record<string, string | string[] | null> = {};
|
||||
if (email) next.email = email;
|
||||
return { filters: next };
|
||||
}),
|
||||
}));
|
||||
|
||||
interface InvitationsListFiltersProps {
|
||||
readonly onFiltersChange: (
|
||||
filters: Record<string, string | string[] | null>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const InvitationsListFilters = ({
|
||||
onFiltersChange,
|
||||
}: InvitationsListFiltersProps) => {
|
||||
const { filters } = useFiltersStore();
|
||||
|
||||
const debouncedOnFiltersChange = useDebounceCallback(onFiltersChange, 500);
|
||||
|
||||
useEffect(() => {
|
||||
debouncedOnFiltersChange(filters);
|
||||
}, [filters, debouncedOnFiltersChange]);
|
||||
|
||||
return (
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Search />
|
||||
<AdvancedFilters />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const Search = () => {
|
||||
const { t } = useTranslation("common");
|
||||
const { filters, setFilter } = useFiltersStore();
|
||||
const value = filters.email?.toString() ?? "";
|
||||
|
||||
return (
|
||||
<View className="flex-1 flex-row items-center">
|
||||
<Input
|
||||
className="flex-1 pr-10"
|
||||
placeholder={`${t("searchPlaceholder")}`}
|
||||
value={value}
|
||||
onChangeText={(text) => {
|
||||
setFilter("email", text);
|
||||
}}
|
||||
/>
|
||||
{value.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 z-10"
|
||||
onPress={() => setFilter("email", null)}
|
||||
accessibilityLabel={t("clear")}
|
||||
>
|
||||
<Icons.X size={16} className="text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const AdvancedFilters = () => {
|
||||
const { t } = useTranslation("common");
|
||||
const { reset, filters } = useFiltersStore();
|
||||
|
||||
const advancedFilterCount = Object.keys(pickBy(filters, Boolean)).filter(
|
||||
(key) => key !== "email",
|
||||
).length;
|
||||
|
||||
return (
|
||||
<BottomSheet>
|
||||
<BottomSheetOpenTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="relative size-10 shrink-0"
|
||||
>
|
||||
<Icons.ListFilter size={18} className="text-muted-foreground" />
|
||||
{advancedFilterCount > 0 && (
|
||||
<View className="bg-primary absolute -top-1.5 -right-1.5 size-4 items-center justify-center rounded-full">
|
||||
<Text className="text-primary-foreground text-xs">
|
||||
{advancedFilterCount}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
</BottomSheetOpenTrigger>
|
||||
|
||||
<BottomSheetContent
|
||||
stackBehavior="replace"
|
||||
name="invitations-advanced-filters"
|
||||
>
|
||||
<BottomSheetView className="gap-6">
|
||||
<View className="flex-row gap-2">
|
||||
<StatusFilter />
|
||||
<RoleFilter />
|
||||
</View>
|
||||
<View className="flex-row gap-2">
|
||||
<BottomSheetCloseTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
onPress={() => reset()}
|
||||
className="grow"
|
||||
>
|
||||
<Text>{t("reset")}</Text>
|
||||
</Button>
|
||||
</BottomSheetCloseTrigger>
|
||||
<BottomSheetCloseTrigger asChild>
|
||||
<Button className="grow">
|
||||
<Text>{t("save")}</Text>
|
||||
</Button>
|
||||
</BottomSheetCloseTrigger>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetContent>
|
||||
</BottomSheet>
|
||||
);
|
||||
};
|
||||
|
||||
const StatusFilter = () => {
|
||||
const { t } = useTranslation("common");
|
||||
const { filters, setFilter } = useFiltersStore();
|
||||
|
||||
const selectedStatuses = Array.isArray(filters.status)
|
||||
? filters.status
|
||||
: typeof filters.status === "string"
|
||||
? [filters.status]
|
||||
: [];
|
||||
|
||||
function toggleStatus(status: string, checked: boolean) {
|
||||
const next = checked
|
||||
? Array.from(new Set([...selectedStatuses, status]))
|
||||
: selectedStatuses.filter((s) => s !== status);
|
||||
setFilter("status", next.length ? next : null);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="grow gap-2">
|
||||
<Text className="text-muted-foreground">{t("status")}</Text>
|
||||
{Object.values(InvitationStatus).map((status) => (
|
||||
<View key={status} className="flex-row items-center gap-3">
|
||||
<Checkbox
|
||||
checked={selectedStatuses.includes(status)}
|
||||
onCheckedChange={(value) => toggleStatus(status, value)}
|
||||
/>
|
||||
<Text
|
||||
onPress={() =>
|
||||
toggleStatus(status, !selectedStatuses.includes(status))
|
||||
}
|
||||
className="text-sm"
|
||||
>
|
||||
{t(status)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const RoleFilter = () => {
|
||||
const { t } = useTranslation("common");
|
||||
const { filters, setFilter } = useFiltersStore();
|
||||
|
||||
const selectedRoles = Array.isArray(filters.role)
|
||||
? filters.role
|
||||
: typeof filters.role === "string"
|
||||
? [filters.role]
|
||||
: [];
|
||||
|
||||
function toggleRole(role: string, checked: boolean) {
|
||||
const next = checked
|
||||
? Array.from(new Set([...selectedRoles, role]))
|
||||
: selectedRoles.filter((r) => r !== role);
|
||||
setFilter("role", next.length ? next : null);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="grow gap-2">
|
||||
<Text className="text-muted-foreground">{t("role")}</Text>
|
||||
{Object.values(MemberRole).map((role) => (
|
||||
<View key={role} className="flex-row items-center gap-2">
|
||||
<Checkbox
|
||||
checked={selectedRoles.includes(role)}
|
||||
onCheckedChange={(value) => toggleRole(role, value)}
|
||||
/>
|
||||
<Text
|
||||
onPress={() => toggleRole(role, !selectedRoles.includes(role))}
|
||||
className="text-sm"
|
||||
>
|
||||
{t(role)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user