feat: turbostarter boilerplate

Production-ready Next.js boilerplate with:
- Runtime env validation (fail-fast on missing vars)
- Feature-gated config (S3, Stripe, email, OAuth)
- Docker + Coolify deployment pipeline
- PostgreSQL + pgvector, MinIO S3, Better Auth
- TypeScript strict mode (no ignoreBuildErrors)
- i18n (en/es), AI modules, billing, monitoring

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-02 17:29:12 +00:00
commit 3527e732d4
1618 changed files with 338230 additions and 0 deletions

View 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>
);
};

View 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];

View 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>
);
};

View 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>
);
},
);

View 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";

View 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>
);
};

View 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";

View 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>
);
};

View 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";

View 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 }),
}));

View 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 };

View 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 };

View 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 };

View 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>
);
};

View 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>
);
};

View 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";

View File

@@ -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>
);
};

View 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,
};

View 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} />
</>
);
};

View 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}
/>
);
};

View 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);

View 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,
};

View 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,
};

View File

@@ -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;
};

View 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,
};
};

View File

@@ -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();
}, []);
}

View File

@@ -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]),
);
}

View 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,
};
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 />}
</>
);
};

View 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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View 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>
);
};

View File

@@ -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>
);
};

View File

@@ -0,0 +1,68 @@
import dayjs from "dayjs";
import { Pressable, View } from "react-native";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { Badge } from "@turbostarter/ui-mobile/badge";
import { Skeleton } from "@turbostarter/ui-mobile/skeleton";
import { Text } from "@turbostarter/ui-mobile/text";
import type { Invitation } from "@turbostarter/auth";
export const InvitationListItem = ({
invitation,
}: {
invitation: Invitation;
}) => {
const { t, i18n } = useTranslation("common");
return (
<Pressable className="active:bg-accent dark:active:bg-accent/50 flex-row items-center gap-3 px-4 py-3">
<View className="flex-1">
<Text
className="font-sans-medium shrink text-sm leading-tight"
numberOfLines={1}
>
{invitation.email}
</Text>
<Text
className={cn("text-muted-foreground text-sm", {
"text-destructive": dayjs(invitation.expiresAt).isBefore(dayjs()),
})}
numberOfLines={1}
>
{dayjs().isAfter(invitation.expiresAt) ? t("expired") : t("expires")}{" "}
{new Date(invitation.expiresAt).toLocaleString(i18n.language, {
hour: "2-digit",
minute: "2-digit",
year: "numeric",
month: "numeric",
day: "2-digit",
})}
</Text>
</View>
<View className="ml-auto flex-row items-center gap-1">
<Badge variant="secondary">
<Text>{t(invitation.status)}</Text>
</Badge>
<Badge variant="outline">
<Text>{t(invitation.role)}</Text>
</Badge>
</View>
</Pressable>
);
};
export const InvitationListItemSkeleton = () => {
return (
<View className="flex-row items-center gap-3 px-4 py-3">
<View className="flex-1 gap-1.5">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-4 w-40" />
</View>
<View className="ml-auto flex-row items-center gap-1">
<Skeleton className="h-5 w-12" />
<Skeleton className="h-5 w-12" />
</View>
</View>
);
};

View File

@@ -0,0 +1,111 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { useState } from "react";
import { Fragment } from "react/jsx-runtime";
import { View } from "react-native";
import { FlatList, RefreshControl } from "react-native-gesture-handler";
import { useTranslation } from "@turbostarter/i18n";
import { pickBy } from "@turbostarter/shared/utils";
import { cn } from "@turbostarter/ui";
import { Text } from "@turbostarter/ui-mobile/text";
import { authClient } from "~/lib/auth";
import { organization } from "~/modules/organization/lib/api";
import { InvitationsListFilters } from "./invitations-list-filters";
import {
InvitationListItem,
InvitationListItemSkeleton,
} from "./invitations-list-item";
export const InvitationsList = () => {
const { t } = useTranslation(["common", "organization"]);
const activeOrganization = authClient.useActiveOrganization();
const [filters, setFilters] = useState<
Record<string, string | string[] | null>
>({});
const perPage = 20;
const params = {
id: activeOrganization.data?.id ?? "",
perPage: perPage.toString(),
sort: JSON.stringify([{ id: "expiresAt", desc: true }]),
...pickBy(filters, Boolean),
};
const invitations = useInfiniteQuery({
queryKey: organization.queries.invitations.getAll(params).queryKey,
queryFn: ({ pageParam }) =>
organization.queries.invitations
.getAll({
...params,
page: pageParam.toString(),
})
.queryFn(),
initialPageParam: 1,
getNextPageParam: ({ total }, pages) =>
total > pages.length * perPage ? pages.length + 1 : undefined,
});
const data = invitations.data?.pages.flatMap((page) => page.data) ?? [];
return (
<View className="flex-1 gap-2">
<InvitationsListFilters onFiltersChange={setFilters} />
<View className="border-border flex-1 rounded-md border">
<FlatList
data={data}
renderItem={({ item }) => <InvitationListItem invitation={item} />}
contentContainerClassName={cn({
"flex-1": !data.length,
"items-center justify-center":
!data.length && !invitations.isLoading,
})}
ItemSeparatorComponent={() => (
<View className="bg-border h-px w-full" />
)}
showsVerticalScrollIndicator={false}
onEndReached={() => invitations.fetchNextPage()}
onEndReachedThreshold={0.5}
ListFooterComponent={() =>
invitations.isFetchingNextPage && (
<View>
{Array.from({ length: 10 }).map((_, index) => (
<Fragment key={index}>
<View className="bg-border h-px w-full" />
<InvitationListItemSkeleton />
</Fragment>
))}
</View>
)
}
refreshControl={
<RefreshControl
refreshing={invitations.isRefetching}
onRefresh={() => invitations.refetch()}
tintColorClassName="accent-primary"
colorsClassName="accent-primary"
/>
}
ListEmptyComponent={
invitations.isLoading ? (
<View className="w-full items-start">
{Array.from({ length: 10 }).map((_, index, arr) => (
<Fragment key={index}>
<InvitationListItemSkeleton />
{index !== arr.length - 1 && (
<View className="bg-border h-px w-full" />
)}
</Fragment>
))}
</View>
) : (
<Text>{t("noResults")}</Text>
)
}
/>
</View>
</View>
);
};

View File

@@ -0,0 +1,218 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import dayjs from "dayjs";
import { memo } from "react";
import { Alert, Pressable, View } from "react-native";
import { handle } from "@turbostarter/api/utils";
import { InvitationStatus } from "@turbostarter/auth";
import { useTranslation } from "@turbostarter/i18n";
import {
Alert as AlertBox,
AlertDescription,
AlertTitle,
} from "@turbostarter/ui-mobile/alert";
import {
BottomSheet,
BottomSheetContent,
BottomSheetDescription,
BottomSheetHeader,
BottomSheetOpenTrigger,
BottomSheetScrollView,
BottomSheetTitle,
useBottomSheet,
} from "@turbostarter/ui-mobile/bottom-sheet";
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 { api } from "~/lib/api";
import { authClient } from "~/lib/auth";
import { organization } from "~/modules/organization/lib/api";
import { user } from "~/modules/user/lib/api";
import { InvitationSummaryCard } from "../invitation-summary-card";
import type { Invitation } from "@turbostarter/auth";
export const UserOrganizationInvitationsBanner = memo(() => {
const { t } = useTranslation(["organization", "common"]);
const { data } = useQuery(user.queries.invitations.getAll);
const pendingInvitations = data?.filter(
(invitation) =>
invitation.status === InvitationStatus.PENDING &&
dayjs(invitation.expiresAt).isAfter(dayjs()),
);
if (!pendingInvitations?.length) {
return null;
}
return (
<UserOrganizationInvitationsListBottomSheet
invitations={pendingInvitations}
>
<Pressable>
<AlertBox variant="primary">
<AlertTitle className="pl-0">
{t("invitations.user.banner.title", {
count: pendingInvitations.length,
})}
</AlertTitle>
<AlertDescription className="pl-0">
{t("invitations.user.banner.description")}
</AlertDescription>
</AlertBox>
</Pressable>
</UserOrganizationInvitationsListBottomSheet>
);
});
const UserOrganizationInvitationsListModalItem = ({
invitation,
onSuccess,
}: {
invitation: Invitation;
onSuccess?: () => void;
}) => {
const { t } = useTranslation(["common", "organization"]);
const queryClient = useQueryClient();
const { refetch } = authClient.useListOrganizations();
const { data, isLoading } = useQuery({
...organization.queries.get({ id: invitation.organizationId }),
queryFn: () =>
handle(api.organizations[":id"].$get)({
param: { id: invitation.organizationId },
}),
});
const acceptInvitation = useMutation({
...organization.mutations.invitations.accept,
onSuccess: async () => {
Alert.alert(
t("success"),
t("invitations.accept.success", "", {
organization: data?.organization?.name ?? "",
}),
);
await queryClient.invalidateQueries(user.queries.invitations.getAll);
await refetch();
onSuccess?.();
},
});
const rejectInvitation = useMutation({
...organization.mutations.invitations.reject,
onSuccess: async () => {
Alert.alert(
t("success"),
t("invitations.reject.success", "", {
organization: data?.organization?.name ?? "",
}),
);
await queryClient.invalidateQueries(user.queries.invitations.getAll);
onSuccess?.();
},
});
if (isLoading) {
return <Skeleton className="h-26 w-full" />;
}
if (!data?.organization) {
return null;
}
return (
<View className="gap-2">
<InvitationSummaryCard
invitation={invitation}
organization={data.organization}
/>
<View className="flex-row gap-1">
<Button
variant="outline"
size="sm"
onPress={() =>
rejectInvitation.mutate({ invitationId: invitation.id })
}
disabled={rejectInvitation.isPending || acceptInvitation.isPending}
className="grow"
>
{rejectInvitation.isPending ? (
<Spin>
<Icons.Loader2 className="text-foreground" size={16} />
</Spin>
) : (
<Icons.X size={16} className="text-foreground" />
)}
<Text>{t("reject")}</Text>
</Button>
<Button
size="sm"
onPress={() =>
acceptInvitation.mutate({ invitationId: invitation.id })
}
disabled={rejectInvitation.isPending || acceptInvitation.isPending}
className="grow"
>
{acceptInvitation.isPending ? (
<Spin>
<Icons.Loader2 className="text-primary-foreground" size={16} />
</Spin>
) : (
<Icons.Check size={16} className="text-primary-foreground" />
)}
<Text>{t("accept")}</Text>
</Button>
</View>
</View>
);
};
export const UserOrganizationInvitationsListBottomSheet = ({
children,
invitations,
}: {
invitations: Invitation[];
children: React.ReactNode;
}) => {
const { t } = useTranslation("organization");
const { ref, close } = useBottomSheet();
return (
<BottomSheet>
<BottomSheetOpenTrigger asChild>{children}</BottomSheetOpenTrigger>
<BottomSheetContent ref={ref} name="user-organization-invitations">
<BottomSheetScrollView>
<BottomSheetHeader>
<BottomSheetTitle>
{t("invitations.user.list.title")}
</BottomSheetTitle>
<BottomSheetDescription>
{t("invitations.user.list.description")}
</BottomSheetDescription>
</BottomSheetHeader>
{invitations.map((invitation) => (
<UserOrganizationInvitationsListModalItem
key={invitation.id}
invitation={invitation}
{...(invitations.length === 1
? { onSuccess: () => close() }
: {})}
/>
))}
</BottomSheetScrollView>
</BottomSheetContent>
</BottomSheet>
);
};

View File

@@ -0,0 +1,166 @@
import * as Linking from "expo-linking";
import {
getInvitationsResponseSchema,
getMembersResponseSchema,
} from "@turbostarter/api/schema";
import { handle } from "@turbostarter/api/utils";
import { pathsConfig } from "~/config/paths";
import { api } from "~/lib/api";
import { authClient } from "~/lib/auth";
import type { InferRequestType } from "hono/client";
const KEY = "organizations";
const queries = {
get: (params: { slug: string } | { id: string }) => ({
queryKey: [KEY, params],
queryFn: () =>
authClient.organization.getFullOrganization({
query:
"id" in params
? { organizationId: params.id }
: { organizationSlug: params.slug },
fetchOptions: {
throw: true,
},
}),
}),
members: {
getIsOnlyOwner: ({ id }: { id: string }) => ({
queryKey: [
...queries.get({ id }).queryKey,
"members",
"is-only-owner",
id,
],
queryFn: () =>
handle(api.organizations[":id"].members["is-only-owner"].$get)({
param: { id },
}),
}),
getAll: ({
id,
...query
}: InferRequestType<
(typeof api.organizations)[":id"]["members"]["$get"]
>["query"] & { id: string }) => ({
queryKey: [...queries.get({ id }).queryKey, "members", query],
queryFn: () =>
handle(api.organizations[":id"].members.$get, {
schema: getMembersResponseSchema,
})({
query,
param: {
id,
},
}),
}),
},
invitations: {
get: ({ id }: { id: string }) => ({
queryKey: [...queries.get({ id }).queryKey, "invitations"],
queryFn: () =>
authClient.organization.getInvitation({
query: {
id,
},
fetchOptions: {
throw: true,
},
}),
}),
getAll: ({
id,
...query
}: InferRequestType<
(typeof api.organizations)[":id"]["invitations"]["$get"]
>["query"] & { id: string }) => ({
queryKey: [...queries.get({ id }).queryKey, "invitations", query],
queryFn: () =>
handle(api.organizations[":id"].invitations.$get, {
schema: getInvitationsResponseSchema,
})({
query,
param: {
id,
},
}),
}),
},
};
const mutations = {
getSlug: {
mutationKey: [KEY, "slug"],
mutationFn: (data: InferRequestType<typeof api.organizations.slug.$get>) =>
handle(api.organizations.slug.$get)(data),
},
setActive: {
mutationKey: [KEY, "active"],
mutationFn: (
params: Parameters<typeof authClient.organization.setActive>[0],
) => authClient.organization.setActive(params),
},
delete: {
mutationKey: [KEY, "delete"],
mutationFn: (
params: Parameters<typeof authClient.organization.delete>[0],
) => authClient.organization.delete(params),
},
leave: {
mutationKey: [KEY, "members", "leave"],
mutationFn: (params: Parameters<typeof authClient.organization.leave>[0]) =>
authClient.organization.leave(params),
},
create: {
mutationKey: [KEY, "create"],
mutationFn: (
params: Parameters<typeof authClient.organization.create>[0],
) =>
authClient.organization.create({
...params,
fetchOptions: { throw: true },
}),
},
update: {
mutationKey: [KEY, "update"],
mutationFn: (
params: Parameters<typeof authClient.organization.update>[0],
) => authClient.organization.update(params),
},
invitations: {
accept: {
mutationKey: [KEY, "invitations", "accept"],
mutationFn: (
params: Parameters<typeof authClient.organization.acceptInvitation>[0],
) => authClient.organization.acceptInvitation(params),
},
reject: {
mutationKey: [KEY, "invitations", "reject"],
mutationFn: (
params: Parameters<typeof authClient.organization.rejectInvitation>[0],
) => authClient.organization.rejectInvitation(params),
},
},
members: {
invite: {
mutationKey: [KEY, "members", "invite"],
mutationFn: (
params: Parameters<typeof authClient.organization.inviteMember>[0],
) =>
authClient.organization.inviteMember(params, {
headers: {
"x-url": `${Linking.createURL(pathsConfig.setup.auth.join)}`,
},
}),
},
},
};
export const organization = {
queries,
mutations,
};

View File

@@ -0,0 +1,13 @@
import { z } from "zod";
import { MemberRole } from "@turbostarter/auth";
const roleSchema = z.enum(MemberRole);
export const toMemberRole = (role: unknown): MemberRole => {
if (roleSchema.safeParse(role).success) {
return roleSchema.parse(role);
}
return MemberRole.MEMBER;
};

View File

@@ -0,0 +1,242 @@
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { useMutation } from "@tanstack/react-query";
import { useFieldArray, useForm } from "react-hook-form";
import { Alert, View } from "react-native";
import * as z from "zod";
import {
getAllRolesAtOrBelow,
inviteMemberSchema,
MemberRole,
} from "@turbostarter/auth";
import { useTranslation } from "@turbostarter/i18n";
import {
BottomSheet,
BottomSheetCloseTrigger,
BottomSheetContent,
BottomSheetOpenTrigger,
BottomSheetTitle,
BottomSheetDescription,
BottomSheetHeader,
useBottomSheet,
BottomSheetScrollView,
} from "@turbostarter/ui-mobile/bottom-sheet";
import { Button } from "@turbostarter/ui-mobile/button";
import {
Form,
FormField,
FormInput,
FormSelect,
} from "@turbostarter/ui-mobile/form";
import { Icons } from "@turbostarter/ui-mobile/icons";
import {
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@turbostarter/ui-mobile/select";
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";
export const InviteMemberBottomSheet = ({
children,
}: {
children?: React.ReactNode;
}) => {
const { t } = useTranslation(["common", "organization"]);
const activeOrganization = authClient.useActiveOrganization();
const activeMember = authClient.useActiveMember();
const { ref, close } = useBottomSheet();
const schema = z.object({
invites: z.array(inviteMemberSchema).min(1),
});
const form = useForm<z.infer<typeof schema>>({
resolver: standardSchemaResolver(schema),
defaultValues: {
invites: [{ email: "", role: MemberRole.MEMBER }],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "invites",
});
const hasInvitePermission = authClient.organization.checkRolePermission({
permission: {
invitation: ["create"],
},
role: toMemberRole(activeMember.data?.role),
});
const inviteMember = useMutation(organization.mutations.members.invite);
const onSubmit = async (data: z.infer<typeof schema>) => {
const organizationId = activeOrganization.data?.id;
if (!organizationId) {
return;
}
const results = await Promise.allSettled(
data.invites.map((invite) =>
inviteMember.mutateAsync({ ...invite, organizationId }),
),
);
const failedInvites = results
.map((result, idx) =>
result.status === "rejected" ? data.invites[idx] : null,
)
.filter((val): val is z.infer<typeof inviteMemberSchema> => Boolean(val));
const successCount = data.invites.length - failedInvites.length;
if (successCount > 0) {
Alert.alert(t("members.invite.success", { count: successCount }));
close();
}
if (failedInvites.length > 0) {
form.reset({ invites: failedInvites });
} else {
form.reset(undefined, { keepDefaultValues: true });
}
};
return (
<BottomSheet>
{children && (
<BottomSheetOpenTrigger asChild>{children}</BottomSheetOpenTrigger>
)}
<BottomSheetContent
ref={ref}
stackBehavior="replace"
name="invite-member"
>
<BottomSheetScrollView>
<BottomSheetHeader>
<BottomSheetTitle>{t("members.invite.title")}</BottomSheetTitle>
<BottomSheetDescription>
{t("members.invite.description")}
</BottomSheetDescription>
</BottomSheetHeader>
<Form {...form}>
<View className="gap-3">
{fields.map((field, index) => (
<View key={field.id} className="gap-3">
<FormField
control={form.control}
name={`invites.${index}.email`}
render={({ field }) => (
<FormInput
{...field}
inputMode="email"
autoCapitalize="none"
autoCorrect={false}
autoFocus={index === 0}
label={t("email")}
placeholder="jane@example.com"
editable={
hasInvitePermission && !form.formState.isSubmitting
}
/>
)}
/>
<FormField
control={form.control}
name={`invites.${index}.role`}
render={({ field }) => (
<FormSelect
{...field}
label={t("role")}
value={{ value: field.value, label: t(field.value) }}
onChange={(option) =>
field.onChange(option?.value ?? MemberRole.MEMBER)
}
disabled={
!hasInvitePermission || form.formState.isSubmitting
}
>
<SelectTrigger>
<SelectValue placeholder={t("member")} />
</SelectTrigger>
<SelectContent sideOffset={4}>
{getAllRolesAtOrBelow(
toMemberRole(activeMember.data?.role),
).map((role) => (
<SelectItem key={role} value={role} label={t(role)}>
<Text>{t(role)}</Text>
</SelectItem>
))}
</SelectContent>
</FormSelect>
)}
/>
{fields.length > 1 && (
<View>
<Button
variant="outline"
size="sm"
disabled={
!hasInvitePermission || form.formState.isSubmitting
}
onPress={() => remove(index)}
>
<Icons.Trash size={14} className="text-foreground" />
<Text>{t("remove")}</Text>
</Button>
</View>
)}
</View>
))}
<Button
variant="outline"
size="sm"
onPress={() => append({ email: "", role: MemberRole.MEMBER })}
disabled={!hasInvitePermission || form.formState.isSubmitting}
>
<Icons.Plus size={16} className="text-foreground" />
<Text>{t("addMore")}</Text>
</Button>
</View>
<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
size={16}
className="text-primary-foreground"
/>
</Spin>
) : (
<Text>{t("invite")}</Text>
)}
</Button>
</View>
</Form>
</BottomSheetScrollView>
</BottomSheetContent>
</BottomSheet>
);
};

View File

@@ -0,0 +1,187 @@
import { useEffect } from "react";
import { View } from "react-native";
import { create } from "zustand";
import { 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 { q } = state.filters;
const next: Record<string, string | string[] | null> = {};
if (q) next.q = q;
return { filters: next };
}),
}));
interface MembersListFiltersProps {
readonly onFiltersChange: (
filters: Record<string, string | string[] | null>,
) => void;
}
export const MembersListFilters = ({
onFiltersChange,
}: MembersListFiltersProps) => {
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.q?.toString() ?? "";
return (
<View className="flex-1 flex-row items-center">
<Input
className="flex-1 pr-10"
placeholder={`${t("searchPlaceholder")}`}
value={value}
onChangeText={(text) => {
setFilter("q", text);
}}
/>
{value.length > 0 && (
<Button
variant="ghost"
size="icon"
className="absolute right-1 z-10"
onPress={() => setFilter("q", 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 !== "q",
).length;
return (
<BottomSheet>
<BottomSheetOpenTrigger asChild>
<Button variant="outline" size="icon" className="relative size-10">
<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="members-advanced-filters"
>
<BottomSheetView className="gap-6">
<RoleFilter />
<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 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="gap-2">
<Text className="text-muted-foreground">{t("role")}</Text>
{Object.values(MemberRole).map((role) => (
<View key={role} className="flex-row items-center gap-3">
<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>
);
};

View File

@@ -0,0 +1,67 @@
import { Pressable, 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 { Icons } from "@turbostarter/ui-mobile/icons";
import { Skeleton } from "@turbostarter/ui-mobile/skeleton";
import { Text } from "@turbostarter/ui-mobile/text";
import { authClient } from "~/lib/auth";
import type { Member } from "@turbostarter/auth";
export const MembersListItem = ({ member }: { member: Member }) => {
const { t } = useTranslation("common");
const session = authClient.useSession();
return (
<Pressable className="active:bg-accent dark:active:bg-accent/50 flex-row items-center gap-3 px-4 py-3">
<Avatar alt={member.user.name}>
<AvatarImage source={{ uri: member.user.image }} />
<AvatarFallback>
<Icons.UserRound size={20} className="text-foreground" />
</AvatarFallback>
</Avatar>
<View className="flex-1">
<View className="flex-1 flex-row items-center gap-2">
<Text
className="font-sans-medium shrink text-sm leading-tight"
numberOfLines={1}
>
{member.user.name}
</Text>
{member.userId === session.data?.user.id && (
<Badge variant="outline">
<Text>{t("you")}</Text>
</Badge>
)}
</View>
<Text className="text-muted-foreground text-sm" numberOfLines={1}>
{member.user.email}
</Text>
</View>
<Badge variant="outline" className="ml-auto">
<Text>{t(member.role)}</Text>
</Badge>
</Pressable>
);
};
export const MembersListItemSkeleton = () => {
return (
<View className="flex-row items-center gap-3 px-4 py-3">
<Skeleton className="size-10 rounded-full" />
<View className="flex-1 gap-1.5">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-full max-w-64" />
</View>
<Skeleton className="ml-auto h-5 w-12" />
</View>
);
};

View File

@@ -0,0 +1,107 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { useState } from "react";
import { Fragment } from "react/jsx-runtime";
import { View } from "react-native";
import { FlatList, RefreshControl } from "react-native-gesture-handler";
import { useTranslation } from "@turbostarter/i18n";
import { pickBy } from "@turbostarter/shared/utils";
import { cn } from "@turbostarter/ui";
import { Text } from "@turbostarter/ui-mobile/text";
import { authClient } from "~/lib/auth";
import { organization } from "~/modules/organization/lib/api";
import { MembersListFilters } from "./members-list-filters";
import { MembersListItem, MembersListItemSkeleton } from "./members-list-item";
export const MembersList = () => {
const { t } = useTranslation("common");
const [filters, setFilters] = useState<
Record<string, string | string[] | null>
>({});
const activeOrganization = authClient.useActiveOrganization();
const perPage = 20;
const params = {
id: activeOrganization.data?.id ?? "",
perPage: perPage.toString(),
sort: JSON.stringify([{ id: "user.name", desc: false }]),
...pickBy(filters, Boolean),
};
const members = useInfiniteQuery({
queryKey: organization.queries.members.getAll(params).queryKey,
queryFn: ({ pageParam }) =>
organization.queries.members
.getAll({
...params,
page: pageParam.toString(),
})
.queryFn(),
initialPageParam: 1,
getNextPageParam: ({ total }, pages) =>
total > pages.length * perPage ? pages.length + 1 : undefined,
});
const data = members.data?.pages.flatMap((page) => page.data) ?? [];
return (
<View className="flex-1 gap-2">
<MembersListFilters onFiltersChange={setFilters} />
<View className="border-border flex-1 rounded-md border">
<FlatList
data={data}
renderItem={({ item }) => <MembersListItem member={item} />}
contentContainerClassName={cn({
"flex-1": !data.length,
"items-center justify-center": !data.length && !members.isLoading,
})}
ItemSeparatorComponent={() => (
<View className="bg-border h-px w-full" />
)}
showsVerticalScrollIndicator={false}
onEndReached={() => members.fetchNextPage()}
onEndReachedThreshold={0.5}
ListFooterComponent={() =>
members.isFetchingNextPage && (
<View>
{Array.from({ length: 10 }).map((_, index) => (
<Fragment key={index}>
<View className="bg-border h-px w-full" />
<MembersListItemSkeleton />
</Fragment>
))}
</View>
)
}
refreshControl={
<RefreshControl
refreshing={members.isRefetching}
onRefresh={() => members.refetch()}
tintColorClassName="accent-primary"
colorsClassName="accent-primary"
/>
}
ListEmptyComponent={
members.isLoading ? (
<View className="w-full items-start">
{Array.from({ length: 15 }).map((_, index, arr) => (
<Fragment key={index}>
<MembersListItemSkeleton />
{index !== arr.length - 1 && (
<View className="bg-border h-px w-full" />
)}
</Fragment>
))}
</View>
) : (
<Text>{t("noResults")}</Text>
)
}
/>
</View>
</View>
);
};

View File

@@ -0,0 +1,112 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { router } from "expo-router";
import { View } from "react-native";
import { useTranslation } from "@turbostarter/i18n";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@turbostarter/ui-mobile/avatar";
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 { CreateOrganizationBottomSheet } from "./create-organization";
import { organization } from "./lib/api";
export const OrganizationPicker = () => {
const { t } = useTranslation("organization");
const { data: organizations, isPending } = authClient.useListOrganizations();
const activeOrganization = authClient.useActiveOrganization();
const activeMember = authClient.useActiveMember();
const setActiveOrganization = useMutation({
...organization.mutations.setActive,
onSuccess: async () => {
await activeOrganization.refetch();
await activeMember.refetch();
router.replace(pathsConfig.dashboard.organization.index);
},
});
return (
<View className="w-full gap-4">
{isPending &&
Array.from({ length: 2 }).map((_, index) => (
<Skeleton className="h-30" key={`skeleton-${index}`} />
))}
{organizations?.map((organization) => (
<Button
variant="outline"
key={organization.id}
className="relative flex h-auto w-full items-center justify-between gap-3 px-5 py-4"
onPress={() =>
setActiveOrganization.mutate({
organizationId: organization.id,
})
}
disabled={setActiveOrganization.isPending}
>
<View className="w-full flex-row items-center justify-between gap-3">
<View className="items-start gap-3">
<Avatar alt={organization.name} className="size-16">
<AvatarImage source={{ uri: organization.logo ?? undefined }} />
<AvatarFallback>
<Text className="text-muted-foreground text-2xl">
{organization.name.charAt(0).toUpperCase()}
</Text>
</AvatarFallback>
</Avatar>
<Text
className="text-muted-foreground text-base"
numberOfLines={1}
>
{organization.name}
</Text>
</View>
<View className="mt-2 self-start">
{setActiveOrganization.isPending &&
setActiveOrganization.variables?.organizationId ===
organization.id ? (
<Spin>
<Icons.Loader2 className="text-muted-foreground" size={20} />
</Spin>
) : (
<Icons.ChevronRight
className="text-muted-foreground"
size={20}
/>
)}
</View>
</View>
</Button>
))}
<CreateOrganizationBottomSheet>
<Button
disabled={setActiveOrganization.isPending}
variant="outline"
className="text-muted-foreground h-30 w-full flex-col gap-2 border-dashed"
>
<Icons.Plus
className="text-muted-foreground"
strokeWidth={1.5}
size={28}
/>
<Text className="text-muted-foreground text-base">
{t("create.cta")}
</Text>
</Button>
</CreateOrganizationBottomSheet>
</View>
);
};

View File

@@ -0,0 +1,66 @@
import { useMutation } from "@tanstack/react-query";
import { router } from "expo-router";
import { Alert } 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 { Spinner } from "~/modules/common/spinner";
import { organization } from "~/modules/organization/lib/api";
import { toMemberRole } from "~/modules/organization/lib/utils";
export const DeleteOrganization = () => {
const { t } = useTranslation(["common", "organization"]);
const { data: activeOrganization } = authClient.useActiveOrganization();
const { data: activeMember } = authClient.useActiveMember();
const deleteOrganization = useMutation({
...organization.mutations.delete,
onSuccess: () => {
router.replace(pathsConfig.dashboard.user.index);
},
});
const hasDeletePermission = authClient.organization.checkRolePermission({
permission: {
organization: ["delete"],
},
role: toMemberRole(activeMember?.role),
});
if (!activeOrganization || !hasDeletePermission) {
return null;
}
return (
<>
<SettingsTile
icon={Icons.Trash2}
destructive
onPress={() => {
Alert.alert(t("delete.title"), t("delete.disclaimer"), [
{
text: t("cancel"),
style: "cancel",
},
{
text: t("delete.title"),
style: "destructive",
onPress: () =>
deleteOrganization.mutate({
organizationId: activeOrganization.id,
}),
},
]);
}}
>
<Text>{t("delete.title")}</Text>
</SettingsTile>
{deleteOrganization.isPending && <Spinner />}
</>
);
};

View File

@@ -0,0 +1,69 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { Alert } 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 { Spinner } from "~/modules/common/spinner";
import { organization } from "~/modules/organization/lib/api";
export const LeaveOrganization = () => {
const { t } = useTranslation(["common", "organization"]);
const activeOrganization = authClient.useActiveOrganization();
const listOrganizations = authClient.useListOrganizations();
const activeMember = authClient.useActiveMember();
const { data: isOnlyOwner } = useQuery({
...organization.queries.members.getIsOnlyOwner({
id: activeOrganization.data?.id ?? "",
}),
retry: false,
});
const leaveOrganization = useMutation({
...organization.mutations.leave,
onSuccess: async () => {
await activeOrganization.refetch();
await listOrganizations.refetch();
await activeMember.refetch();
router.replace(pathsConfig.dashboard.user.index);
},
});
const canLeave = !isOnlyOwner?.status;
if (!activeOrganization.data || !canLeave) {
return null;
}
return (
<>
<SettingsTile
icon={Icons.LogOut}
destructive
onPress={() => {
Alert.alert(t("leave.title"), t("leave.disclaimer"), [
{ text: t("cancel"), style: "cancel" },
{
text: t("leave.cta"),
style: "destructive",
onPress: () => {
leaveOrganization.mutate({
organizationId: activeOrganization.data?.id ?? "",
});
},
},
]);
}}
>
<Text>{t("leave.title")}</Text>
</SettingsTile>
{leaveOrganization.isPending && <Spinner />}
</>
);
};

View File

@@ -0,0 +1,100 @@
import { useMutation } from "@tanstack/react-query";
import { View } from "react-native";
import { isKey, useTranslation } from "@turbostarter/i18n";
import { Badge } from "@turbostarter/ui-mobile/badge";
import { Skeleton } from "@turbostarter/ui-mobile/skeleton";
import { Text } from "@turbostarter/ui-mobile/text";
import { authClient } from "~/lib/auth";
import {
AvatarForm,
AvatarFormRemoveButton,
AvatarFormUploadButton,
AvatarFormPreview,
} from "~/modules/common/avatar-form";
import { organization } from "~/modules/organization/lib/api";
import { toMemberRole } from "~/modules/organization/lib/utils";
const OrganizationInfoSkeleton = () => {
return (
<View className="items-center">
<Skeleton className="mb-4 size-28 rounded-full" />
<Skeleton className="mb-3 h-5 w-40" />
<Skeleton className="h-5 w-20" />
</View>
);
};
export const OrganizationInfo = () => {
const { t, i18n } = useTranslation("common");
const activeOrganization = authClient.useActiveOrganization();
const activeMember = authClient.useActiveMember();
const updateOrganization = useMutation(organization.mutations.update);
if (
activeOrganization.isPending ||
!activeOrganization.data ||
activeMember.isPending ||
!activeMember.data
) {
return <OrganizationInfoSkeleton />;
}
const role = activeMember.data.role;
const hasUpdatePermission = authClient.organization.checkRolePermission({
permission: {
organization: ["update"],
},
role: toMemberRole(role),
});
return (
<View className="items-center gap-4">
<AvatarForm
key={activeOrganization.data.id}
id={activeOrganization.data.id}
image={activeOrganization.data.logo}
update={(image) =>
updateOrganization.mutateAsync({
data: {
logo: image ?? "",
},
organizationId: activeOrganization.data?.id ?? "",
})
}
>
<View className="relative">
<AvatarFormPreview
alt={activeOrganization.data.name}
fallback={
<Text className="text-muted-foreground text-4xl leading-none">
{activeOrganization.data.name.charAt(0).toUpperCase()}
</Text>
}
/>
{hasUpdatePermission && (
<AvatarFormUploadButton onUpload={activeOrganization.refetch} />
)}
{hasUpdatePermission && (
<AvatarFormRemoveButton onRemove={activeOrganization.refetch} />
)}
</View>
</AvatarForm>
<View className="items-center gap-2.5">
<Text className="font-sans-semibold text-xl">
{activeOrganization.data.name}
</Text>
{role && (
<Badge variant="outline">
<Text>{isKey(role, i18n, "common") ? t(role) : role}</Text>
</Badge>
)}
</View>
</View>
);
};

View File

@@ -0,0 +1,40 @@
import * as Linking from "expo-linking";
import { pathsConfig } from "~/config/paths";
import { authClient } from "~/lib/auth";
const KEY = "user";
const queries = {
invitations: {
getAll: {
queryKey: [KEY, "invitations"],
queryFn: () =>
authClient.organization.listUserInvitations({
fetchOptions: { throw: true },
}),
},
},
};
const mutations = {
delete: {
mutationKey: [KEY, "delete"],
mutationFn: (params: Parameters<typeof authClient.deleteUser>[0]) =>
authClient.deleteUser(params, {
headers: {
"x-url": Linking.createURL(pathsConfig.dashboard.user.settings.index),
},
}),
},
update: {
mutationKey: [KEY, "update"],
mutationFn: (params: Parameters<typeof authClient.updateUser>[0]) =>
authClient.updateUser(params),
},
};
export const user = {
queries,
mutations,
};

View File

@@ -0,0 +1,61 @@
import { useMutation } from "@tanstack/react-query";
import { Redirect } from "expo-router";
import { View } from "react-native";
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 {
AvatarForm,
AvatarFormPreview,
AvatarFormUploadButton,
AvatarFormRemoveButton,
} from "~/modules/common/avatar-form";
import { user } from "~/modules/user/lib/api";
const AccountInfoSkeleton = () => {
return (
<View className="items-center">
<Skeleton className="mb-4 size-28 rounded-full" />
<Skeleton className="mb-3 h-5 w-40" />
<Skeleton className="h-5 w-64" />
</View>
);
};
export const AccountInfo = () => {
const { data, isPending } = authClient.useSession();
const updateUser = useMutation(user.mutations.update);
if (isPending) {
return <AccountInfoSkeleton />;
}
if (!data?.user) {
return <Redirect href={pathsConfig.setup.auth.login} />;
}
return (
<View className="items-center gap-4">
<AvatarForm
id={data.user.id}
image={data.user.image}
update={(image) => updateUser.mutateAsync({ image })}
>
<View className="relative">
<AvatarFormPreview alt={data.user.name} />
<AvatarFormUploadButton />
<AvatarFormRemoveButton />
</View>
</AvatarForm>
<View className="items-center px-6">
<Text className="font-sans-semibold text-lg">{data.user.name}</Text>
<Text className="text-muted-foreground text-center text-sm">
{data.user.email}
</Text>
</View>
</View>
);
};

View File

@@ -0,0 +1,60 @@
import { useMutation } from "@tanstack/react-query";
import { router } from "expo-router";
import { Alert } 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 { Spinner } from "~/modules/common/spinner";
import { user } from "~/modules/user/lib/api";
export const DeleteAccount = () => {
const { t } = useTranslation(["common", "auth"]);
const deleteUser = useMutation({
...user.mutations.delete,
onSuccess: () => {
Alert.alert(t("account.delete.confirmation.success"), undefined, [
{
onPress: () => {
router.back();
},
},
]);
},
});
return (
<>
<SettingsTile
destructive
icon={Icons.Trash2}
onPress={() => {
Alert.alert(
t("account.delete.title"),
t("account.delete.disclaimer"),
[
{
text: t("cancel"),
style: "cancel",
},
{
text: t("account.delete.confirmation.cta"),
style: "destructive",
onPress: () =>
deleteUser.mutate({
callbackURL: pathsConfig.index,
}),
},
],
);
}}
>
<Text>{t("account.delete.title")}</Text>
</SettingsTile>
{deleteUser.isPending && <Spinner />}
</>
);
};

View File

@@ -0,0 +1,53 @@
import { useMutation } from "@tanstack/react-query";
import { router } from "expo-router";
import { Alert } from "react-native";
import { useTranslation } from "@turbostarter/i18n";
import { Icons } from "@turbostarter/ui-mobile/icons";
import { Text } from "@turbostarter/ui-mobile/text";
import { useSetupSteps } from "~/app/(setup)/steps/_layout";
import { pathsConfig } from "~/config/paths";
import { authClient } from "~/lib/auth";
import { auth } from "~/modules/auth/lib/api";
import { SettingsTile } from "~/modules/common/settings-tile";
import { Spinner } from "~/modules/common/spinner";
export const Logout = () => {
const { t } = useTranslation(["common", "auth"]);
const { reset } = useSetupSteps();
const { refetch } = authClient.useListOrganizations();
const signOut = useMutation({
...auth.mutations.signOut,
onSuccess: async () => {
reset();
await refetch();
router.replace(pathsConfig.index);
},
});
return (
<>
<SettingsTile
icon={Icons.LogOut}
onPress={() => {
Alert.alert(t("logout.cta"), t("logout.confirm"), [
{
text: t("cancel"),
style: "cancel",
},
{
text: t("logout.cta"),
style: "destructive",
onPress: () => signOut.mutate(undefined),
},
]);
}}
>
<Text>{t("logout.cta")}</Text>
</SettingsTile>
{signOut.isPending && <Spinner />}
</>
);
};

View File

@@ -0,0 +1,182 @@
import { useState } from "react";
import { View, Alert, Share } from "react-native";
import { useTranslation } from "@turbostarter/i18n";
import { logger } from "@turbostarter/shared/logger";
import {
BottomSheet,
BottomSheetCloseTrigger,
BottomSheetContent,
BottomSheetView,
useBottomSheet,
BottomSheetTitle,
BottomSheetDescription,
BottomSheetHeader,
} from "@turbostarter/ui-mobile/bottom-sheet";
import { Button } from "@turbostarter/ui-mobile/button";
import { Icons } from "@turbostarter/ui-mobile/icons";
import { Text } from "@turbostarter/ui-mobile/text";
import { useCopyToClipboard } from "~/modules/common/hooks/use-copy-to-clipboard";
import { RequirePassword } from "../require-password";
import { useTwoFactor } from "../use-two-factor";
import { useBackupCodes } from "./use-backup-codes";
import type { PasswordPayload } from "@turbostarter/auth";
import type { BottomSheetContentRef } from "@turbostarter/ui-mobile/bottom-sheet";
interface BackupCodesSheetProps {
readonly ref?: React.RefObject<BottomSheetContentRef | null>;
}
export const BackupCodesSheet = ({ ref: passedRef }: BackupCodesSheetProps) => {
const { t } = useTranslation(["common", "auth"]);
const { ref: bottomSheetRef } = useBottomSheet();
const ref = passedRef ?? bottomSheetRef;
const { codes } = useBackupCodes();
return (
<BottomSheet>
<BottomSheetContent ref={ref} stackBehavior="replace" name="backup-codes">
<BottomSheetView>
<BottomSheetHeader>
<BottomSheetTitle>
{t("account.twoFactor.backupCodes.save.title")}
</BottomSheetTitle>
<BottomSheetDescription>
{t("account.twoFactor.backupCodes.save.description")}
</BottomSheetDescription>
</BottomSheetHeader>
<View className="flex-1 gap-6">
<View className="border-border w-full rounded-md border">
<View className="border-border bg-muted/25 flex-row flex-wrap border-b py-1">
{codes.map((code) => (
<View
key={code}
className="w-1/2 items-center justify-center rounded p-1.5"
>
<Text className="text-center font-mono text-sm">
{code}
</Text>
</View>
))}
</View>
<View className="flex-row justify-end p-2">
<Download />
<Copy />
</View>
</View>
<BottomSheetCloseTrigger asChild>
<Button className="mt-auto">
<Text>{t("continue")}</Text>
</Button>
</BottomSheetCloseTrigger>
</View>
</BottomSheetView>
</BottomSheetContent>
</BottomSheet>
);
};
const Copy = () => {
const { t } = useTranslation("common");
const [_, copy] = useCopyToClipboard();
const [showCheck, setShowCheck] = useState(false);
const { codes } = useBackupCodes();
const handleCopy = async () => {
const success = await copy(codes.join("\n"));
if (!success) {
return;
}
setShowCheck(true);
setTimeout(() => {
setShowCheck(false);
}, 2000);
};
return (
<Button
variant="ghost"
className="h-auto flex-row gap-2 px-3 py-1.5"
onPress={handleCopy}
>
{showCheck ? (
<Icons.Check size={16} className="text-foreground" />
) : (
<Icons.Copy size={16} className="text-foreground" />
)}
<Text>{t("copy")}</Text>
</Button>
);
};
const Download = () => {
const { t } = useTranslation("common");
const { codes } = useBackupCodes();
const handleDownload = async () => {
try {
await Share.share({
message: codes.join("\n"),
});
} catch (error) {
logger.error(error);
Alert.alert(t("error.general"));
}
};
return (
<Button
variant="ghost"
className="h-auto flex-row gap-2 px-3 py-1.5"
onPress={handleDownload}
>
<Icons.Download size={16} className="text-foreground" />
<Text>{t("download")}</Text>
</Button>
);
};
export const BackupCodesTile = () => {
const { t } = useTranslation(["common", "auth"]);
const { enabled } = useTwoFactor();
const { generate } = useBackupCodes();
const { ref } = useBottomSheet();
return (
<>
<View className="border-border flex-row items-center justify-between gap-4 rounded-md border p-4">
<View className="flex-1">
<Text className="font-sans-medium text-sm">
{t("account.twoFactor.backupCodes.title")}
</Text>
<Text className="text-muted-foreground text-sm">
{t("account.twoFactor.backupCodes.description")}
</Text>
</View>
<RequirePassword
onConfirm={async (data: PasswordPayload) => {
await generate.mutateAsync(data);
ref.current?.present();
}}
>
<Button variant="outline" disabled={!enabled}>
<Text>{t("regenerate")}</Text>
</Button>
</RequirePassword>
</View>
<BackupCodesSheet ref={ref} />
</>
);
};

View File

@@ -0,0 +1,26 @@
import { useMutation } from "@tanstack/react-query";
import { create } from "zustand";
import { auth } from "~/modules/auth/lib/api";
const useBackupCodesStore = create<{
codes: string[];
setCodes: (codes: string[]) => void;
}>((set) => ({
codes: [],
setCodes: (codes) => set({ codes }),
}));
export const useBackupCodes = () => {
const { codes, setCodes } = useBackupCodesStore();
const generate = useMutation({
...auth.mutations.twoFactor.backupCodes.generate,
onSuccess: ({ backupCodes }) => {
setCodes(backupCodes);
},
});
const verify = useMutation(auth.mutations.twoFactor.backupCodes.verify);
return { codes, setCodes, generate, verify };
};

View File

@@ -0,0 +1,134 @@
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { memo, useCallback } from "react";
import { useForm } from "react-hook-form";
import { View } from "react-native";
import { passwordSchema } from "@turbostarter/auth";
import { useTranslation } from "@turbostarter/i18n";
import {
BottomSheet,
BottomSheetCloseTrigger,
BottomSheetContent,
BottomSheetDescription,
BottomSheetHeader,
BottomSheetOpenTrigger,
BottomSheetScrollView,
BottomSheetTitle,
useBottomSheet,
} from "@turbostarter/ui-mobile/bottom-sheet";
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 type { PasswordPayload } from "@turbostarter/auth";
import type { BottomSheetContentRef } from "@turbostarter/ui-mobile/bottom-sheet";
interface RequirePasswordProps {
readonly title?: string;
readonly description?: string;
readonly cta?: string;
readonly onConfirm: (data: PasswordPayload) => Promise<void>;
readonly children: React.ReactNode;
readonly ref?: React.RefObject<BottomSheetContentRef>;
}
export const RequirePassword = memo<RequirePasswordProps>(
({ title, description, onConfirm, cta, children, ref: passedRef }) => {
const { t } = useTranslation(["common", "auth"]);
const { ref: bottomSheetRef } = useBottomSheet();
const ref = passedRef ?? bottomSheetRef;
const form = useForm({
resolver: standardSchemaResolver(passwordSchema),
defaultValues: {
password: "",
},
});
const handleSubmit = useCallback(
async (data: PasswordPayload) => {
await onConfirm(data);
form.reset();
ref.current?.close();
},
[onConfirm, form, ref],
);
return (
<BottomSheet>
<BottomSheetOpenTrigger asChild>{children}</BottomSheetOpenTrigger>
<BottomSheetContent
ref={ref}
stackBehavior="replace"
name="require-password"
>
<BottomSheetScrollView>
<BottomSheetHeader>
<BottomSheetTitle>
{title ?? t("account.password.require.title")}
</BottomSheetTitle>
<BottomSheetDescription>
{description ?? t("account.password.require.description")}
</BottomSheetDescription>
</BottomSheetHeader>
<Form {...form}>
<View className="flex-1 gap-6">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormInput
autoFocus
label={t("password")}
secureTextEntry
autoComplete="password"
editable={!form.formState.isSubmitting}
{...field}
/>
</FormItem>
)}
/>
<View className="mt-auto gap-2">
<BottomSheetCloseTrigger asChild>
<Button variant="outline" className="flex-1">
<Text>{t("cancel")}</Text>
</Button>
</BottomSheetCloseTrigger>
<Button
className="flex-1"
disabled={form.formState.isSubmitting}
onPress={form.handleSubmit(handleSubmit)}
>
{form.formState.isSubmitting ? (
<Spin>
<Icons.Loader2
className="text-primary-foreground"
size={16}
/>
</Spin>
) : (
<Text>{cta ?? t("continue")}</Text>
)}
</Button>
</View>
</View>
</Form>
</BottomSheetScrollView>
</BottomSheetContent>
</BottomSheet>
);
},
);
RequirePassword.displayName = "RequirePassword";

View File

@@ -0,0 +1,234 @@
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { View } from "react-native";
import QRCode from "react-qr-code";
import { otpSchema } from "@turbostarter/auth";
import { useTranslation } from "@turbostarter/i18n";
import { ThemeMode } from "@turbostarter/ui";
import {
BottomSheet,
BottomSheetContent,
useBottomSheet,
BottomSheetTitle,
BottomSheetDescription,
BottomSheetHeader,
BottomSheetScrollView,
} from "@turbostarter/ui-mobile/bottom-sheet";
import { Button } from "@turbostarter/ui-mobile/button";
import {
FormField,
FormItem,
FormMessage,
Form,
} 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 { useCopyToClipboard } from "~/modules/common/hooks/use-copy-to-clipboard";
import { useTheme } from "~/modules/common/hooks/use-theme";
import { BackupCodesSheet } from "../backup-codes/backup-codes";
import { RequirePassword } from "../require-password";
import { useTwoFactor } from "../use-two-factor";
import { useTotp } from "./use-totp";
import type { OtpPayload, PasswordPayload } from "@turbostarter/auth";
import type { BottomSheetContentRef } from "@turbostarter/ui-mobile/bottom-sheet";
interface TotpSheetProps {
readonly ref?: React.RefObject<BottomSheetContentRef | null>;
}
export const TotpSheet = ({ ref: passedRef }: TotpSheetProps) => {
const { resolvedTheme } = useTheme();
const { t } = useTranslation(["common", "auth"]);
const { ref: totpSheetRef } = useBottomSheet();
const { ref: backupCodesRef } = useBottomSheet();
const ref = passedRef ?? totpSheetRef;
const { uri, verify } = useTotp();
const form = useForm({
resolver: standardSchemaResolver(otpSchema),
defaultValues: {
code: "",
},
});
const onSubmit = async (data: OtpPayload) => {
await verify.mutateAsync(data);
ref.current?.dismiss();
backupCodesRef.current?.present();
};
return (
<>
<BottomSheet>
<BottomSheetContent ref={ref} stackBehavior="replace" name="totp">
<BottomSheetScrollView>
<BottomSheetHeader>
<BottomSheetTitle>
{t("account.twoFactor.totp.enable.title")}
</BottomSheetTitle>
<BottomSheetDescription>
{t("account.twoFactor.totp.enable.description")}
</BottomSheetDescription>
</BottomSheetHeader>
<View className="w-full flex-1 items-center gap-4">
<Secret />
<QRCode
value={uri}
size={180}
bgColor="transparent"
fgColor={resolvedTheme === ThemeMode.DARK ? "#fff" : "#000"}
/>
<Form {...form}>
<View className="w-full flex-1 items-center gap-6">
<FormField
control={form.control}
name="code"
render={({ field }) => (
<FormItem>
<InputOTP
maxLength={6}
autoFocus
value={field.value}
onChange={field.onChange}
onComplete={() => form.handleSubmit(onSubmit)()}
render={({ slots }) => (
<InputOTPGroup>
{slots.map((slot, index) => (
<InputOTPSlot
key={index}
index={index}
max={6}
{...slot}
/>
))}
</InputOTPGroup>
)}
/>
<FormMessage />
</FormItem>
)}
/>
<View className="mt-auto w-full gap-2">
<Button
variant="outline"
className="flex-1"
onPress={() => ref.current?.close()}
>
<Text>{t("close")}</Text>
</Button>
<Button
className="flex-1"
disabled={form.formState.isSubmitting}
onPress={form.handleSubmit(onSubmit)}
>
{form.formState.isSubmitting ? (
<Spin>
<Icons.Loader2
className="text-primary-foreground"
size={16}
/>
</Spin>
) : (
<Text>{t("continue")}</Text>
)}
</Button>
</View>
</View>
</Form>
</View>
</BottomSheetScrollView>
</BottomSheetContent>
</BottomSheet>
<BackupCodesSheet ref={backupCodesRef} />
</>
);
};
const Secret = () => {
const { uri } = useTotp();
const [, copy] = useCopyToClipboard();
const [showCheck, setShowCheck] = useState(false);
const secret = useMemo(() => {
return uri ? new URL(uri).searchParams.get("secret") : null;
}, [uri]);
const handleCopy = async () => {
const success = await copy(secret ?? "");
if (!success) {
return;
}
setShowCheck(true);
setTimeout(() => {
setShowCheck(false);
}, 2000);
};
return (
<View className="flex-row flex-wrap items-center gap-2 px-5">
<Text>
<Text className="text-muted-foreground text-center">{secret}</Text>
<Text onPress={handleCopy} className="pl-2.5">
{showCheck ? (
<Icons.Check className="text-foreground" size={12} />
) : (
<Icons.Copy className="text-foreground" size={12} />
)}
</Text>
</Text>
</View>
);
};
export const TotpTile = () => {
const { ref } = useBottomSheet();
const { t } = useTranslation(["common", "auth"]);
const { enabled } = useTwoFactor();
const { setUri, getUri } = useTotp();
const onEdit = async (data: PasswordPayload) => {
const response = await getUri.mutateAsync(data);
setUri(response.totpURI);
ref.current?.present();
};
return (
<>
<View className="border-border flex-row items-center justify-between gap-4 rounded-md border p-4">
<View className="flex-1">
<Text className="font-sans-medium text-sm">
{t("account.twoFactor.totp.title")}
</Text>
<Text className="text-muted-foreground text-sm">
{t("account.twoFactor.totp.description")}
</Text>
</View>
<RequirePassword onConfirm={onEdit}>
<Button variant="outline" disabled={!enabled}>
<Text>{enabled ? t("edit") : t("add")}</Text>
</Button>
</RequirePassword>
</View>
<TotpSheet ref={ref} />
</>
);
};

View File

@@ -0,0 +1,44 @@
import { useMutation } from "@tanstack/react-query";
import { Alert } from "react-native";
import { create } from "zustand";
import { useTranslation } from "@turbostarter/i18n";
import { auth } from "~/modules/auth/lib/api";
import { useBackupCodes } from "../backup-codes/use-backup-codes";
const useUri = create<{
uri: string;
setUri: (uri: string) => void;
}>((set) => ({
uri: "",
setUri: (uri) => set({ uri }),
}));
export const useTotp = () => {
const { t } = useTranslation(["auth"]);
const { uri, setUri } = useUri();
const { codes, setCodes } = useBackupCodes();
const getUri = useMutation({
...auth.mutations.twoFactor.totp.getUri,
onSuccess: async ({ totpURI }, data) => {
setUri(totpURI);
if (!codes.length) {
const backupCodes =
await auth.mutations.twoFactor.backupCodes.generate.mutationFn(data);
setCodes(backupCodes.backupCodes);
}
},
});
const verify = useMutation({
...auth.mutations.twoFactor.totp.verify,
onSuccess: () => {
Alert.alert(t("account.twoFactor.totp.success"));
},
});
return { uri, setUri, verify, getUri };
};

View File

@@ -0,0 +1,25 @@
import { useMutation } from "@tanstack/react-query";
import { Alert } from "react-native";
import { useTranslation } from "@turbostarter/i18n";
import { authClient } from "~/lib/auth";
import { auth } from "~/modules/auth/lib/api";
export const useTwoFactor = () => {
const { data } = authClient.useSession();
const { t } = useTranslation(["auth", "common"]);
const enabled = data?.user.twoFactorEnabled ?? false;
const enable = useMutation(auth.mutations.twoFactor.enable);
const disable = useMutation({
...auth.mutations.twoFactor.disable,
onSuccess: () => {
Alert.alert(t("account.twoFactor.disable.success"));
},
});
return { enabled, enable, disable };
};

View File

@@ -0,0 +1,60 @@
import { useTranslation } from "@turbostarter/i18n";
import {
BottomSheet,
BottomSheetCloseTrigger,
BottomSheetContent,
BottomSheetOpenTrigger,
BottomSheetTitle,
BottomSheetDescription,
BottomSheetView,
useBottomSheet,
BottomSheetHeader,
} from "@turbostarter/ui-mobile/bottom-sheet";
import { Button } from "@turbostarter/ui-mobile/button";
import { LocaleCustomizer, LocaleIcon } from "@turbostarter/ui-mobile/i18n";
import { Icons } from "@turbostarter/ui-mobile/icons";
import { Text } from "@turbostarter/ui-mobile/text";
import { useI18nConfig } from "~/lib/providers/i18n";
import { SettingsTile } from "~/modules/common/settings-tile";
export const I18nSettings = () => {
const { t, i18n } = useTranslation("common");
const { setConfig } = useI18nConfig();
const { ref } = useBottomSheet();
const Icon =
i18n.language && i18n.language in LocaleIcon
? LocaleIcon[i18n.language as keyof typeof LocaleIcon]
: null;
return (
<BottomSheet>
<BottomSheetOpenTrigger asChild>
<SettingsTile icon={Icons.Languages}>
<Text>{t("language.label")}</Text>
{Icon && <Icon className="size-4.5" />}
</SettingsTile>
</BottomSheetOpenTrigger>
<BottomSheetContent ref={ref} stackBehavior="replace" name="i18n">
<BottomSheetView>
<BottomSheetHeader>
<BottomSheetTitle>{t("language.label")}</BottomSheetTitle>
<BottomSheetDescription>
{t("language.description")}
</BottomSheetDescription>
</BottomSheetHeader>
<LocaleCustomizer onChange={(locale) => setConfig({ locale })} />
<BottomSheetCloseTrigger asChild>
<Button>
<Text>{t("save")}</Text>
</Button>
</BottomSheetCloseTrigger>
</BottomSheetView>
</BottomSheetContent>
</BottomSheet>
);
};

View File

@@ -0,0 +1,84 @@
import { View } from "react-native";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import {
BottomSheet,
BottomSheetCloseTrigger,
BottomSheetContent,
BottomSheetOpenTrigger,
BottomSheetView,
BottomSheetTitle,
BottomSheetDescription,
BottomSheetHeader,
} from "@turbostarter/ui-mobile/bottom-sheet";
import { Button } from "@turbostarter/ui-mobile/button";
import { Icons } from "@turbostarter/ui-mobile/icons";
import { Text } from "@turbostarter/ui-mobile/text";
import { MODE_ICONS, ThemeCustomizer } from "@turbostarter/ui-mobile/theme";
import { appConfig } from "~/config/app";
import { useTheme } from "~/modules/common/hooks/use-theme";
import { SettingsTile } from "~/modules/common/settings-tile";
import type { ThemeConfig } from "@turbostarter/ui";
export const ThemeSettings = () => {
const { t } = useTranslation("common");
const { config, setConfig, resolvedTheme } = useTheme();
const onChange = (config: ThemeConfig) => {
setConfig(config);
};
return (
<BottomSheet>
<BottomSheetOpenTrigger asChild>
<SettingsTile icon={MODE_ICONS[config.mode]}>
<Text>{t("theme.title")}</Text>
<View
className={cn(
"bg-primary flex size-4.5 shrink-0 items-center justify-center rounded-full",
)}
/>
</SettingsTile>
</BottomSheetOpenTrigger>
<BottomSheetContent stackBehavior="replace" name="theme-settings">
<BottomSheetView>
<View className="flex-row items-start">
<BottomSheetHeader>
<BottomSheetTitle>
{t("theme.customization.title")}
</BottomSheetTitle>
<BottomSheetDescription>
{t("theme.customization.description")}
</BottomSheetDescription>
</BottomSheetHeader>
<Button
variant="ghost"
size="icon"
className="ml-auto rounded-[0.5rem]"
onPress={() => onChange(appConfig.theme)}
>
<Icons.Undo2 className="text-foreground" width={20} height={20} />
</Button>
</View>
<ThemeCustomizer
config={config}
onChange={onChange}
resolvedTheme={resolvedTheme}
/>
<BottomSheetCloseTrigger asChild>
<Button>
<Text>{t("save")}</Text>
</Button>
</BottomSheetCloseTrigger>
</BottomSheetView>
</BottomSheetContent>
</BottomSheet>
);
};