Compare commits

...

5 Commits

119 changed files with 6846 additions and 78 deletions

View File

@ -112,7 +112,24 @@ export default [
"@typescript-eslint/no-misused-promises": ["error", {
"checksVoidReturn": false
}
]
],
'@typescript-eslint/naming-convention': ['error',
{
selector: 'enum',
format: ['UPPER_CASE', 'PascalCase'],
},
{
selector: 'variable',
format: ['camelCase', 'PascalCase', 'UPPER_CASE'],
},
{
selector: 'function',
format: ['camelCase', 'PascalCase'],
},
{
selector: 'typeLike',
format: ['PascalCase'],
}],
},
},
{

View File

@ -15,14 +15,23 @@
"@graphql-codegen/typescript": "4.1.6",
"@graphql-codegen/typescript-operations": "4.6.1",
"@graphql-codegen/typescript-react-apollo": "4.3.3",
"@hello-pangea/dnd": "18.0.1",
"@hookform/resolvers": "5.2.1",
"@pbe/react-yandex-maps": "1.2.5",
"@radix-ui/react-alert-dialog": "1.1.14",
"@radix-ui/react-avatar": "1.1.10",
"@radix-ui/react-dialog": "1.1.14",
"@radix-ui/react-dropdown-menu": "2.1.15",
"@radix-ui/react-label": "2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-select": "2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-switch": "1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "1.2.7",
"@tanstack/react-table": "8.21.3",
"apollo-upload-client": "18.0.1",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"graphql": "16.11.0",
@ -35,7 +44,9 @@
"react": "19.1.1",
"react-dom": "19.1.1",
"react-hook-form": "7.61.1",
"react-icons": "5.5.0",
"sonner": "2.0.6",
"subscriptions-transport-ws": "0.11.0",
"tailwind-merge": "3.3.1",
"zod": "4.0.14",
"zustand": "5.0.7"
@ -45,6 +56,7 @@
"@next/eslint-plugin-next": "15.4.5",
"@parcel/watcher": "^2.5.1",
"@tailwindcss/postcss": "4.1.11",
"@types/apollo-upload-client": "18.0.0",
"@types/node": "22.17.0",
"@types/react": "19.1.9",
"@types/react-dom": "19.1.7",

View File

@ -0,0 +1,20 @@
import { getTranslations } from 'next-intl/server';
import { ChangeChatSettings } from '@/components/features/chat/settings/ChangeChatSettings';
import { NO_INDEX_PAGE } from '@/libs/constants/seo.constants';
import type { Metadata } from 'next';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('dashboard.chat.header');
return {
title: t('heading'),
description: t('description'),
...NO_INDEX_PAGE,
};
}
const ChatSettingsPage = () => <ChangeChatSettings />;
export default ChatSettingsPage;

View File

@ -0,0 +1,20 @@
import { getTranslations } from 'next-intl/server';
import { FollowersTable } from '@/components/features/follow/table/FollowersTable';
import { NO_INDEX_PAGE } from '@/libs/constants/seo.constants';
import type { Metadata } from 'next';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('dashboard.followers.header');
return {
title: t('heading'),
description: t('description'),
...NO_INDEX_PAGE,
};
}
const FollowersPage = () => <FollowersTable />;
export default FollowersPage;

View File

@ -0,0 +1,20 @@
import { getTranslations } from 'next-intl/server';
import { KeysSettings } from '@/components/features/keys/settings/KeysSettings';
import { NO_INDEX_PAGE } from '@/libs/constants/seo.constants';
import type { Metadata } from 'next';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('dashboard.keys.header');
return {
title: t('heading'),
description: t('description'),
...NO_INDEX_PAGE,
};
}
const KeysSettingsPage = () => <KeysSettings />;
export default KeysSettingsPage;

View File

@ -0,0 +1,20 @@
import { getTranslations } from 'next-intl/server';
import { PlansTable } from '@/components/features/sponsorship/plan/table/PlansTable';
import { NO_INDEX_PAGE } from '@/libs/constants/seo.constants';
import type { Metadata } from 'next';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('dashboard.plans.header');
return {
title: t('heading'),
description: t('description'),
...NO_INDEX_PAGE,
};
}
const PlansPage = () => <PlansTable />;
export default PlansPage;

View File

@ -1,19 +1,21 @@
import { getTranslations } from 'next-intl/server';
import { UserSettings } from '@/components/features/user/UserSettings';
import type { Metadata } from 'next';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('layout.header.headerMenu.profileMenu');
const t = await getTranslations('dashboard.settings.header');
return {
title: t('dashboard'),
title: t('heading'),
description: t('description'),
robots: { index: false, follow: false },
};
}
const DashboardSettings = () => (
<div>
DashboardSettings page
</div>
<UserSettings />
);
export default DashboardSettings;

View File

@ -0,0 +1,20 @@
import { getTranslations } from 'next-intl/server';
import { SponsorsTable } from '@/components/features/sponsorship/subscription/table/SponsorsTable';
import { NO_INDEX_PAGE } from '@/libs/constants/seo.constants';
import type { Metadata } from 'next';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('dashboard.sponsors.header');
return {
title: t('heading'),
description: t('description'),
...NO_INDEX_PAGE,
};
}
const SponsorsPage = () => <SponsorsTable />;
export default SponsorsPage;

View File

@ -0,0 +1,20 @@
import { getTranslations } from 'next-intl/server';
import { TransactionsTable } from '@/components/features/sponsorship/transactions/table/TransactionsTable';
import { NO_INDEX_PAGE } from '@/libs/constants/seo.constants';
import type { Metadata } from 'next';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('dashboard.transactions.header');
return {
title: t('heading'),
description: t('description'),
...NO_INDEX_PAGE,
};
}
const TransactionsPage = () => <TransactionsTable />;
export default TransactionsPage;

View File

@ -0,0 +1,18 @@
import { getTranslations } from 'next-intl/server';
import { DeactivateForm } from '@/components/features/auth/forms/DeactivateForm';
import { NO_INDEX_PAGE } from '@/libs/constants/seo.constants';
import type { Metadata } from 'next';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('auth.deactivate');
return {
title: t('heading'),
...NO_INDEX_PAGE,
};
}
const DeactivatePage = () => <DeactivateForm />;
export default DeactivatePage;

View File

@ -2,11 +2,13 @@ import { Geist } from 'next/font/google';
import { NextIntlClientProvider } from 'next-intl';
import { getLocale, getMessages } from 'next-intl/server';
import { ColorSwitcher } from '@/components/ui/elements/ColorSwitcher';
import ApolloClientProvider from '@/providers/ApolloClientProvider';
import { ThemeProvider } from '@/providers/ThemeProvider';
import { ToastProvider } from '@/providers/ToastProvider';
import '../styles/globals.css';
import '../styles/themes.css';
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
@ -31,6 +33,8 @@ const RootLayout = async ({
return (
<html suppressHydrationWarning lang={locale}>
<body className={geistSans.variable}>
<ColorSwitcher />
<ApolloClientProvider>
<NextIntlClientProvider messages={messages}>
<ThemeProvider

View File

@ -0,0 +1,179 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button } from '@/components/ui/common/Button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from '@/components/ui/common/Form';
import { Input } from '@/components/ui/common/Input';
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from '@/components/ui/common/InputOTP';
import { useDeactivateAccountMutation } from '@/graphql/generated/output';
import { useAuth } from '@/hooks/useAuth';
import { type TypeDeactivateSchema, deactivateSchema } from '@/schemas/auth/deactivate.schema';
import AuthWrapper from '../AuthWrapper';
export const DeactivateForm = () => {
const t = useTranslations('auth.deactivate');
const { exit } = useAuth();
const router = useRouter();
const [isShowConfirm, setIsShowConfirm] = useState(false);
const form = useForm<TypeDeactivateSchema>({
resolver: zodResolver(deactivateSchema),
defaultValues: {
email: '',
password: '',
},
});
const [deactivate, { loading: isLoadingDeactivate }] = useDeactivateAccountMutation({
onCompleted(data) {
if (data.deactivateAccount.message) {
setIsShowConfirm(true);
} else {
exit();
toast.success(t('successMessage'));
router.push('/');
}
},
onError() {
toast.error(t('errorMessage'));
},
});
const { isValid } = form.formState;
function onSubmit(data: TypeDeactivateSchema) {
void deactivate({ variables: { data } });
}
return (
<AuthWrapper
backButtonHref="/dashboard/settings"
backButtonLabel={t('backButtonLabel')}
heading={t('heading')}
>
<Form {...form}>
<form
className="grid gap-y-3"
onSubmit={form.handleSubmit(onSubmit)}
>
{isShowConfirm
? (
<FormField
control={form.control}
name="pin"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('pinLabel')}
</FormLabel>
<FormControl>
<InputOTP maxLength={6} {...field}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormDescription>
{t('pinDescription')}
</FormDescription>
</FormItem>
)}
/>
)
: (
<>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('emailLabel')}
</FormLabel>
<FormControl>
<Input
disabled={isLoadingDeactivate}
placeholder="john.doe@example.com"
{...field}
/>
</FormControl>
<FormDescription>
{t('emailDescription')}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('passwordLabel')}
</FormLabel>
<FormControl>
<Input
disabled={isLoadingDeactivate}
placeholder="********"
type="password"
{...field}
/>
</FormControl>
<FormDescription>
{t('passwordDescription')}
</FormDescription>
</FormItem>
)}
/>
</>
)}
<Button
className="mt-2 w-full"
disabled={!isValid || isLoadingDeactivate}
>
{t('submitButton')}
</Button>
</form>
</Form>
</AuthWrapper>
);
};

View File

@ -0,0 +1,132 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Form, FormField } from '@/components/ui/common/Form';
import { Heading } from '@/components/ui/elements/Heading';
import {
ToggleCard,
ToggleCardSkeleton,
} from '@/components/ui/elements/ToggleCard';
import { useChangeChatSettingsMutation } from '@/graphql/generated/output';
import { useCurrent } from '@/hooks/useCurrent';
import { changeChatSettingsSchema } from '@/schemas/chat/change-chat.settings.schema';
import type { TypeChangeChatSettingsSchema } from '@/schemas/chat/change-chat.settings.schema';
export const ChangeChatSettings = () => {
const t = useTranslations('dashboard.chat');
const { user, isLoadingProfile } = useCurrent();
const form = useForm<TypeChangeChatSettingsSchema>({
resolver: zodResolver(changeChatSettingsSchema),
values: {
isChatEnable: user?.stream.isChatEnable ?? false,
isChatFollowersOnly: user?.stream.isChatFollowersOnly ?? false,
isChatPremiumFollowersOnly:
user?.stream.isChatPremiumFollowersOnly ?? false,
},
});
const [update, { loading: isLoadingUpdate }] = useChangeChatSettingsMutation({
onCompleted(data) {
toast.success(t('successMessage'));
},
onError() {
toast.error(t('errorMessage'));
},
});
function onChange(
field: keyof TypeChangeChatSettingsSchema,
value: boolean,
) {
form.setValue(field, value);
void update({
variables: {
data: { ...form.getValues(), [field]: value },
},
});
}
return (
<div className="lg:px-10">
<Heading
description={t('header.description')}
size="lg"
title={t('header.heading')}
/>
<div className="mt-3 space-y-6">
{isLoadingProfile
? Array.from({ length: 3 }).map((_, index) => (
// eslint-disable-next-line react/no-array-index-key
<ToggleCardSkeleton key={index} />
))
: (
<Form {...form}>
<FormField
control={form.control}
name="isChatEnable"
render={({ field }) => (
<ToggleCard
description={t('isChatEnabled.description')}
heading={t('isChatEnabled.heading')}
isDisabled={isLoadingUpdate}
value={field.value}
onChange={(value) => { onChange('isChatEnable', value); }}
/>
)}
/>
<FormField
control={form.control}
name="isChatFollowersOnly"
render={({ field }) => (
<ToggleCard
description={t(
'isChatFollowersOnly.description',
)}
heading={t('isChatFollowersOnly.heading')}
isDisabled={isLoadingUpdate}
value={field.value}
onChange={(value) => { onChange('isChatFollowersOnly', value); }}
/>
)}
/>
<FormField
control={form.control}
name="isChatPremiumFollowersOnly"
render={({ field }) => (
<ToggleCard
description={t(
'isChatPremiumFollowersOnly.description',
)}
heading={t(
'isChatPremiumFollowersOnly.heading',
)}
isDisabled={
isLoadingUpdate || !user?.isVerified
}
value={field.value}
onChange={(value) => {
onChange(
'isChatPremiumFollowersOnly',
value,
);
}}
/>
)}
/>
</Form>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,106 @@
'use client';
import { MoreHorizontal, User } from 'lucide-react';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/common/Button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/common/DropdownMenu';
import { ChannelAvatar } from '@/components/ui/elements/ChannelAvatar';
import { ChannelVerified } from '@/components/ui/elements/ChannelVerified';
import { DataTable, DataTableSkeleton } from '@/components/ui/elements/DataTable';
import { Heading } from '@/components/ui/elements/Heading';
import {
type FindMyFollowersQuery,
useFindMyFollowersQuery,
} from '@/graphql/generated/output';
import { formatDate } from '@/utils/format-date';
import type { ColumnDef } from '@tanstack/react-table';
const FollowerCell = ({ row }: any) => (
<div className="flex items-center gap-x-2">
<ChannelAvatar channel={row.original.follower} size="sm" />
<h2>
{row.original.follower.name}
</h2>
{row.original.follower.isVerified ? <ChannelVerified size="sm" /> : null}
</div>
);
const FollowerActionCell = ({ row }: any) => {
const t = useTranslations('dashboard.followers');
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="size-8 p-0" variant="ghost">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right">
<Link
href={`/${row.original.follower.name}`}
target="_blank"
>
<DropdownMenuItem>
<User className="mr-2 size-4" />
{t('columns.viewChannel')}
</DropdownMenuItem>
</Link>
</DropdownMenuContent>
</DropdownMenu>
);
};
export const FollowersTable = () => {
const t = useTranslations('dashboard.followers');
const { data, loading: isLoadingFollowers } = useFindMyFollowersQuery();
const followers = data?.findMyFollowers ?? [];
const followersColumns: ColumnDef<FindMyFollowersQuery['findMyFollowers'][0]>[] = [
{
accessorKey: 'createdAt',
header: t('columns.date'),
cell: ({ row }) => formatDate(row.original.createdAt),
},
{
accessorKey: 'follower',
header: t('columns.user'),
cell: FollowerCell,
},
{
accessorKey: 'actions',
header: t('columns.actions'),
cell: FollowerActionCell,
},
];
return (
<div className="lg:px-10">
<Heading
description={t('header.description')}
size="lg"
title={t('header.heading')}
/>
<div className="mt-5">
{
isLoadingFollowers
? <DataTableSkeleton />
: <DataTable columns={followersColumns} data={followers} />
}
</div>
</div>
);
};

View File

@ -0,0 +1,173 @@
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/common/Button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/common/Dialog';
export const InstructionModal = () => {
const t = useTranslations('dashboard.keys.instructionModal');
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="secondary">
{t('trigger')}
</Button>
</DialogTrigger>
<DialogContent className="max-h-[80vh] max-w-[800px] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-xl">
{t('heading')}
</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
{t('description')}
</DialogDescription>
</DialogHeader>
<h2 className="text-lg font-semibold">
{t('step1Title')}
</h2>
<p className="text-sm text-muted-foreground">
{t('step1Description')}
</p>
<ol className="list-inside list-decimal space-y-2 pl-4">
<li className="text-sm text-muted-foreground">
<strong>
{t('downloadObs')}
</strong>
<br />
{t('downloadObsDescription')}
{' '}
<a
className="text-blue-500 underline hover:text-blue-700"
href="https://obsproject.com"
rel="noopener noreferrer"
target="_blank"
>
{t('obsLinkText')}
</a>
.
</li>
<li className="text-sm text-muted-foreground">
<strong>
{t('copyKeys')}
</strong>
<br />
{t('copyKeysDescription')}
</li>
</ol>
<h2 className="mt-4 text-lg font-semibold">
{t('step2Title')}
</h2>
<p className="text-sm text-muted-foreground">
{t('step2Description')}
</p>
<ol className="list-inside list-decimal space-y-2 pl-4">
<li className="text-sm text-muted-foreground">
<strong>
{t('openObs')}
</strong>
<br />
{t('openObsDescription')}
</li>
<li className="text-sm text-muted-foreground">
<strong>
{t('openStreamSettings')}
</strong>
<br />
{t('openStreamSettingsDescription')}
</li>
<li className="text-sm text-muted-foreground">
<strong>
{t('enterDetails')}
</strong>
<br />
{t('enterDetailsDescription')}
</li>
<li className="text-sm text-muted-foreground">
<strong>
{t('saveSettings')}
</strong>
<br />
{t('saveSettingsDescription')}
</li>
</ol>
<h2 className="mt-4 text-lg font-semibold">
{t('step3Title')}
</h2>
<p className="text-sm text-muted-foreground">
{t('step3Description')}
</p>
<ol className="list-inside list-decimal space-y-2 pl-4">
<li className="text-sm text-muted-foreground">
<strong>
{t('startStream')}
</strong>
<br />
{t('startStreamDescription')}
</li>
<li className="text-sm text-muted-foreground">
<strong>
{t('monitorStream')}
</strong>
<br />
{t('monitorStreamDescription')}
</li>
</ol>
<p className="mt-4 text-sm text-muted-foreground">
{t('congrats')}
</p>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary">
{t('close')}
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,51 @@
'use client';
import { useTranslations } from 'next-intl';
import { Heading } from '@/components/ui/elements/Heading';
import { ToggleCardSkeleton } from '@/components/ui/elements/ToggleCard';
import { useCurrent } from '@/hooks/useCurrent';
import { CreateIngressForm } from './forms/CreateIngressForm';
import { StreamKey } from './forms/StreamKey';
import { StreamURL } from './forms/StreamURL';
import { InstructionModal } from './InstructionModal';
export const KeysSettings = () => {
const t = useTranslations('dashboard.keys.header');
const { user, isLoadingProfile } = useCurrent();
return (
<div className="lg:px-10">
<div className="block items-center justify-between space-y-3 lg:flex lg:space-y-0">
<Heading
description={t('description')}
size="lg"
title={t('heading')}
/>
<div className="flex items-center gap-x-4">
<InstructionModal />
<CreateIngressForm />
</div>
</div>
<div className="mt-5 space-y-6">
{isLoadingProfile
? Array.from({ length: 2 }).map((_, index) => (
// eslint-disable-next-line react/no-array-index-key
<ToggleCardSkeleton key={index} />
))
: (
<>
<StreamURL value={user?.stream?.serverUrl} />
<StreamKey value={user?.stream?.key} />
</>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,149 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button } from '@/components/ui/common/Button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/common/Dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from '@/components/ui/common/Form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/common/Select';
import { useCreateIngressMutation } from '@/graphql/generated/output';
import { useCurrent } from '@/hooks/useCurrent';
import {
IngressType,
type TypeCreateIngressSchema,
createIngressSchema,
} from '@/schemas/stream/create-ingress.schema';
export const CreateIngressForm = () => {
const t = useTranslations('dashboard.keys.createModal');
const [isOpen, setIsOpen] = useState(false);
const { refetch } = useCurrent();
const form = useForm<TypeCreateIngressSchema>({
resolver: zodResolver(createIngressSchema),
defaultValues: {
ingressType: IngressType.RTMP,
},
});
const [create, { loading: isLoadingCreate }] = useCreateIngressMutation({
onCompleted() {
setIsOpen(false);
void refetch();
toast.success(t('successMessage'));
},
onError() {
toast.error(t('errorMessage'));
},
});
const { isValid } = form.formState;
function onSubmit(data: TypeCreateIngressSchema) {
void create({ variables: { ingressType: data.ingressType } });
}
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button>
{t('trigger')}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t('heading')}
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form
className="space-y-6"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="ingressType"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('ingressTypeLabel')}
</FormLabel>
<FormControl>
<Select
defaultValue={field.value.toString()}
onValueChange={(value) => {
field.onChange(Number(value));
}}
>
<SelectTrigger>
<SelectValue
placeholder={t(
'ingressTypePlaceholder',
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem
disabled={isLoadingCreate}
value={IngressType.RTMP.toString()}
>
RTMP
</SelectItem>
<SelectItem
disabled={isLoadingCreate}
value={IngressType.WHIP.toString()}
>
WHIP
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
{t('ingressTypeDescription')}
</FormDescription>
</FormItem>
)}
/>
<div className="flex justify-end">
<Button disabled={!isValid || isLoadingCreate}>
{t('submitButton')}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,47 @@
import { Eye, EyeOff } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { Button } from '@/components/ui/common/Button';
import { Input } from '@/components/ui/common/Input';
import { CardContainer } from '@/components/ui/elements/CardContainer';
import { CopyButton } from '@/components/ui/elements/CopyButton';
type StreamKeyProps = {
value?: string | null;
};
export const StreamKey = ({ value }: StreamKeyProps) => {
const t = useTranslations('dashboard.keys.key');
const [isShow, setIsShow] = useState(false);
const Icon = isShow ? Eye : EyeOff;
return (
<CardContainer
isRightContentFull
heading={t('heading')}
rightContent={(
<div className="flex w-full items-center gap-x-4">
<Input
disabled
placeholder={t('heading')}
type={isShow ? 'text' : 'password'}
value={value ?? ''}
/>
<CopyButton value={value} />
<Button
size="lgIcon"
variant="ghost"
onClick={() => { setIsShow(!isShow); }}
>
<Icon className="size-5" />
</Button>
</div>
)}
/>
);
};

View File

@ -0,0 +1,31 @@
import { useTranslations } from 'next-intl';
import { Input } from '@/components/ui/common/Input';
import { CardContainer } from '@/components/ui/elements/CardContainer';
import { CopyButton } from '@/components/ui/elements/CopyButton';
type StreamURLProps = {
value?: string | null;
};
export const StreamURL = ({ value }: StreamURLProps) => {
const t = useTranslations('dashboard.keys.url');
return (
<CardContainer
isRightContentFull
heading={t('heading')}
rightContent={(
<div className="flex w-full items-center gap-x-4">
<Input
disabled
placeholder={t('heading')}
value={value ?? ''}
/>
<CopyButton value={value} />
</div>
)}
/>
);
};

View File

@ -0,0 +1,174 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button } from '@/components/ui/common/Button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/common/Dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from '@/components/ui/common/Form';
import { Input } from '@/components/ui/common/Input';
import { Textarea } from '@/components/ui/common/Textarea';
import {
useCreateSponsorshipPlanMutation,
useFindMySponsorshipPlansQuery,
} from '@/graphql/generated/output';
import {
type TypeCreatePlanSchema,
createPlanSchema,
} from '@/schemas/plan/create-plan.schema';
export const CreatePlanForm = () => {
const t = useTranslations('dashboard.plans.createForm');
const [isOpen, setIsOpen] = useState(false);
const { refetch } = useFindMySponsorshipPlansQuery();
const form = useForm<TypeCreatePlanSchema>({
resolver: zodResolver(createPlanSchema),
defaultValues: {
title: '',
description: '',
price: 0,
},
});
const [create, { loading: isLoadingCreate }] = useCreateSponsorshipPlanMutation({
onCompleted() {
setIsOpen(false);
form.reset();
void refetch();
toast.success(t('successMessage'));
},
onError() {
toast.error(t('errorMessage'));
},
});
const { isValid } = form.formState;
function onSubmit(data: TypeCreatePlanSchema) {
void create({ variables: { data } });
}
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button>
{t('trigger')}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t('heading')}
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form
className="space-y-6"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('titleLabel')}
</FormLabel>
<FormControl>
<Input
disabled={isLoadingCreate}
placeholder={t('titlePlaceholder')}
{...field}
/>
</FormControl>
<FormDescription>
{t('titleDescription')}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('descriptionLabel')}
</FormLabel>
<FormControl>
<Textarea
disabled={isLoadingCreate}
placeholder={t(
'descriptionPlaceholder',
)}
{...field}
/>
</FormControl>
<FormDescription>
{t('descriptionDescription')}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="price"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('priceLabel')}
</FormLabel>
<FormControl>
<Input
disabled={isLoadingCreate}
placeholder={t('priceLabel')}
type="number"
{...field}
/>
</FormControl>
<FormDescription>
{t('priceDescription')}
</FormDescription>
</FormItem>
)}
/>
<div className="flex justify-end">
<Button disabled={!isValid || isLoadingCreate}>
{t('submitButton')}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,135 @@
'use client';
import { MoreHorizontal, Trash } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { Button } from '@/components/ui/common/Button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/common/DropdownMenu';
import {
DataTable,
DataTableSkeleton,
} from '@/components/ui/elements/DataTable';
import { Heading } from '@/components/ui/elements/Heading';
import {
type FindMySponsorshipPlansQuery,
useFindMySponsorshipPlansQuery,
useRemoveSponsorshipPlanMutation,
} from '@/graphql/generated/output';
import { useCurrent } from '@/hooks/useCurrent';
import { convertPrice } from '@/utils/convert-price';
import { formatDate } from '@/utils/format-date';
import { CreatePlanForm } from '../forms/CreatePlanForm';
import { VerifiedChannelAlert } from './VerifiedChannelAlert';
import type { ColumnDef } from '@tanstack/react-table';
export const PlansTable = () => {
const t = useTranslations('dashboard.plans');
const { user } = useCurrent();
const {
data,
loading: isLoadingPlans,
refetch,
} = useFindMySponsorshipPlansQuery();
const plans = data?.findMySponsorshipPlans ?? [];
const plansColumns: ColumnDef<
FindMySponsorshipPlansQuery['findMySponsorshipPlans'][0]
>[] = [
{
accessorKey: 'createdAt',
header: t('columns.date'),
cell: ({ row }) => formatDate(row.original.createdAt),
},
{
accessorKey: 'title',
header: t('columns.title'),
cell: ({ row }) => row.original.title,
},
{
accessorKey: 'price',
header: t('columns.price'),
cell: ({ row }) => convertPrice(row.original.price),
},
{
accessorKey: 'actions',
header: t('columns.actions'),
// eslint-disable-next-line react/no-unstable-nested-components
cell: ({ row }) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [remove, { loading: isLoadingRemove }] = useRemoveSponsorshipPlanMutation({
onCompleted() {
void refetch();
toast.success(t('columns.successMessage'));
},
onError() {
// @ts-ignore
toast.error(t('columns.errorMessage'));
},
});
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="size-8 p-0" variant="ghost">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right">
<DropdownMenuItem
className="text-red-500 focus:text-red-500"
disabled={isLoadingRemove}
onClick={async () => remove({
variables: { planId: row.original.id },
})}
>
<Trash className="mr-2 size-4" />
{t('columns.remove')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
return user?.isVerified
? (
<div className="lg:px-10">
<div className="block items-center justify-between space-y-3 lg:flex lg:space-y-0">
<Heading
description={t('header.description')}
size="lg"
title={t('header.heading')}
/>
<CreatePlanForm />
</div>
<div className="mt-5">
{isLoadingPlans
? (
<DataTableSkeleton />
)
: (
<DataTable columns={plansColumns} data={plans} />
)}
</div>
</div>
)
: (
<VerifiedChannelAlert />
);
};

View File

@ -0,0 +1,20 @@
import { ShieldAlert } from 'lucide-react';
import { useTranslations } from 'next-intl';
export const VerifiedChannelAlert = () => {
const t = useTranslations('dashboard.plans.alert');
return (
<div className="flex h-[75vh] w-full flex-col items-center justify-center">
<ShieldAlert className="size-20 text-muted-foreground" />
<h1 className="mt-6 text-2xl font-semibold">
{t('heading')}
</h1>
<p className="mt-3 w-full items-center text-center text-muted-foreground lg:w-[60%]">
{t('description')}
</p>
</div>
);
};

View File

@ -0,0 +1,112 @@
'use client';
import { MoreHorizontal, User } from 'lucide-react';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/common/Button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/common/DropdownMenu';
import { ChannelAvatar } from '@/components/ui/elements/ChannelAvatar';
import { ChannelVerified } from '@/components/ui/elements/ChannelVerified';
import {
DataTable,
DataTableSkeleton,
} from '@/components/ui/elements/DataTable';
import { Heading } from '@/components/ui/elements/Heading';
import {
type FindMySponsorsQuery,
useFindMySponsorsQuery,
} from '@/graphql/generated/output';
import { formatDate } from '@/utils/format-date';
import type { ColumnDef } from '@tanstack/react-table';
export const SponsorsTable = () => {
const t = useTranslations('dashboard.sponsors');
const { data, loading: isLoadingSponsors } = useFindMySponsorsQuery();
const sponsors = data?.findMySponsors ?? [];
const sponsorsColumns: ColumnDef<
FindMySponsorsQuery['findMySponsors'][0]
>[] = [
{
accessorKey: 'expiresAt',
header: t('columns.date'),
cell: ({ row }) => formatDate(row.original.expiresAt),
},
{
accessorKey: 'user',
header: t('columns.user'),
// eslint-disable-next-line react/no-unstable-nested-components
cell: ({ row }) => (
<div className="flex items-center gap-x-2">
<ChannelAvatar channel={row.original.user} size="sm" />
<h2>
{row.original.user.name}
</h2>
{row.original.user.isVerified ? <ChannelVerified size="sm" /> : null}
</div>
),
},
{
accessorKey: 'plan',
header: t('columns.plan'),
cell: ({ row }) => row.original.plan.title,
},
{
accessorKey: 'actions',
header: t('columns.actions'),
// eslint-disable-next-line react/no-unstable-nested-components
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="size-8 p-0" variant="ghost">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right">
<Link
href={`/${row.original.user.name}`}
target="_blank"
>
<DropdownMenuItem>
<User className="mr-2 size-4" />
{t('columns.viewChannel')}
</DropdownMenuItem>
</Link>
</DropdownMenuContent>
</DropdownMenu>
),
},
];
return (
<div className="lg:px-10">
<Heading
description={t('header.description')}
size="lg"
title={t('header.heading')}
/>
<div className="mt-5">
{isLoadingSponsors
? (
<DataTableSkeleton />
)
: (
<DataTable columns={sponsorsColumns} data={sponsors} />
)}
</div>
</div>
);
};

View File

@ -0,0 +1,115 @@
'use client';
import { useTranslations } from 'next-intl';
import {
DataTable,
DataTableSkeleton,
} from '@/components/ui/elements/DataTable';
import { Heading } from '@/components/ui/elements/Heading';
import {
type FindMyTransactionsQuery,
TransactionStatus,
useFindMyTransactionsQuery,
} from '@/graphql/generated/output';
import { convertPrice } from '@/utils/convert-price';
import { formatDate } from '@/utils/format-date';
import type { ColumnDef } from '@tanstack/react-table';
export const TransactionsTable = () => {
const t = useTranslations('dashboard.transactions');
const { data, loading: isLoadingTransactions } = useFindMyTransactionsQuery();
const transactions = data?.findMyTransactions ?? [];
const transactionsColumns: ColumnDef<
FindMyTransactionsQuery['findMyTransactions'][0]
>[] = [
{
accessorKey: 'createdAt',
header: t('columns.date'),
cell: ({ row }) => formatDate(row.original.createdAt),
},
{
accessorKey: 'status',
header: t('columns.status'),
// eslint-disable-next-line react/no-unstable-nested-components
cell: ({ row }) => {
const { status } = row.original;
// eslint-disable-next-line no-useless-assignment
let statusColor = '';
switch (status) {
case TransactionStatus.Success:
statusColor = 'text-green-500';
return (
<div className={`py-1.5 ${statusColor}`}>
{t('columns.success')}
</div>
);
case TransactionStatus.Pending:
statusColor = 'text-yellow-500';
return (
<div className={`py-1.5 ${statusColor}`}>
{t('columns.pending')}
</div>
);
case TransactionStatus.Failed:
statusColor = 'text-red-600';
return (
<div className={`py-1.5 ${statusColor}`}>
{t('columns.failed')}
</div>
);
case TransactionStatus.Expired:
statusColor = 'text-purple-500';
return (
<div className={`py-1.5 ${statusColor}`}>
{t('columns.expired')}
</div>
);
default:
statusColor = 'text-foreground';
return (
<div className={`py-1.5 ${statusColor}`}>
{status}
</div>
);
}
},
},
{
accessorKey: 'amount',
header: t('columns.amount'),
cell: ({ row }) => convertPrice(row.original.amount),
},
];
return (
<div className="lg:px-10">
<Heading
description={t('header.description')}
title={t('header.heading')}
/>
<div className="mt-5">
{isLoadingTransactions
? (
<DataTableSkeleton />
)
: (
<DataTable
columns={transactionsColumns}
data={transactions}
/>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,143 @@
import { useTranslations } from 'next-intl';
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/ui/common/Tabs';
import { Heading } from '@/components/ui/elements/Heading';
import { ChangeEmailForm } from './account/ChangeEmailForm';
import { ChangePasswordForm } from './account/ChangePasswordForm';
import { DeactivateCard } from './account/DeactivateCard';
import { WrapperTotp } from './account/totp/WrapperTotp';
import { ChangeColorForm } from './appearance/ChangeColorForm';
import { ChangeLanguageForm } from './appearance/ChangeLanguageForm';
import { ChangeThemeForm } from './appearance/ChangeThemeForm';
import { ChangeNotificationsSettingsForm } from './notifications/ChangeNotificationsSettingsForm';
import { ChangeAvatarForm } from './profile/ChangeAvatarForm';
import { ChangeInfoForm } from './profile/ChangeInfoForm';
import { SocialLinksForm } from './profile/social-links-form/SocialLinksForm';
import { SessionsList } from './sessions/SessionsList';
export const UserSettings = () => {
const t = useTranslations('dashboard.settings');
return (
<div className="lg:px-10">
<Heading
description={t('header.description')}
size="lg"
title={t('header.heading')}
/>
<Tabs className="mt-3 w-full" defaultValue="profile">
<TabsList className="grid max-w-2xl grid-cols-5">
<TabsTrigger value="profile">
{t('header.profile')}
</TabsTrigger>
<TabsTrigger value="account">
{t('header.account')}
</TabsTrigger>
<TabsTrigger value="appearance">
{t('header.appearance')}
</TabsTrigger>
<TabsTrigger value="notifications">
{t('header.notifications')}
</TabsTrigger>
<TabsTrigger value="sessions">
{t('header.sessions')}
</TabsTrigger>
</TabsList>
<TabsContent value="profile">
<div className="mt-5 space-y-6">
<Heading
description={t('profile.header.description')}
title={t('profile.header.heading')}
/>
<ChangeAvatarForm />
<ChangeInfoForm />
<SocialLinksForm />
</div>
</TabsContent>
<TabsContent value="account">
<div className="mt-5 space-y-6">
<Heading
description={t('account.header.description')}
title={t('account.header.heading')}
/>
<ChangeEmailForm />
<ChangePasswordForm />
<Heading
description={t(
'account.header.securityDescription',
)}
title={t('account.header.securityHeading')}
/>
<WrapperTotp />
<Heading
description={t(
'account.header.deactivationDescription',
)}
title={t('account.header.deactivationHeading')}
/>
<DeactivateCard />
</div>
</TabsContent>
<TabsContent value="appearance">
<div className="mt-5 space-y-6">
<Heading
description={t('appearance.header.description')}
title={t('appearance.header.heading')}
/>
<ChangeThemeForm />
<ChangeLanguageForm />
<ChangeColorForm />
</div>
</TabsContent>
<TabsContent value="notifications">
<div className="mt-5 space-y-6">
<Heading
description={t('notifications.header.description')}
title={t('notifications.header.heading')}
/>
<ChangeNotificationsSettingsForm />
</div>
</TabsContent>
<TabsContent value="sessions">
<div className="mt-5 space-y-6">
<Heading
description={t('sessions.header.description')}
title={t('sessions.header.heading')}
/>
<SessionsList />
</div>
</TabsContent>
</Tabs>
</div>
);
};

View File

@ -0,0 +1,106 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button } from '@/components/ui/common/Button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from '@/components/ui/common/Form';
import { Input } from '@/components/ui/common/Input';
import { Separator } from '@/components/ui/common/Separator';
import { Skeleton } from '@/components/ui/common/Skeleton';
import { FormWrapper } from '@/components/ui/elements/FormWrapper';
import { useChangeEmailMutation } from '@/graphql/generated/output';
import { useCurrent } from '@/hooks/useCurrent';
import {
type TypeChangeEmailSchema,
changeEmailSchema,
} from '@/schemas/user/change-email.schema';
export const ChangeEmailFormSkeleton = () => <Skeleton className="h-64 w-full" />;
export const ChangeEmailForm = () => {
const t = useTranslations('dashboard.settings.account.email');
const { user, isLoadingProfile, refetch } = useCurrent();
const form = useForm<TypeChangeEmailSchema>({
resolver: zodResolver(changeEmailSchema),
values: {
email: user?.email ?? '',
},
});
const [update, { loading: isLoadingUpdate }] = useChangeEmailMutation({
onCompleted() {
void refetch();
toast.success(t('successMessage'));
},
onError() {
toast.error(t('errorMessage'));
},
});
const { isValid, isDirty } = form.formState;
function onSubmit(data: TypeChangeEmailSchema) {
void update({ variables: { data } });
}
return isLoadingProfile
? (
<ChangeEmailFormSkeleton />
)
: (
<FormWrapper heading={t('heading')}>
<Form {...form}>
<form
className="grid gap-y-3"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="px-5">
<FormLabel>
{t('emailLabel')}
</FormLabel>
<FormControl>
<Input
disabled={isLoadingUpdate}
placeholder="john.doe@example.com"
{...field}
/>
</FormControl>
<FormDescription>
{t('emailDescription')}
</FormDescription>
</FormItem>
)}
/>
<Separator />
<div className="flex justify-end p-5">
<Button
disabled={!isValid || !isDirty || isLoadingUpdate}
>
{t('submitButton')}
</Button>
</div>
</form>
</Form>
</FormWrapper>
);
};

View File

@ -0,0 +1,134 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button } from '@/components/ui/common/Button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from '@/components/ui/common/Form';
import { Input } from '@/components/ui/common/Input';
import { Separator } from '@/components/ui/common/Separator';
import { Skeleton } from '@/components/ui/common/Skeleton';
import { FormWrapper } from '@/components/ui/elements/FormWrapper';
import { useChangePasswordMutation } from '@/graphql/generated/output';
import { useCurrent } from '@/hooks/useCurrent';
import {
type TypeChangePasswordSchema,
changePasswordSchema,
} from '@/schemas/user/change-password.schema';
export const ChangePasswordFormSkeleton = () => <Skeleton className="h-96 w-full" />;
export const ChangePasswordForm = () => {
const t = useTranslations('dashboard.settings.account.password');
const { isLoadingProfile, refetch } = useCurrent();
const form = useForm<TypeChangePasswordSchema>({
resolver: zodResolver(changePasswordSchema),
values: {
oldPassword: '',
newPassword: '',
},
});
const [update, { loading: isLoadingUpdate }] = useChangePasswordMutation({
onCompleted() {
form.reset();
void refetch();
toast.success(t('successMessage'));
},
onError() {
toast.error(t('errorMessage'));
},
});
const { isValid } = form.formState;
function onSubmit(data: TypeChangePasswordSchema) {
void update({ variables: { data } });
}
return isLoadingProfile
? (
<ChangePasswordFormSkeleton />
)
: (
<FormWrapper heading={t('heading')}>
<Form {...form}>
<form
className="grid gap-y-3"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="oldPassword"
render={({ field }) => (
<FormItem className="px-5">
<FormLabel>
{t('oldPasswordLabel')}
</FormLabel>
<FormControl>
<Input
disabled={isLoadingUpdate}
placeholder="********"
type="password"
{...field}
/>
</FormControl>
<FormDescription>
{t('oldPasswordDescription')}
</FormDescription>
</FormItem>
)}
/>
<Separator />
<FormField
control={form.control}
name="newPassword"
render={({ field }) => (
<FormItem className="px-5">
<FormLabel>
{t('newPasswordLabel')}
</FormLabel>
<FormControl>
<Input
disabled={isLoadingUpdate}
placeholder="********"
type="password"
{...field}
/>
</FormControl>
<FormDescription>
{t('newPasswordDescription')}
</FormDescription>
</FormItem>
)}
/>
<Separator />
<div className="flex justify-end p-5">
<Button disabled={!isValid || isLoadingUpdate}>
{t('submitButton')}
</Button>
</div>
</form>
</Form>
</FormWrapper>
);
};

View File

@ -0,0 +1,34 @@
'use client';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/common/Button';
import { CardContainer } from '@/components/ui/elements/CardContainer';
import { ConfirmModal } from '@/components/ui/elements/ConfirmModal';
export const DeactivateCard = () => {
const t = useTranslations('dashboard.settings.account.deactivation');
const router = useRouter();
return (
<CardContainer
description={t('description')}
heading={t('heading')}
rightContent={(
<div className="flex items-center gap-x-4">
<ConfirmModal
heading={t('confirmModal.heading')}
message={t('confirmModal.message')}
onConfirm={() => { router.push('/account/deactivate'); }}
>
<Button>
{t('button')}
</Button>
</ConfirmModal>
</div>
)}
/>
);
};

View File

@ -0,0 +1,35 @@
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { Button } from '@/components/ui/common/Button';
import { ConfirmModal } from '@/components/ui/elements/ConfirmModal';
import { useDisableTotpMutation } from '@/graphql/generated/output';
import { useCurrent } from '@/hooks/useCurrent';
export const DisableTotp = () => {
const t = useTranslations('dashboard.settings.account.twoFactor.disable');
const { refetch } = useCurrent();
const [disable, { loading: isLoadingDisable }] = useDisableTotpMutation({
onCompleted() {
void refetch();
toast.success(t('successMessage'));
},
onError() {
toast.error(t('errorMessage'));
},
});
return (
<ConfirmModal
heading={t('heading')}
message={t('message')}
onConfirm={async () => disable()}
>
<Button disabled={isLoadingDisable} variant="secondary">
{t('trigger')}
</Button>
</ConfirmModal>
);
};

View File

@ -0,0 +1,176 @@
import { zodResolver } from '@hookform/resolvers/zod';
import Image from 'next/image';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button } from '@/components/ui/common/Button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/common/Dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from '@/components/ui/common/Form';
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from '@/components/ui/common/InputOTP';
import {
useEnableTotpMutation,
useGenerateTotpSecretQuery,
} from '@/graphql/generated/output';
import { useCurrent } from '@/hooks/useCurrent';
import {
type TypeEnableTotpSchema,
enableTotpSchema,
} from '@/schemas/user/enable-totp.schema';
export const EnableTotp = () => {
const t = useTranslations('dashboard.settings.account.twoFactor.enable');
const [isOpen, setIsOpen] = useState(false);
const { refetch } = useCurrent();
const { data, loading: isLoadingGenerate } = useGenerateTotpSecretQuery();
const twoFactorAuth = data?.generateTotpSecret;
const form = useForm<TypeEnableTotpSchema>({
resolver: zodResolver(enableTotpSchema),
defaultValues: {
pin: '',
},
});
const [enable, { loading: isLoadingEnable }] = useEnableTotpMutation({
onCompleted() {
void refetch();
setIsOpen(false);
toast.success(t('successMessage'));
},
onError() {
toast.error(t('errorMessage'));
},
});
const { isValid } = form.formState;
function onSubmit(values: TypeEnableTotpSchema) {
void enable({
variables: {
data: {
secret: twoFactorAuth?.secret ?? '',
pin: values.pin,
},
},
});
}
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button>
{t('trigger')}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{t('heading')}
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form
className="flex flex-col gap-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<div className="flex flex-col items-center justify-center gap-4">
<span className="text-sm text-muted-foreground">
{twoFactorAuth?.qrcodeUrl
? t('qrInstructions')
: ''}
</span>
<Image
alt="QR"
className="rounded-lg"
height={300}
src={twoFactorAuth?.qrcodeUrl ?? ''}
width={300}
/>
</div>
<div className="flex flex-col gap-2">
<span className="text-center text-sm text-muted-foreground">
{twoFactorAuth?.secret
? t('secretCodeLabel')
+ twoFactorAuth.secret
: ''}
</span>
</div>
<FormField
control={form.control}
name="pin"
render={({ field }) => (
<FormItem className="flex flex-col justify-center max-sm:items-center">
<FormLabel>
{t('pinLabel')}
</FormLabel>
<FormControl>
<InputOTP maxLength={6} {...field}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormDescription>
{t('pinDescription')}
</FormDescription>
</FormItem>
)}
/>
<DialogFooter>
<Button
disabled={
!isValid
|| isLoadingGenerate
|| isLoadingEnable
}
type="submit"
>
{t('submitButton')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,34 @@
'use client';
import { useTranslations } from 'next-intl';
import { Skeleton } from '@/components/ui/common/Skeleton';
import { CardContainer } from '@/components/ui/elements/CardContainer';
import { useCurrent } from '@/hooks/useCurrent';
import { DisableTotp } from './DisableTotp';
import { EnableTotp } from './EnableTotp';
export const WrapperTotpSkeleton = () => <Skeleton className="h-24 w-full" />;
export const WrapperTotp = () => {
const t = useTranslations('dashboard.settings.account.twoFactor');
const { user, isLoadingProfile } = useCurrent();
return isLoadingProfile
? (
<WrapperTotpSkeleton />
)
: (
<CardContainer
description={t('description')}
heading={t('heading')}
rightContent={(
<div className="flex items-center gap-x-4">
{!user?.isTotpEnabled ? <EnableTotp /> : <DisableTotp />}
</div>
)}
/>
);
};

View File

@ -0,0 +1,47 @@
'use client';
import { Check } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { CardContainer } from '@/components/ui/elements/CardContainer';
import { useConfig } from '@/hooks/useConfig';
import { BASE_COLORS } from '@/libs/constants/colors.constants';
import type { CSSProperties } from 'react';
export const ChangeColorForm = () => {
const t = useTranslations('dashboard.settings.appearance.color');
const config = useConfig();
return (
<CardContainer
description={t('description')}
heading={t('heading')}
rightContent={(
<div className="grid grid-cols-4 gap-2 md:grid-cols-8">
{BASE_COLORS.map((theme) => {
const isActive = config.theme === theme.name;
return (
<button
key={theme.name}
style={
{
'--theme-primary': `hsl(${theme.color})`,
} as CSSProperties
}
type="button"
onClick={() => { config.setTheme(theme.name); }}
>
<span className="flex size-9 shrink-0 -translate-x-1 items-center justify-center rounded-lg bg-(--theme-primary) hover:border-2 hover:border-foreground">
{isActive ? <Check className="size-5 text-white" /> : null}
</span>
</button>
);
})}
</div>
)}
/>
);
};

View File

@ -0,0 +1,94 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLocale, useTranslations } from 'next-intl';
import { useTransition } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Form, FormField } from '@/components/ui/common/Form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/common/Select';
import { CardContainer } from '@/components/ui/elements/CardContainer';
import { setCurrentLanguage } from '@/libs/i18n/language';
import {
changeLanguageSchema,
} from '@/schemas/user/change-language.schema';
import type { TypeChangeLanguageSchema } from '@/schemas/user/change-language.schema';
const languages = {
ru: 'Русский',
en: 'English',
};
export const ChangeLanguageForm = () => {
const t = useTranslations('dashboard.settings.appearance.language');
const [isPending, startTransition] = useTransition();
const locale = useLocale();
const form = useForm<TypeChangeLanguageSchema>({
resolver: zodResolver(changeLanguageSchema),
values: {
language: locale,
},
});
function onSubmit(data: TypeChangeLanguageSchema) {
startTransition(async () => {
try {
await setCurrentLanguage(data.language);
} catch (error) {
toast.success(t('successMessage'));
}
});
}
return (
<CardContainer
description={t('description')}
heading={t('heading')}
rightContent={(
<Form {...form}>
<FormField
control={form.control}
name="language"
render={({ field }) => (
<Select
value={field.value}
onValueChange={(value: string) => {
field.onChange(value);
void form.handleSubmit(onSubmit)();
}}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder={t('selectPlaceholder')} />
</SelectTrigger>
<SelectContent>
{Object.entries(languages).map(
([code, name]) => (
<SelectItem
key={code}
disabled={isPending}
value={code}
>
{name}
</SelectItem>
),
)}
</SelectContent>
</Select>
)}
/>
</Form>
)}
/>
);
};

View File

@ -0,0 +1,53 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations } from 'next-intl';
import { useTheme } from 'next-themes';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Form, FormField } from '@/components/ui/common/Form';
import { ToggleCard } from '@/components/ui/elements/ToggleCard';
import {
changeThemeSchema,
} from '@/schemas/user/change-theme.schema';
import type { TypeChangeThemeSchema } from '@/schemas/user/change-theme.schema';
export const ChangeThemeForm = () => {
const t = useTranslations('dashboard.settings.appearance.theme');
const { theme, setTheme } = useTheme();
const form = useForm<TypeChangeThemeSchema>({
resolver: zodResolver(changeThemeSchema),
values: {
theme: theme === 'dark' ? 'dark' : 'light',
},
});
const onChange = (value: boolean) => {
const newTheme = value ? 'dark' : 'light';
setTheme(newTheme);
form.setValue('theme', newTheme);
toast.success(t('successMessage'));
};
return (
<Form {...form}>
<FormField
control={form.control}
name="theme"
render={({ field }) => (
<ToggleCard
description={t('description')}
heading={t('heading')}
value={field.value === 'dark'}
onChange={onChange}
/>
)}
/>
</Form>
);
};

View File

@ -0,0 +1,101 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Form, FormField } from '@/components/ui/common/Form';
import {
ToggleCard,
ToggleCardSkeleton,
} from '@/components/ui/elements/ToggleCard';
import { useChangeNotificationsSettingsMutation } from '@/graphql/generated/output';
import { useCurrent } from '@/hooks/useCurrent';
import {
type TypeChangeNotificationsSettingsSchema,
changeNotificationsSettingsSchema,
} from '@/schemas/user/change-notifications-settings.schema';
export const ChangeNotificationsSettingsForm = () => {
const t = useTranslations('dashboard.settings.notifications');
const { user, isLoadingProfile, refetch } = useCurrent();
const form = useForm<TypeChangeNotificationsSettingsSchema>({
resolver: zodResolver(changeNotificationsSettingsSchema),
values: {
siteNotifications:
user?.notificationSettings?.siteNotifications ?? false,
telegramNotifications:
user?.notificationSettings?.telegramNotifications ?? false,
},
});
const [update, { loading: isLoadingUpdate }] = useChangeNotificationsSettingsMutation({
onCompleted(data) {
void refetch();
toast.success(t('successMessage'));
if (data.changeNotificationSettings.telegramAuthToken) {
window.open(
`https://t.me/ksv741_teastream_bot?start=${data.changeNotificationSettings.telegramAuthToken}`,
'_blank',
);
}
},
onError() {
toast.error(t('errorMessage'));
},
});
function onChange(
field: keyof TypeChangeNotificationsSettingsSchema,
value: boolean,
) {
form.setValue(field, value);
void update({
variables: {
data: { ...form.getValues(), [field]: value },
},
});
}
return isLoadingProfile
? Array.from({ length: 2 }).map((_, index) => (
// eslint-disable-next-line react/no-array-index-key
<ToggleCardSkeleton key={index} />
))
: (
<Form {...form}>
<FormField
control={form.control}
name="siteNotifications"
render={({ field }) => (
<ToggleCard
description={t('siteNotifications.description')}
heading={t('siteNotifications.heading')}
isDisabled={isLoadingUpdate}
value={field.value}
onChange={(value) => { onChange('siteNotifications', value); }}
/>
)}
/>
<FormField
control={form.control}
name="telegramNotifications"
render={({ field }) => (
<ToggleCard
description={t('telegramNotifications.description')}
heading={t('telegramNotifications.heading')}
isDisabled={isLoadingUpdate}
value={field.value}
onChange={(value) => { onChange('telegramNotifications', value); }}
/>
)}
/>
</Form>
);
};

View File

@ -0,0 +1,158 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trash } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { type ChangeEvent, useRef } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button } from '@/components/ui/common/Button';
import { Form, FormField } from '@/components/ui/common/Form';
import { Skeleton } from '@/components/ui/common/Skeleton';
import { ChannelAvatar } from '@/components/ui/elements/ChannelAvatar';
import { ConfirmModal } from '@/components/ui/elements/ConfirmModal';
import { FormWrapper } from '@/components/ui/elements/FormWrapper';
import {
useChangeProfileAvatarMutation,
useRemoveProfileAvatarMutation,
} from '@/graphql/generated/output';
import { useCurrent } from '@/hooks/useCurrent';
import {
type TypeUploadFileSchema,
uploadFileSchema,
} from '@/schemas/upload-file.schema';
export const ChangeAvatarFormSkeleton = () => <Skeleton className="h-52 w-full" />;
export const ChangeAvatarForm = () => {
const t = useTranslations('dashboard.settings.profile.avatar');
const { user, isLoadingProfile, refetch } = useCurrent();
const inputRef = useRef<HTMLInputElement>(null);
const form = useForm<TypeUploadFileSchema>({
resolver: zodResolver(uploadFileSchema),
values: {
file: user?.avatar ?? '',
},
});
const [update, { loading: isLoadingUpdate }] = useChangeProfileAvatarMutation({
onCompleted() {
void refetch();
toast.success(t('successUpdateMessage'));
},
onError() {
toast.error(t('errorUpdateMessage'));
},
});
const [remove, { loading: isLoadingRemove }] = useRemoveProfileAvatarMutation({
onCompleted() {
void refetch();
toast.success(t('successRemoveMessage'));
},
onError() {
toast.error(t('errorRemoveMessage'));
},
});
function handleImageChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (file) {
void update({ variables: { avatar: file } });
form.setValue('file', file);
}
}
if (!user) {
return null;
}
return isLoadingProfile
? (
<ChangeAvatarFormSkeleton />
)
: (
<FormWrapper heading={t('heading')}>
<Form {...form}>
<FormField
control={form.control}
name="file"
render={({ field }) => (
<div className="px-5 pb-5">
<div className="w-full items-center space-x-6 lg:flex">
<ChannelAvatar
channel={{
name: user.name,
avatar:
field.value instanceof File
? URL.createObjectURL(
field.value,
)
: field.value,
}}
size="xl"
/>
<div className="space-y-3">
<div className="flex items-center gap-x-3">
<input
ref={inputRef}
className="hidden"
type="file"
onChange={handleImageChange}
/>
<Button
disabled={
isLoadingUpdate
|| isLoadingRemove
}
variant="secondary"
onClick={() => inputRef.current?.click()}
>
{t('updateButton')}
</Button>
{user?.avatar
? (
<ConfirmModal
heading={t(
'confirmModal.heading',
)}
message={t(
'confirmModal.message',
)}
onConfirm={async () => remove()}
>
<Button
disabled={
isLoadingUpdate
|| isLoadingRemove
}
size="lgIcon"
variant="ghost"
>
<Trash className="size-4" />
</Button>
</ConfirmModal>
)
: null}
</div>
<p className="text-sm text-muted-foreground">
{t('info')}
</p>
</div>
</div>
</div>
)}
/>
</Form>
</FormWrapper>
);
};

View File

@ -0,0 +1,165 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button } from '@/components/ui/common/Button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from '@/components/ui/common/Form';
import { Input } from '@/components/ui/common/Input';
import { Separator } from '@/components/ui/common/Separator';
import { Skeleton } from '@/components/ui/common/Skeleton';
import { Textarea } from '@/components/ui/common/Textarea';
import { FormWrapper } from '@/components/ui/elements/FormWrapper';
import { useChangeProfileInfoMutation } from '@/graphql/generated/output';
import { useCurrent } from '@/hooks/useCurrent';
import {
type TypeChangeInfoSchema,
changeInfoSchema,
} from '@/schemas/user/change-info.schema';
export const ChangeInfoFormSkeleton = () => <Skeleton className="h-96 w-full" />;
export const ChangeInfoForm = () => {
const t = useTranslations('dashboard.settings.profile.info');
const { user, isLoadingProfile, refetch } = useCurrent();
const form = useForm<TypeChangeInfoSchema>({
resolver: zodResolver(changeInfoSchema),
values: {
name: user?.name ?? '',
displayName: user?.displayName ?? '',
bio: user?.bio ?? '',
},
});
const [update, { loading: isLoadingUpdate }] = useChangeProfileInfoMutation(
{
onCompleted() {
void refetch();
toast.success(t('successMessage'));
},
onError() {
toast.error(t('errorMessage'));
},
},
);
const { isValid, isDirty } = form.formState;
function onSubmit(data: TypeChangeInfoSchema) {
void update({ variables: { data } });
}
return isLoadingProfile
? (
<ChangeInfoFormSkeleton />
)
: (
<FormWrapper heading={t('heading')}>
<Form {...form}>
<form
className="grid gap-y-3"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="px-5">
<FormLabel>
{t('usernameLabel')}
</FormLabel>
<FormControl>
<Input
disabled={isLoadingUpdate}
placeholder={t('usernamePlaceholder')}
{...field}
/>
</FormControl>
<FormDescription>
{t('usernameDescription')}
</FormDescription>
</FormItem>
)}
/>
<Separator />
<FormField
control={form.control}
name="displayName"
render={({ field }) => (
<FormItem className="px-5 pb-3">
<FormLabel>
{t('displayNameLabel')}
</FormLabel>
<FormControl>
<Input
disabled={isLoadingUpdate}
placeholder={t(
'displayNamePlaceholder',
)}
{...field}
/>
</FormControl>
<FormDescription>
{t('displayNameDescription')}
</FormDescription>
</FormItem>
)}
/>
<Separator />
<FormField
control={form.control}
name="bio"
render={({ field }) => (
<FormItem className="px-5 pb-3">
<FormLabel>
{t('bioLabel')}
</FormLabel>
<FormControl>
<Textarea
disabled={isLoadingUpdate}
placeholder={t('bioPlaceholder')}
{...field}
/>
</FormControl>
<FormDescription>
{t('bioDescription')}
</FormDescription>
</FormItem>
)}
/>
<Separator />
<div className="flex justify-end p-5">
<Button
disabled={!isValid || !isDirty || isLoadingUpdate}
>
{t('submitButton')}
</Button>
</div>
</form>
</Form>
</FormWrapper>
);
};

View File

@ -0,0 +1,199 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { GripVertical, Pencil, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button } from '@/components/ui/common/Button';
import {
Form,
FormControl,
FormField,
FormItem,
} from '@/components/ui/common/Form';
import { Input } from '@/components/ui/common/Input';
import {
type FindSocialLinksQuery,
useFindSocialLinksQuery,
useRemoveSocialLinkMutation,
useUpdateSocialLinkMutation,
} from '@/graphql/generated/output';
import {
type TypeSocialLinksSchema,
socialLinksSchema,
} from '@/schemas/user/social-links.schema';
import type { DraggableProvided } from '@hello-pangea/dnd';
type SocialLinkItemProps = {
socialLink: FindSocialLinksQuery['findSocialLinks'][0];
provided: DraggableProvided;
};
export const SocialLinkItem = ({ socialLink, provided }: SocialLinkItemProps) => {
const t = useTranslations('dashboard.settings.profile.socialLinks.editForm');
const [editingId, setEditingId] = useState<string | null>(null);
const { refetch } = useFindSocialLinksQuery();
const form = useForm<TypeSocialLinksSchema>({
resolver: zodResolver(socialLinksSchema),
values: {
title: socialLink.title ?? '',
url: socialLink.url ?? '',
},
});
const { isValid, isDirty } = form.formState;
function toggleEditing(id: string | null) {
setEditingId(id);
}
const [update, { loading: isLoadingUpdate }] = useUpdateSocialLinkMutation({
onCompleted() {
toggleEditing(null);
void refetch();
toast.success(t('successUpdateMessage'));
},
onError() {
toast.error(t('errorUpdateMessage'));
},
});
const [remove, { loading: isLoadingRemove }] = useRemoveSocialLinkMutation({
onCompleted() {
void refetch();
toast.success(t('successRemoveMessage'));
},
onError() {
toast.error(t('errorRemoveMessage'));
},
});
function onSubmit(data: TypeSocialLinksSchema) {
void update({ variables: { id: socialLink.id, data } });
}
return (
<div
ref={provided.innerRef}
className="mb-4 flex items-center gap-x-2 rounded-md border border-border bg-background text-sm"
{...provided.draggableProps}
>
<div
className="rounded-l-md border-r border-r-border px-2 py-9 text-foreground transition"
{...provided.dragHandleProps}
>
<GripVertical className="size-5" />
</div>
<div className="space-y-1 px-2">
{editingId === socialLink.id
? (
<Form {...form}>
<form
className="flex gap-x-6"
onSubmit={form.handleSubmit(onSubmit)}
>
<div className="w-96 space-y-2">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
className="h-8"
disabled={
isLoadingUpdate
|| isLoadingRemove
}
placeholder="YouTube"
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
className="h-8"
disabled={
isLoadingUpdate
|| isLoadingRemove
}
placeholder="https://youtube.com/@TeaCoder52"
{...field}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="flex items-center gap-x-4">
<Button
variant="secondary"
onClick={() => { toggleEditing(null); }}
>
{t('cancelButton')}
</Button>
<Button
disabled={
!isDirty
|| !isValid
|| isLoadingUpdate
|| isLoadingRemove
}
>
{t('submitButton')}
</Button>
</div>
</form>
</Form>
)
: (
<>
<h2 className="text-[17px] font-semibold text-foreground">
{socialLink.title}
</h2>
<p className="text-muted-foreground">
{socialLink.url}
</p>
</>
)}
</div>
<div className="ml-auto flex items-center gap-x-2 pr-4">
{editingId !== socialLink.id && (
<Button
size="lgIcon"
variant="ghost"
onClick={() => { toggleEditing(socialLink.id); }}
>
<Pencil className="size-4 text-muted-foreground" />
</Button>
)}
<Button
size="lgIcon"
variant="ghost"
onClick={async () => remove({ variables: { id: socialLink.id } })}
>
<Trash2 className="size-4 text-muted-foreground" />
</Button>
</div>
</div>
);
};

View File

@ -0,0 +1,140 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button } from '@/components/ui/common/Button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from '@/components/ui/common/Form';
import { Input } from '@/components/ui/common/Input';
import { Separator } from '@/components/ui/common/Separator';
import { Skeleton } from '@/components/ui/common/Skeleton';
import { FormWrapper } from '@/components/ui/elements/FormWrapper';
import {
useCreateSocialLinkMutation,
useFindSocialLinksQuery,
} from '@/graphql/generated/output';
import {
type TypeSocialLinksSchema,
socialLinksSchema,
} from '@/schemas/user/social-links.schema';
import { SocialLinksList } from './SocialLinksList';
export const SocialLinksFormSkeleton = () => <Skeleton className="h-72 w-full" />;
export const SocialLinksForm = () => {
const t = useTranslations(
'dashboard.settings.profile.socialLinks.createForm',
);
const { loading: isLoadingLinks, refetch } = useFindSocialLinksQuery();
const form = useForm<TypeSocialLinksSchema>({
resolver: zodResolver(socialLinksSchema),
defaultValues: {
title: '',
url: '',
},
});
const [create, { loading: isLoadingCreate }] = useCreateSocialLinkMutation({
onCompleted() {
form.reset();
void refetch();
toast.success(t('successMessage'));
},
onError() {
toast.error(t('errorMessage'));
},
});
const { isValid } = form.formState;
function onSubmit(data: TypeSocialLinksSchema) {
void create({ variables: { data } });
}
return isLoadingLinks
? (
<SocialLinksFormSkeleton />
)
: (
<FormWrapper heading={t('heading')}>
<Form {...form}>
<form
className="grid gap-y-3"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem className="px-5">
<FormLabel>
{t('titleLabel')}
</FormLabel>
<FormControl>
<Input
disabled={isLoadingCreate}
placeholder={t('titlePlaceholder')}
{...field}
/>
</FormControl>
<FormDescription>
{t('titleDescription')}
</FormDescription>
</FormItem>
)}
/>
<Separator />
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem className="px-5 pb-3">
<FormLabel>
{t('urlLabel')}
</FormLabel>
<FormControl>
<Input
disabled={isLoadingCreate}
placeholder={t('urlPlaceholder')}
{...field}
/>
</FormControl>
<FormDescription>
{t('urlDescription')}
</FormDescription>
</FormItem>
)}
/>
<Separator />
<div className="flex justify-end p-5">
<Button disabled={!isValid || isLoadingCreate}>
{t('submitButton')}
</Button>
</div>
</form>
</Form>
<SocialLinksList />
</FormWrapper>
);
};

View File

@ -0,0 +1,102 @@
'use client';
import {
DragDropContext,
Draggable,
type DropResult,
Droppable,
} from '@hello-pangea/dnd';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { Separator } from '@/components/ui/common/Separator';
import {
useFindSocialLinksQuery,
useReorderSocialLinksMutation,
} from '@/graphql/generated/output';
import { SocialLinkItem } from './SocialLinkItem';
export const SocialLinksList = () => {
const t = useTranslations('dashboard.settings.profile.socialLinks');
const { data, refetch } = useFindSocialLinksQuery();
// eslint-disable-next-line react-hooks/exhaustive-deps
const items = data?.findSocialLinks ?? [];
const [socialLinks, setSocialLinks] = useState(items);
useEffect(() => {
setSocialLinks(items);
}, [items]);
const [reorder, { loading: isLoadingReorder }] = useReorderSocialLinksMutation({
onCompleted() {
void refetch();
toast.success(t('successReorderMessage'));
},
onError() {
toast.error(t('errorReorderMessage'));
},
});
const onDragEnd = (result: DropResult) => {
if (!result.destination) return;
// eslint-disable-next-line @typescript-eslint/no-shadow
const items = Array.from(socialLinks);
const [reorderItem] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, reorderItem);
const bulkUpdateData = items.map((socialLink, index) => ({
id: socialLink.id,
position: index,
}));
setSocialLinks(items);
void reorder({ variables: { list: bulkUpdateData } });
};
return socialLinks.length
? (
<>
<Separator />
<div className="mt-5 px-5">
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="socialLinks">
{(dropProvided) => (
<div
{...dropProvided.droppableProps}
ref={dropProvided.innerRef}
>
{socialLinks.map((socialLink, index) => (
<Draggable
key={socialLink.id}
draggableId={socialLink.id}
index={index}
isDragDisabled={isLoadingReorder}
>
{(dragProvided) => (
<SocialLinkItem
key={socialLink.id}
provided={dragProvided}
socialLink={socialLink}
/>
)}
</Draggable>
))}
{dropProvided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
</div>
</>
)
: null;
};

View File

@ -0,0 +1,69 @@
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { Button } from '@/components/ui/common/Button';
import { CardContainer } from '@/components/ui/elements/CardContainer';
import { ConfirmModal } from '@/components/ui/elements/ConfirmModal';
import {
type FindSessionsByUserQuery,
useFindSessionsByUserQuery,
useRemoveSessionMutation,
} from '@/graphql/generated/output';
import { getBrowserIcon } from '@/utils/get-browser-icon';
import { SessionModal } from './SessionModal';
type SessionItemProps = {
session: FindSessionsByUserQuery['findSessionsByUser'][0];
isCurrentSession?: boolean;
};
export const SessionItem = ({ session, isCurrentSession }: SessionItemProps) => {
const t = useTranslations('dashboard.settings.sessions.sessionItem');
const { refetch } = useFindSessionsByUserQuery();
const [remove, { loading: isLoadingRemove }] = useRemoveSessionMutation({
onCompleted() {
void refetch();
toast.success(t('successMessage'));
},
onError() {
toast.error(t('errorMessage'));
},
});
const Icon = getBrowserIcon(session.metadata.device.browser);
return (
<CardContainer
description={`${session.metadata.location.country}, ${session.metadata.location.city}`}
heading={`${session.metadata.device.browser}, ${session.metadata.device.os}`}
Icon={Icon}
rightContent={(
<div className="flex items-center gap-x-4">
{!isCurrentSession && (
<ConfirmModal
heading={t('confirmModal.heading')}
message={t('confirmModal.message')}
onConfirm={async () => remove({ variables: { id: session.id } })}
>
<Button
disabled={isLoadingRemove}
variant="secondary"
>
{t('deleteButton')}
</Button>
</ConfirmModal>
)}
<SessionModal session={session}>
<Button>
{t('detailsButton')}
</Button>
</SessionModal>
</div>
)}
/>
);
};

View File

@ -0,0 +1,110 @@
import { Map, Placemark, YMaps } from '@pbe/react-yandex-maps';
import { useTranslations } from 'next-intl';
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from '@/components/ui/common/Dialog';
import { formatDate } from '@/utils/format-date';
import type { FindSessionsByUserQuery } from '@/graphql/generated/output';
import type { PropsWithChildren } from 'react';
type SessionModalProps = {
session: FindSessionsByUserQuery['findSessionsByUser'][0];
};
export const SessionModal = ({
children,
session,
}: PropsWithChildren<SessionModalProps>) => {
const t = useTranslations('dashboard.settings.sessions.sessionModal');
const center = [
session.metadata.location.latitude,
session.metadata.location.longitude,
];
return (
<Dialog>
<DialogTrigger asChild>
{children}
</DialogTrigger>
<DialogContent>
<DialogTitle className="text-xl">
{t('heading')}
</DialogTitle>
<div className="space-y-3">
<div className="flex items-center">
<span className="font-medium">
{t('device')}
</span>
<span className="ml-2 text-muted-foreground">
{session.metadata.device.browser}
,
{' '}
{session.metadata.device.os}
</span>
</div>
<div className="flex items-center">
<span className="font-medium">
{t('location')}
</span>
<span className="ml-2 text-muted-foreground">
{session.metadata.location.country}
,
{' '}
{session.metadata.location.city}
</span>
</div>
<div className="flex items-center">
<span className="font-medium">
{t('ipAddress')}
</span>
<span className="ml-2 text-muted-foreground">
{session.metadata.ip}
</span>
</div>
<div className="flex items-center">
<span className="font-medium">
{t('createdAt')}
</span>
<span className="ml-2 text-muted-foreground">
{formatDate(session.createdAt, true)}
</span>
</div>
<YMaps>
<div style={{ width: '100%', height: '300px' }}>
<Map
defaultState={{
center,
zoom: 11,
}}
height="100%"
width="100%"
>
<Placemark geometry={center} />
</Map>
</div>
</YMaps>
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,58 @@
'use client';
import { useTranslations } from 'next-intl';
import { Heading } from '@/components/ui/elements/Heading';
import { ToggleCardSkeleton } from '@/components/ui/elements/ToggleCard';
import {
useFindCurrentSessionQuery,
useFindSessionsByUserQuery,
} from '@/graphql/generated/output';
import { SessionItem } from './SessionItem';
export const SessionsList = () => {
const t = useTranslations('dashboard.settings.sessions');
const { data: sessionData, loading: isLoadingCurrent } = useFindCurrentSessionQuery();
const currentSession = sessionData?.findCurrentSession;
const { data: sessionsData, loading: isLoadingSessions } = useFindSessionsByUserQuery();
const sessions = sessionsData?.findSessionsByUser ?? [];
if (!currentSession) {
return null;
}
return (
<div className="space-y-6">
<Heading size="sm" title={t('info.current')} />
{isLoadingCurrent
? (
<ToggleCardSkeleton />
)
: (
<SessionItem isCurrentSession session={currentSession} />
)}
<Heading size="sm" title={t('info.active')} />
{/* eslint-disable-next-line no-nested-ternary */}
{isLoadingSessions
? Array.from({ length: 3 }).map((_, index) => (
// eslint-disable-next-line react/no-array-index-key
<ToggleCardSkeleton key={index} />
))
: sessions.length
? sessions.map((session, index) => (
<SessionItem key={session.id} session={session} />
))
: (
<div className="text-muted-foreground">
{t('info.notFound')}
</div>
)}
</div>
);
};

View File

@ -0,0 +1,147 @@
'use client';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import {
type ComponentPropsWithoutRef,
type ComponentRef,
type HTMLAttributes,
forwardRef,
} from 'react';
import { cn } from '@/utils/tw-merge';
import { buttonVariants } from './Button';
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = forwardRef<
ComponentRef<typeof AlertDialogPrimitive.Overlay>,
ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = forwardRef<
ComponentRef<typeof AlertDialogPrimitive.Content>,
ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-2 text-center sm:text-left',
className,
)}
{...props}
/>
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className,
)}
{...props}
/>
);
AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle = forwardRef<
ComponentRef<typeof AlertDialogPrimitive.Title>,
ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold', className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = forwardRef<
ComponentRef<typeof AlertDialogPrimitive.Description>,
ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = forwardRef<
ComponentRef<typeof AlertDialogPrimitive.Action>,
ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = forwardRef<
ComponentRef<typeof AlertDialogPrimitive.Cancel>,
ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: 'secondary' }),
'mt-2 sm:mt-0',
className,
)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
};

View File

@ -0,0 +1,130 @@
'use client';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import {
type ComponentPropsWithoutRef,
type ComponentRef,
type HTMLAttributes,
forwardRef,
} from 'react';
import { cn } from '@/utils/tw-merge';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = forwardRef<
ComponentRef<typeof DialogPrimitive.Overlay>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = forwardRef<
ComponentRef<typeof DialogPrimitive.Content>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="size-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-1.5 text-center sm:text-left',
className,
)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className,
)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = forwardRef<
ComponentRef<typeof DialogPrimitive.Title>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-wide',
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = forwardRef<
ComponentRef<typeof DialogPrimitive.Description>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('textcn-sm text-muted-foreground', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@ -0,0 +1,170 @@
'use client';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import {
type ComponentPropsWithoutRef,
type ComponentRef,
forwardRef,
} from 'react';
import { cn } from '@/utils/tw-merge';
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = forwardRef<
ComponentRef<typeof SelectPrimitive.Trigger>,
ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = forwardRef<
ComponentRef<typeof SelectPrimitive.ScrollUpButton>,
ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = forwardRef<
ComponentRef<typeof SelectPrimitive.ScrollDownButton>,
ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = forwardRef<
ComponentRef<typeof SelectPrimitive.Content>,
ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({
className, children, position = 'popper', ...props
}, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper'
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper'
&& 'h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = forwardRef<
ComponentRef<typeof SelectPrimitive.Label>,
ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = forwardRef<
ComponentRef<typeof SelectPrimitive.Item>,
ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>
{children}
</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = forwardRef<
ComponentRef<typeof SelectPrimitive.Separator>,
ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@ -0,0 +1,33 @@
'use client';
import * as SwitchPrimitives from '@radix-ui/react-switch';
import {
type ComponentPropsWithoutRef,
type ComponentRef,
forwardRef,
} from 'react';
import { cn } from '@/utils/tw-merge';
const Switch = forwardRef<
ComponentRef<typeof SwitchPrimitives.Root>,
ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted-foreground',
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View File

@ -0,0 +1,124 @@
import {
type HTMLAttributes,
type TdHTMLAttributes,
type ThHTMLAttributes,
forwardRef,
} from 'react';
import { cn } from '@/utils/tw-merge';
const Table = forwardRef<HTMLTableElement, HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
),
);
Table.displayName = 'Table';
const TableHeader = forwardRef<
HTMLTableSectionElement,
HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
));
TableHeader.displayName = 'TableHeader';
const TableBody = forwardRef<
HTMLTableSectionElement,
HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
));
TableBody.displayName = 'TableBody';
const TableFooter = forwardRef<
HTMLTableSectionElement,
HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
className,
)}
{...props}
/>
));
TableFooter.displayName = 'TableFooter';
const TableRow = forwardRef<
HTMLTableRowElement,
HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'border-b transition-colors hover:bg-card data-[state=selected]:bg-muted',
className,
)}
{...props}
/>
));
TableRow.displayName = 'TableRow';
const TableHead = forwardRef<
HTMLTableCellElement,
ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
className,
)}
{...props}
/>
));
TableHead.displayName = 'TableHead';
const TableCell = forwardRef<
HTMLTableCellElement,
TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
'px-4 py-2 align-middle [&:has([role=checkbox])]:pr-0',
className,
)}
{...props}
/>
));
TableCell.displayName = 'TableCell';
const TableCaption = forwardRef<
HTMLTableCaptionElement,
HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn('mt-4 text-sm text-muted-foreground', className)}
{...props}
/>
));
TableCaption.displayName = 'TableCaption';
export {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
};

View File

@ -0,0 +1,61 @@
'use client';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import {
type ComponentPropsWithoutRef,
type ComponentRef,
forwardRef,
} from 'react';
import { cn } from '@/utils/tw-merge';
const Tabs = TabsPrimitive.Root;
const TabsList = forwardRef<
ComponentRef<typeof TabsPrimitive.List>,
ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-10 items-center justify-center rounded-md bg-card p-1 text-muted-foreground',
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = forwardRef<
ComponentRef<typeof TabsPrimitive.Trigger>,
ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'data-data-[state=active]:shadow-sm inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium text-foreground ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-accent',
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = forwardRef<
ComponentRef<typeof TabsPrimitive.Content>,
ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export {
Tabs, TabsContent, TabsList, TabsTrigger,
};

View File

@ -0,0 +1,19 @@
import { type ComponentProps, forwardRef } from 'react';
import { cn } from '@/utils/tw-merge';
const Textarea = forwardRef<HTMLTextAreaElement, ComponentProps<'textarea'>>(
({ className, ...props }, ref) => (
<textarea
ref={ref}
className={cn(
'flex max-h-[80px] min-h-[80px] w-full rounded-md border border-border bg-input px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:border-primary focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
),
);
Textarea.displayName = 'Textarea';
export { Textarea };

View File

@ -0,0 +1,68 @@
import { cn } from '@/utils/tw-merge';
import { Card } from '../common/Card';
import type { LucideIcon } from 'lucide-react';
import type { PropsWithChildren, ReactNode } from 'react';
import type { IconType } from 'react-icons';
type CardContainerProps = {
heading: string;
description?: string;
Icon?: IconType | LucideIcon;
isRightContentFull?: boolean;
rightContent?: ReactNode;
};
export const CardContainer = ({
heading,
description,
Icon,
isRightContentFull,
rightContent,
children,
}: PropsWithChildren<CardContainerProps>) => (
<Card className="p-4">
<div className="flex items-center justify-between">
<div className="flex flex-row items-center gap-x-4">
{Icon
? (
<div className="rounded-full bg-foreground p-2.5">
<Icon className="size-7 text-secondary" />
</div>
)
: null}
<div className="space-y-1">
<h2 className="font-semibold tracking-wide">
{heading}
</h2>
{description
? (
<p className="max-w-4xl text-sm text-muted-foreground">
{description}
</p>
)
: null}
</div>
</div>
{rightContent
? (
<div className={cn(isRightContentFull && 'ml-6 w-full')}>
{rightContent}
</div>
)
: null}
</div>
{children
? (
<div className="mt-4">
{children}
</div>
)
: null}
</Card>
);

View File

@ -34,10 +34,14 @@ export const ChannelAvatar = ({ size, channel, isLive }: ChannelAvatarProps) =>
isLive && 'ring-2 ring-rose-500',
)}
>
{channel.avatar
? (
<AvatarImage
className="object-cover"
src={getMediaSource(channel.avatar)}
/>
)
: null}
<AvatarFallback
className={cn(

View File

@ -0,0 +1,23 @@
'use client';
import { useEffect } from 'react';
import { useConfig } from '@/hooks/useConfig';
export const ColorSwitcher = () => {
const { theme } = useConfig();
useEffect(() => {
document.body.classList.forEach((className) => {
if ((/^theme.*/).exec(className)) {
document.body.classList.remove(className);
}
});
if (theme) {
document.body.classList.add(`theme-${theme}`);
}
}, [theme]);
return null;
};

View File

@ -0,0 +1,62 @@
'use client';
import { useTranslations } from 'next-intl';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '../common/AlertDialog';
import type { PropsWithChildren } from 'react';
type ConfirmModalProps = {
heading: string;
message: string;
onConfirm: () => void;
};
export const ConfirmModal = ({
children,
heading,
message,
onConfirm,
}: PropsWithChildren<ConfirmModalProps>) => {
const t = useTranslations('components.confirmModal');
return (
<AlertDialog>
<AlertDialogTrigger asChild>
{children}
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{heading}
</AlertDialogTitle>
<AlertDialogDescription>
{message}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t('cancel')}
</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>
{t('continue')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@ -0,0 +1,40 @@
import { Check, Copy } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { toast } from 'sonner';
import { Button } from '../common/Button';
type CopyButtonProps = {
value?: string | null;
};
export const CopyButton = ({ value }: CopyButtonProps) => {
const t = useTranslations('components.copyButton');
const [isCopied, setIsCopied] = useState(false);
function onCopy() {
if (!value) return;
setIsCopied(true);
void navigator.clipboard.writeText(value);
toast.success(t('successMessage'));
setTimeout(() => {
setIsCopied(false);
}, 2000);
}
const Icon = isCopied ? Check : Copy;
return (
<Button
disabled={!value || isCopied}
size="lgIcon"
variant="ghost"
onClick={() => { onCopy(); }}
>
<Icon className="size-5" />
</Button>
);
};

View File

@ -0,0 +1,99 @@
'use client';
import {
type ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import { Loader } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Card } from '../common/Card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '../common/Table';
type DataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[];
data: TData[];
};
export const DataTable = <TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) => {
const t = useTranslations('components.dataTable');
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<div className="rounded-lg border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef
.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length
? table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
: (
<TableRow>
<TableCell
className="h-24 text-center"
colSpan={columns.length}
>
{t('notFound')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
};
export const DataTableSkeleton = () => (
<div className="mx-auto mb-10 w-full max-w-(--breakpoint-2xl)">
<Card className="mt-6 flex h-[500px] w-full items-center justify-center">
<Loader className="size-8 animate-spin text-muted-foreground" />
</Card>
</div>
);

View File

@ -0,0 +1,26 @@
import {
Card, CardContent, CardHeader, CardTitle,
} from '../common/Card';
import type { PropsWithChildren } from 'react';
type FormWrapperProps = {
heading: string;
};
export const FormWrapper = ({
children,
heading,
}: PropsWithChildren<FormWrapperProps>) => (
<Card>
<CardHeader className="p-4">
<CardTitle className="text-lg">
{heading}
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{children}
</CardContent>
</Card>
);

View File

@ -0,0 +1,43 @@
import { type VariantProps, cva } from 'class-variance-authority';
import { cn } from '@/utils/tw-merge';
const headingSizes = cva('', {
variants: {
size: {
sm: 'text-lg',
default: 'text-2xl',
lg: 'text-4xl',
xl: 'text-5xl',
},
},
defaultVariants: {
size: 'default',
},
});
type HeadingProps = VariantProps<typeof headingSizes> & {
title: string;
description?: string;
};
export const Heading = ({ size, title, description }: HeadingProps) => (
<div className="space-y-2">
<h1
className={cn(
'font-semibold text-foreground',
headingSizes({ size }),
)}
>
{title}
</h1>
{description
? (
<p className="text-muted-foreground">
{description}
</p>
)
: null}
</div>
);

View File

@ -0,0 +1,34 @@
import { Skeleton } from '../common/Skeleton';
import { Switch } from '../common/Switch';
import { CardContainer } from './CardContainer';
type ToggleCardProps = {
heading: string;
description: string;
isDisabled?: boolean;
value: boolean;
onChange: (value: boolean) => void;
};
export const ToggleCard = ({
heading,
description,
isDisabled,
value,
onChange,
}: ToggleCardProps) => (
<CardContainer
description={description}
heading={heading}
rightContent={(
<Switch
checked={value}
disabled={isDisabled}
onCheckedChange={onChange}
/>
)}
/>
);
export const ToggleCardSkeleton = () => <Skeleton className="mt-6 h-20 w-full" />;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,8 @@
mutation DeactivateAccount($data: DeactivateAccountInput!) {
deactivateAccount(data: $data) {
user {
isDeactivated
}
message
}
}

View File

@ -0,0 +1,5 @@
mutation ChangeChatSettings($data: ChangeChatSettingsInput!) {
changeChatSettings(data: $data) {
id
}
}

View File

@ -0,0 +1,5 @@
mutation SendChatMessage($data: SendMessageInput!) {
sendChatMessage(data: $data) {
streamId
}
}

View File

@ -0,0 +1,5 @@
mutation CreateSponsorshipPlan($data: CreatePlanInput!) {
createSponsorshipPlan(data: $data) {
id
}
}

View File

@ -0,0 +1,5 @@
mutation RemoveSponsorshipPlan($planId: String!) {
removeSponsorshipPlan(planId: $planId) {
id
}
}

View File

@ -0,0 +1,5 @@
mutation MakePayment($planId: String!) {
makePayment(planId: $planId) {
url
}
}

View File

@ -0,0 +1,3 @@
mutation CreateIngress($ingressType: Float!) {
createIngress(ingressType: $ingressType)
}

View File

@ -0,0 +1,6 @@
mutation ChangeEmail($data: ChangeEmailInput!) {
changeEmail(data: $data) {
email
id
}
}

View File

@ -0,0 +1,9 @@
mutation ChangeNotificationsSettings($data: ChangeNotificationSettingsInput!) {
changeNotificationSettings(data: $data) {
notificationSettings {
siteNotifications
telegramNotifications
}
telegramAuthToken
}
}

View File

@ -0,0 +1,6 @@
mutation ChangePassword($data: ChangePasswordInput!) {
changePassword(data: $data) {
name
id
}
}

View File

@ -0,0 +1,3 @@
mutation ChangeProfileAvatar($avatar: Upload!) {
changeProfileAvatar(avatar: $avatar)
}

View File

@ -0,0 +1,7 @@
mutation ChangeProfileInfo($data: ChangeProfileInfoInput!) {
changeProfileInfo(data: $data) {
name
id
displayName
}
}

View File

@ -0,0 +1,3 @@
mutation ClearSessionCookie {
clearSessionCookie
}

View File

@ -0,0 +1,5 @@
mutation CreateSocialLink($data: SocialLinkInput!) {
createSocialLink(data: $data) {
id
}
}

View File

@ -0,0 +1,3 @@
mutation DisableTotp {
disableTotp
}

View File

@ -0,0 +1,3 @@
mutation EnableTotp($data: EnableTotpInput!) {
enableTotp(data: $data)
}

View File

@ -0,0 +1,3 @@
mutation RemoveProfileAvatar {
removeProfileAvatar
}

View File

@ -0,0 +1,3 @@
mutation RemoveSession($id: String!) {
removeSession(id: $id)
}

View File

@ -0,0 +1,3 @@
mutation RemoveSocialLink($id: String!) {
removeSocialLink(id: $id)
}

View File

@ -0,0 +1,3 @@
mutation ReorderSocialLinks($list: [SocialLinkOrderInput!]!) {
reorderSocialLink(list: $list)
}

View File

@ -0,0 +1,5 @@
mutation UpdateSocialLink($id: String!, $data: SocialLinkInput!) {
updateSocialLink(id: $id, data: $data) {
id
}
}

View File

@ -0,0 +1,10 @@
query FindMyFollowers {
findMyFollowers {
createdAt
follower {
name
avatar
isVerified
}
}
}

View File

@ -0,0 +1,6 @@
query FindMyFollowings {
findMyFollowings {
createdAt
followingId
}
}

View File

@ -0,0 +1,8 @@
query FindMySponsorshipPlans {
findMySponsorshipPlans {
id
createdAt
title
price
}
}

View File

@ -0,0 +1,13 @@
query FindMySponsors {
findMySponsors {
expiresAt
user {
name
avatar
isVerified
}
plan {
title
}
}
}

View File

@ -0,0 +1,7 @@
query FindMyTransactions {
findMyTransactions {
createdAt
status
amount
}
}

View File

@ -0,0 +1,19 @@
query FindCurrentSession {
findCurrentSession {
id
createdAt
metadata {
location {
country
city
latitude
longitude
}
device {
browser
os
}
ip
}
}
}

View File

@ -16,5 +16,16 @@ query FindProfile {
url
position
}
notificationSettings {
siteNotifications
telegramNotifications
}
stream {
serverUrl
key
isChatEnable
isChatFollowersOnly
isChatPremiumFollowersOnly
}
}
}

View File

@ -0,0 +1,19 @@
query FindSessionsByUser {
findSessionsByUser {
id
createdAt
metadata {
location {
country
city
latitude
longitude
}
device {
browser
os
}
ip
}
}
}

View File

@ -0,0 +1,8 @@
query FindSocialLinks {
findSocialLinks {
id
title
url
position
}
}

View File

@ -0,0 +1,6 @@
query GenerateTotpSecret {
generateTotpSecret {
qrcodeUrl
secret
}
}

View File

@ -0,0 +1,11 @@
import { configStore } from '@/store/config/config.store';
export function useConfig() {
const theme = configStore((state) => state.theme);
const setTheme = configStore((state) => state.setTheme);
return {
theme,
setTheme,
};
}

View File

@ -1,13 +1,41 @@
import { ApolloClient, createHttpLink, InMemoryCache } from '@apollo/client';
import { ApolloClient, InMemoryCache, split } from '@apollo/client';
import { WebSocketLink } from '@apollo/client/link/ws';
import { getMainDefinition } from '@apollo/client/utilities';
import createUploadLink from 'apollo-upload-client/createUploadLink.mjs';
import { OperationTypeNode } from 'graphql/language';
import { Kind } from 'graphql/language/kinds';
import { SERVER_URL } from './constants/url.constants';
import { SERVER_URL, WEBSOCKET_URL } from './constants/url.constants';
const httpLink = createHttpLink({
const httpLink = createUploadLink({
uri: SERVER_URL,
credentials: 'include',
headers: {
'apollo-require-preflight': 'true',
},
});
const wsLink = new WebSocketLink({
uri: WEBSOCKET_URL,
options: {
reconnect: true,
},
});
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === Kind.OPERATION_DEFINITION
&& definition.operation === OperationTypeNode.SUBSCRIPTION
);
},
wsLink,
httpLink,
);
export const client = new ApolloClient({
link: httpLink,
link: splitLink,
cache: new InMemoryCache(),
});

View File

@ -0,0 +1,36 @@
export const BASE_COLORS = [
{
name: 'violet',
color: '262.1 83.3% 57.8%',
},
{
name: 'blue',
color: '204, 70%, 53%',
},
{
name: 'turquoise',
color: '176, 77%, 41%',
},
{
name: 'yellow',
color: '48, 89%, 50%',
},
{
name: 'peach',
color: '17, 94%, 67%',
},
{
name: 'pink',
color: '330.4 81.2% 60.4%',
},
{
name: 'rose',
color: '340, 82%, 52%',
},
{
name: 'red',
color: '0 72.2% 50.6%',
},
] as const;
export type TypeBaseColor = (typeof BASE_COLORS)[number]['name'];

View File

@ -0,0 +1,15 @@
export const NO_INDEX_PAGE = { robots: { index: false, follow: false } };
export const SITE_NAME = 'TeaStream';
export const SITE_DESCRIPTION = 'TeaStream — это платформа для прямых трансляций, которая соединяет стримеров и зрителей. Делитесь своими увлечениями и присоединяйтесь к сообществу TeaStream!';
export const SITE_KEYWORDS = [
'TeaStream',
'платформа для прямых трансляций',
'стриминг',
'прямые трансляции',
'взаимодействие со зрителями',
'видео-контент',
'онлайн-сообщество',
];

View File

@ -1,2 +1,3 @@
export const SERVER_URL = process.env.NEXT_PUBLIC_SERVER_URL as string;
export const WEBSOCKET_URL = process.env.NEXT_PUBLIC_WEBSOCKET_URL as string;
export const MEDIA_URL = process.env.NEXT_PUBLIC_MEDIA_URL as string;

View File

@ -0,0 +1,9 @@
import { z } from 'zod';
export const deactivateSchema = z.object({
email: z.email(),
password: z.string().min(8),
pin: z.string().optional(),
});
export type TypeDeactivateSchema = z.infer<typeof deactivateSchema>;

View File

@ -0,0 +1,11 @@
import { z } from 'zod';
export const changeChatSettingsSchema = z.object({
isChatEnable: z.boolean(),
isChatFollowersOnly: z.boolean(),
isChatPremiumFollowersOnly: z.boolean(),
});
export type TypeChangeChatSettingsSchema = z.infer<
typeof changeChatSettingsSchema
>;

View File

@ -0,0 +1,7 @@
import { z } from 'zod';
export const sendMessageSchema = z.object({
text: z.string().min(1),
});
export type TypeSendMessageSchema = z.infer<typeof sendMessageSchema>;

Some files were not shown because too many files have changed in this diff Show More