diff --git a/app/(protected)/team/(team)/invitations/page.tsx b/app/(protected)/team/(team)/invitations/page.tsx deleted file mode 100644 index 9f9f851..0000000 --- a/app/(protected)/team/(team)/invitations/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { InvitationsPage as default } from 'pages/team'; diff --git a/app/(protected)/team/(team)/layout.tsx b/app/(protected)/team/(team)/layout.tsx deleted file mode 100644 index 3ca574e..0000000 --- a/app/(protected)/team/(team)/layout.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { PageLayout } from 'app/layouts/PageLayout'; -import { teamTabs } from 'pages/team'; -import { Badge } from 'shared/ui'; - -export default function TeamLayout({ children }: { children: React.ReactNode }) { - return ( - 8 участников} - tabs={teamTabs} - > - {children} - - ); -} diff --git a/app/(protected)/team/(team)/members/page.tsx b/app/(protected)/team/(team)/members/page.tsx deleted file mode 100644 index 74be9f9..0000000 --- a/app/(protected)/team/(team)/members/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { MembersPage as default } from 'pages/team'; diff --git a/app/(protected)/team/(team)/roles/page.tsx b/app/(protected)/team/(team)/roles/page.tsx deleted file mode 100644 index f81d7e5..0000000 --- a/app/(protected)/team/(team)/roles/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { RolesPage as default } from 'pages/team'; diff --git a/app/(protected)/team/(team)/settings/page.tsx b/app/(protected)/team/(team)/settings/page.tsx deleted file mode 100644 index 420eb16..0000000 --- a/app/(protected)/team/(team)/settings/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { Settings as default } from 'pages/team'; diff --git a/app/(protected)/team/invitations/error.tsx b/app/(protected)/team/invitations/error.tsx new file mode 100644 index 0000000..4aa4c7d --- /dev/null +++ b/app/(protected)/team/invitations/error.tsx @@ -0,0 +1,15 @@ +'use client'; + +import { ErrorState } from 'widgets/error-state'; + +export default function Error({ unstable_retry }: { unstable_retry: () => void }) { + return ( + unstable_retry()} + className="border" + /> + ); +} diff --git a/app/(protected)/team/invitations/page.tsx b/app/(protected)/team/invitations/page.tsx new file mode 100644 index 0000000..2b72773 --- /dev/null +++ b/app/(protected)/team/invitations/page.tsx @@ -0,0 +1 @@ +export { InvitationsPage as default } from 'pages/team/invitations'; diff --git a/app/(protected)/team/members/error.tsx b/app/(protected)/team/members/error.tsx new file mode 100644 index 0000000..3e37ec9 --- /dev/null +++ b/app/(protected)/team/members/error.tsx @@ -0,0 +1,15 @@ +'use client'; + +import { ErrorState } from 'widgets/error-state'; + +export default function Error({ unstable_retry }: { unstable_retry: () => void }) { + return ( + unstable_retry()} + className="border" + /> + ); +} diff --git a/app/(protected)/team/members/page.tsx b/app/(protected)/team/members/page.tsx new file mode 100644 index 0000000..52a3f9d --- /dev/null +++ b/app/(protected)/team/members/page.tsx @@ -0,0 +1 @@ +export { MembersPage as default } from 'pages/team/members'; diff --git a/app/(protected)/team/(team)/page.tsx b/app/(protected)/team/page.tsx similarity index 100% rename from app/(protected)/team/(team)/page.tsx rename to app/(protected)/team/page.tsx diff --git a/app/(protected)/team/projects/[projectSlug]/[boardSlug]/error.tsx b/app/(protected)/team/projects/[projectSlug]/[boardSlug]/error.tsx index 3865873..756c188 100644 --- a/app/(protected)/team/projects/[projectSlug]/[boardSlug]/error.tsx +++ b/app/(protected)/team/projects/[projectSlug]/[boardSlug]/error.tsx @@ -2,12 +2,7 @@ import { ErrorState } from 'widgets/error-state'; -export default function Error({ - unstable_retry, -}: { - error: Error & { digest?: string }; - unstable_retry: () => void; -}) { +export default function Error({ unstable_retry }: { unstable_retry: () => void }) { return ( void; -}) { +export default function Error({ unstable_retry }: { unstable_retry: () => void }) { return ( void; -}) { +export default function Error({ unstable_retry }: { unstable_retry: () => void }) { return ( void; -}) { +export default function Error({ unstable_retry }: { unstable_retry: () => void }) { return ( void }) { + return ( + unstable_retry()} + className="border" + /> + ); +} diff --git a/app/(protected)/team/settings/page.tsx b/app/(protected)/team/settings/page.tsx new file mode 100644 index 0000000..e85e395 --- /dev/null +++ b/app/(protected)/team/settings/page.tsx @@ -0,0 +1 @@ +export { SettingsPage as default } from 'pages/team/settings'; diff --git a/app/(protected)/user/(user)/layout.tsx b/app/(protected)/user/(user)/layout.tsx index 5ee9d2d..34a3979 100644 --- a/app/(protected)/user/(user)/layout.tsx +++ b/app/(protected)/user/(user)/layout.tsx @@ -1,14 +1,23 @@ -import { PageLayout } from 'app/layouts/PageLayout'; -import { profileTabs } from 'pages/profile'; +import { Bell, Settings } from 'lucide-react'; +import { routes } from 'shared/config'; +import { PageWrapper } from 'widgets/page-wrapper'; +import { type TabNavItem, VerticalTabsNav } from 'widgets/tabs-nav'; + +const tabs: TabNavItem[] = [ + { key: routes.user.profile(), label: 'Общие', icon: }, + { key: routes.user.notifications(), label: 'Уведомления', icon: }, +]; export default function ProfileLayout({ children }: { children: React.ReactNode }) { return ( - - {children} - +
+ + {children} +
+ ); } diff --git a/app/(protected)/user/(user)/notifications/error.tsx b/app/(protected)/user/(user)/notifications/error.tsx new file mode 100644 index 0000000..c80f26b --- /dev/null +++ b/app/(protected)/user/(user)/notifications/error.tsx @@ -0,0 +1,15 @@ +'use client'; + +import { ErrorState } from 'widgets/error-state'; + +export default function Error({ unstable_retry }: { unstable_retry: () => void }) { + return ( + unstable_retry()} + className="border" + /> + ); +} diff --git a/app/(protected)/user/(user)/notifications/page.tsx b/app/(protected)/user/(user)/notifications/page.tsx index 4344000..2f8866c 100644 --- a/app/(protected)/user/(user)/notifications/page.tsx +++ b/app/(protected)/user/(user)/notifications/page.tsx @@ -1 +1 @@ -export { NotificationsPage as default } from 'pages/profile'; +export { NotificationsPage as default } from 'pages/user/notifications'; diff --git a/app/(protected)/user/(user)/profile/error.tsx b/app/(protected)/user/(user)/profile/error.tsx new file mode 100644 index 0000000..358d6e2 --- /dev/null +++ b/app/(protected)/user/(user)/profile/error.tsx @@ -0,0 +1,15 @@ +'use client'; + +import { ErrorState } from 'widgets/error-state'; + +export default function Error({ unstable_retry }: { unstable_retry: () => void }) { + return ( + unstable_retry()} + className="border" + /> + ); +} diff --git a/app/(protected)/user/(user)/profile/page.tsx b/app/(protected)/user/(user)/profile/page.tsx index 284e44f..cc2114f 100644 --- a/app/(protected)/user/(user)/profile/page.tsx +++ b/app/(protected)/user/(user)/profile/page.tsx @@ -1 +1 @@ -export { MePage as default } from 'pages/profile'; +export { UserPage as default } from 'pages/user/profile'; diff --git a/app/(protected)/user/(user)/security/page.tsx b/app/(protected)/user/(user)/security/page.tsx deleted file mode 100644 index dc4ccd9..0000000 --- a/app/(protected)/user/(user)/security/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { SecurityPage as default } from 'pages/profile'; diff --git a/app/(protected)/user/teams/@invitations/error.tsx b/app/(protected)/user/teams/@invitations/error.tsx index 4408046..4aa4c7d 100644 --- a/app/(protected)/user/teams/@invitations/error.tsx +++ b/app/(protected)/user/teams/@invitations/error.tsx @@ -2,12 +2,7 @@ import { ErrorState } from 'widgets/error-state'; -export default function Error({ - unstable_retry, -}: { - error: Error & { digest?: string }; - unstable_retry: () => void; -}) { +export default function Error({ unstable_retry }: { unstable_retry: () => void }) { return ( void; -}) { +export default function Error({ unstable_retry }: { unstable_retry: () => void }) { return ( void; }) { return ( diff --git a/next-env.d.ts b/next-env.d.ts index c05d9f7..cdb6b7b 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,7 +1,7 @@ /// /// /// -import './.next/types/routes.d.ts'; +import './.next/dev/types/routes.d.ts'; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/app/layouts/PageLayout.tsx b/src/app/layouts/PageLayout.tsx deleted file mode 100644 index cf44ab8..0000000 --- a/src/app/layouts/PageLayout.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { ComponentProps } from 'react'; -import { PageLayout as PageLayoutParent } from 'widgets/page-layout'; -import { TabsNav } from 'widgets/tabs-nav'; - -interface PageLayoutProps extends Omit, 'nav'> { - tabs?: ComponentProps['tabs']; -} - -export function PageLayout({ children, tabs, ...props }: PageLayoutProps) { - return ( - : undefined} {...props}> - {children} - - ); -} diff --git a/src/app/layouts/SidebarLayout.tsx b/src/app/layouts/SidebarLayout.tsx index 81104ae..533598a 100644 --- a/src/app/layouts/SidebarLayout.tsx +++ b/src/app/layouts/SidebarLayout.tsx @@ -12,7 +12,7 @@ export function SidebarLayout({ children, ...props }: ComponentProps - +
diff --git a/src/entities/auth/api/queries.ts b/src/entities/auth/api/queries.ts index fc28d30..5f8ba53 100644 --- a/src/entities/auth/api/queries.ts +++ b/src/entities/auth/api/queries.ts @@ -1,6 +1,7 @@ import { queryOptions } from '@tanstack/react-query'; -import { AuthHttp } from './http'; +import type { ConnectedOAuthProvidersResponse, OAuthProvider } from '../model/types'; import { authFabricKeys } from '../model/const'; +import { AuthHttp } from './http'; export class AuthQueries { static getOAuthProviders() { @@ -15,6 +16,14 @@ export class AuthQueries { queryKey: authFabricKeys.connectedProviders(), queryFn: async ({ signal }) => AuthHttp.connectedOAuthProviders(signal), staleTime: 60_000 * 360 * 24, + select: (data) => + data.reduce( + (acc, v) => { + acc[v.provider] = v; + return acc; + }, + {} as Record + ), }); } } diff --git a/src/entities/auth/model/schemas.ts b/src/entities/auth/model/schemas.ts index e8f58ab..d98d381 100644 --- a/src/entities/auth/model/schemas.ts +++ b/src/entities/auth/model/schemas.ts @@ -90,7 +90,7 @@ export const ConnectedOAuthProvidersResponse = z .object({ email: Email, avatarUrl: z.string().nullable(), - provider: z.string(), + provider: OAuthProvider, connectedAt: z.string(), }) .array(); diff --git a/src/entities/user/api/http.ts b/src/entities/user/api/http.ts index b2dc8ab..3e07f1c 100644 --- a/src/entities/user/api/http.ts +++ b/src/entities/user/api/http.ts @@ -1,6 +1,6 @@ import { api } from 'shared/api'; -import * as TUser from '../model/types'; import * as SUser from '../model/schemas'; +import type * as TUser from '../model/types'; export class UserHttp { static getUser(signal?: AbortSignal) { diff --git a/src/entities/user/model/schemas.ts b/src/entities/user/model/schemas.ts index 2bfb22c..facb05d 100644 --- a/src/entities/user/model/schemas.ts +++ b/src/entities/user/model/schemas.ts @@ -1,5 +1,4 @@ import { DateTimeString, GlobalSuccess } from 'shared/api'; -import { PaginatedResponseSchema } from 'shared/api/'; import { z } from 'zod/v4'; export const UserAvatarSchema = z @@ -91,14 +90,11 @@ export const ProfileUpdateBody = z.object({ headline: z.string().max(100, 'Должность слишком длинная').nullable().optional(), location: z.string().max(100, 'Локация слишком длинная').nullable().optional(), phone: z.string().max(20, 'Номер телефона слишком длинный').nullable().optional(), - gender: z - .enum(['none', 'male', 'female', 'non_binary', 'other', 'prefer_not_to_say']) - .default('none') - .optional(), + gender: z.enum(['none', 'male', 'female', 'non_binary', 'other', 'prefer_not_to_say']).optional(), vacationStart: z.string().nullable().optional(), vacationEnd: z.string().nullable().optional(), vacationMessage: z.string().max(500, 'Сообщение слишком длинное').nullable().optional(), - pronouns: z.enum(['he_him', 'she_her', 'they_them', 'other', 'none']).default('none').optional(), + pronouns: z.enum(['he_him', 'she_her', 'they_them', 'other', 'none']).optional(), pronounsCustom: z.string().max(50, 'Максимальная длина 50 символов').nullable().optional(), bio: z.string().max(1000, 'О себе не более 1000 символов').nullable().optional(), timezone: z.string().max(50).optional(), diff --git a/src/features/auth/oauth-login/ui/OAuthLoginButtonsContent.tsx b/src/features/auth/oauth-login/ui/OAuthLoginButtonsContent.tsx index d0d1e38..d21d0d5 100644 --- a/src/features/auth/oauth-login/ui/OAuthLoginButtonsContent.tsx +++ b/src/features/auth/oauth-login/ui/OAuthLoginButtonsContent.tsx @@ -34,7 +34,7 @@ export function OAuthLoginButtonsContent({ className }: OAuthLoginButtonsContent type="button" className={data.className} key={value} - variant={'outline'} + variant="outline" size="icon" onClick={() => startOAuth(value)} > diff --git a/src/pages/main/config/landing.ts b/src/pages/main/config/landing.ts new file mode 100644 index 0000000..276d851 --- /dev/null +++ b/src/pages/main/config/landing.ts @@ -0,0 +1,223 @@ +import type { LucideIcon } from 'lucide-react'; +import { + Building2, + GraduationCap, + HardHat, + Languages, + LayoutTemplate, + MousePointerClick, + Scissors, + Store, + Users, + UtensilsCrossed, +} from 'lucide-react'; +import type { Route } from 'next'; +import { routes } from 'shared/config'; + +interface LandingFeature { + icon: LucideIcon; + title: string; + description: string; +} + +interface LandingTemplate { + icon: LucideIcon; + title: string; + description: string; + columns: [string, string, string]; + accent: string; +} + +interface LandingStep { + title: string; + description: string; +} + +interface LandingTestimonial { + quote: string; + name: string; + role: string; + business: string; +} + +const landingIndustries = [ + 'Магазины и розница', + 'Салоны и сервисы', + 'Рестораны и кафе', + 'Офисы и администрация', + 'Строительство и ремонт', + 'Обучение и курсы', + 'Производство и склады', + 'Клиники и медцентры', + 'Логистика и доставка', + 'Агентства и студии', +] as const; + +const landingHeroAudience = + 'Для розницы, услуг, общепита, офисов, строительства, образования, медицины, логистики и любых команд, где важна простота.'; + +const landingFeatures: LandingFeature[] = [ + { + icon: LayoutTemplate, + title: 'Готовые доски под ваш бизнес', + description: + 'Не нужно придумывать структуру с нуля. Выберите шаблон под свою сферу — от магазина и салона до стройки и учебного центра — и сразу работайте.', + }, + { + icon: Languages, + title: 'Понятные слова', + description: + 'Никаких спринтов, бэклогов и канбан-досок. Только привычные формулировки: «новые заявки», «в работе», «готово».', + }, + { + icon: MousePointerClick, + title: 'Низкий порог входа', + description: + 'Разберётся любой сотрудник за несколько минут. Без обучения, вебинаров и длинных инструкций.', + }, + { + icon: Users, + title: 'Команда на одной волне', + description: + 'Пригласите коллег по ссылке. Все видят одни и те же задачи и статусы — без путаницы в чатах.', + }, +]; + +const landingTemplates: LandingTemplate[] = [ + { + icon: Store, + title: 'Магазин', + description: 'Заказы, поставки и выкладка товара', + columns: ['Новые заказы', 'Собираем', 'Выдано'], + accent: 'bg-blue-500/10 text-blue-700 dark:text-blue-300', + }, + { + icon: Scissors, + title: 'Салон красоты', + description: 'Записи, мастера и расходники', + columns: ['Записи', 'Клиент в кресле', 'Завершено'], + accent: 'bg-pink-500/10 text-pink-700 dark:text-pink-300', + }, + { + icon: UtensilsCrossed, + title: 'Ресторан', + description: 'Заказы, кухня и зал', + columns: ['Принято', 'Готовим', 'Подано'], + accent: 'bg-orange-500/10 text-orange-700 dark:text-orange-300', + }, + { + icon: Building2, + title: 'Офис', + description: 'Поручения, согласования, документы', + columns: ['Новые', 'В работе', 'Сделано'], + accent: 'bg-violet-500/10 text-violet-700 dark:text-violet-300', + }, + { + icon: HardHat, + title: 'Строительство', + description: 'Объекты, бригады и снабжение', + columns: ['План', 'На объекте', 'Сдано'], + accent: 'bg-amber-500/10 text-amber-700 dark:text-amber-300', + }, + { + icon: GraduationCap, + title: 'Обучение', + description: 'Группы, материалы и расписание', + columns: ['Записались', 'Идёт курс', 'Выпуск'], + accent: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300', + }, +]; + +const landingSteps: LandingStep[] = [ + { + title: 'Выберите шаблон', + description: 'Укажите сферу бизнеса — доска уже настроена под ваши процессы.', + }, + { + title: 'Пригласите команду', + description: 'Отправьте ссылку коллегам. Каждый сразу видит свои задачи.', + }, + { + title: 'Ведите дела каждый день', + description: 'Перетаскивайте карточки по этапам. Всё наглядно и без лишних слов.', + }, +]; + +const landingTestimonials: LandingTestimonial[] = [ + { + quote: + 'Раньше всё было в блокноте и WhatsApp. Теперь вся команда видит, что на ком висит — и никто не теряет заказы.', + name: 'Анна К.', + role: 'Владелица', + business: 'салон красоты', + }, + { + quote: + 'Нам не нужны были «спринты» и «бэклоги». Здесь всё по-человечески: приняли заказ, готовим, отдали.', + name: 'Игорь М.', + role: 'Управляющий', + business: 'кофейня', + }, + { + quote: + 'Подключили офис за один день. Бухгалтерия, закупки и администраторы — все работают в одной системе.', + name: 'Елена С.', + role: 'Директор', + business: 'торговая компания', + }, +]; + +interface LandingFooterLink { + label: string; + href: Route | `#${string}`; +} + +interface LandingFooterSection { + title: string; + links: LandingFooterLink[]; +} + +const landingFooterDescription = + 'Трекер задач для обычного бизнеса. Готовые доски, понятные слова и быстрый старт без обучения.'; + +const landingFooterSections: LandingFooterSection[] = [ + { + title: 'Продукт', + links: [ + { label: 'Возможности', href: '#features' }, + { label: 'Шаблоны досок', href: '#templates' }, + { label: 'Как начать', href: '#how-it-works' }, + ], + }, + { + title: 'Аккаунт', + links: [ + { label: 'Регистрация', href: routes.auth.signup() }, + { label: 'Вход', href: routes.auth.signin() }, + ], + }, +]; + +const landingFooterHighlights = [ + 'Бесплатный старт', + 'Без кредитной карты', + 'Настройка за 5 минут', +] as const; + +export { + landingFeatures, + landingFooterDescription, + landingFooterHighlights, + landingFooterSections, + landingHeroAudience, + landingIndustries, + landingSteps, + landingTemplates, + landingTestimonials, + type LandingFeature, + type LandingFooterLink, + type LandingFooterSection, + type LandingStep, + type LandingTemplate, + type LandingTestimonial, +}; diff --git a/src/pages/main/ui/BoardPreview.tsx b/src/pages/main/ui/BoardPreview.tsx new file mode 100644 index 0000000..296b0e5 --- /dev/null +++ b/src/pages/main/ui/BoardPreview.tsx @@ -0,0 +1,41 @@ +interface BoardPreviewProps { + columns: [string, string, string]; + className?: string; +} + +function BoardPreview({ columns, className }: BoardPreviewProps) { + const cards = [ + [2, 1], + [1, 2], + [3, 0], + ] as const; + + return ( +
+ {columns.map((title, columnIndex) => ( +
+

{title}

+
+ {Array.from({ length: cards[columnIndex][0] }).map((_, cardIndex) => ( +
+
+
+
+ ))} +
+
+ ))} +
+ ); +} + +export { BoardPreview }; diff --git a/src/pages/main/ui/LandingCta.tsx b/src/pages/main/ui/LandingCta.tsx new file mode 100644 index 0000000..76c55aa --- /dev/null +++ b/src/pages/main/ui/LandingCta.tsx @@ -0,0 +1,33 @@ +import { ArrowRight } from 'lucide-react'; +import NextLink from 'next/link'; +import { routes } from 'shared/config'; +import { Button } from 'shared/ui'; + +function LandingCta() { + return ( +
+
+

+ Попробуйте сегодня — без сложных настроек +

+

+ Создайте рабочее пространство, выберите шаблон под свой бизнес и пригласите команду. + Первые задачи можно добавить уже через пять минут. +

+
+ + +
+
+
+ ); +} + +export { LandingCta }; diff --git a/src/pages/main/ui/LandingFeatures.tsx b/src/pages/main/ui/LandingFeatures.tsx new file mode 100644 index 0000000..81fcaff --- /dev/null +++ b/src/pages/main/ui/LandingFeatures.tsx @@ -0,0 +1,41 @@ +import { landingFeatures } from '../config/landing'; + +function LandingFeatures() { + return ( +
+
+

Почему мы

+

+ Всё, что нужно бизнесу — и ничего лишнего +

+

+ Мы убрали сложность и оставили только то, что помогает команде работать слаженно. +

+
+ +
+ {landingFeatures.map((feature) => { + const Icon = feature.icon; + + return ( +
+
+ +
+

{feature.title}

+

{feature.description}

+
+ ); + })} +
+
+ ); +} + +export { LandingFeatures }; diff --git a/src/pages/main/ui/LandingFooter.tsx b/src/pages/main/ui/LandingFooter.tsx new file mode 100644 index 0000000..63fa19a --- /dev/null +++ b/src/pages/main/ui/LandingFooter.tsx @@ -0,0 +1,85 @@ +import { routes } from 'shared/config'; +import { AppCopyright, Link, Logo } from 'shared/ui'; +import { + landingFooterDescription, + landingFooterHighlights, + landingFooterSections, + landingIndustries, + type LandingFooterLink, +} from '../config/landing'; + +function isAnchorLink(href: LandingFooterLink['href']): href is `#${string}` { + return href.startsWith('#'); +} + +function LandingFooter() { + return ( +
+
+
+ + + +

+ {landingFooterDescription} +

+
    + {landingFooterHighlights.map((highlight) => ( +
  • + {highlight} +
  • + ))} +
+
+ + {landingFooterSections.map((section) => ( + + ))} + +
+

Для кого

+
    + {landingIndustries.map((industry) => ( +
  • {industry}
  • + ))} +
+
+
+ +
+ +

+ Сделано для бизнеса, где важна простота +

+
+
+ ); +} + +export { LandingFooter }; diff --git a/src/pages/main/ui/LandingHeader.tsx b/src/pages/main/ui/LandingHeader.tsx new file mode 100644 index 0000000..587548d --- /dev/null +++ b/src/pages/main/ui/LandingHeader.tsx @@ -0,0 +1,40 @@ +import NextLink from 'next/link'; +import { routes } from 'shared/config'; +import { Button, Link, Logo } from 'shared/ui'; + +const navItems = [ + { href: '#features', label: 'Возможности' }, + { href: '#templates', label: 'Шаблоны' }, + { href: '#how-it-works', label: 'Как начать' }, +] as const; + +function LandingHeader() { + return ( +
+
+ + + + + + +
+ + +
+
+
+ ); +} + +export { LandingHeader }; diff --git a/src/pages/main/ui/LandingHero.tsx b/src/pages/main/ui/LandingHero.tsx new file mode 100644 index 0000000..98fe094 --- /dev/null +++ b/src/pages/main/ui/LandingHero.tsx @@ -0,0 +1,71 @@ +import { ArrowRight } from 'lucide-react'; +import NextLink from 'next/link'; +import { routes } from 'shared/config'; +import { Badge, Button } from 'shared/ui'; +import { landingHeroAudience, landingIndustries } from '../config/landing'; +import { BoardPreview } from './BoardPreview'; + +function LandingHero() { + return ( +
+
+ + Для бизнеса, а не для IT-отделов + + +

+ Ведите дела команды без сложных терминов +

+
+ +
+
+
+

+ {landingHeroAudience} Готовые доски и понятные названия — начните работать в первый же + день. +

+
    + {landingIndustries.map((industry) => ( +
  • + {industry} +
  • + ))} +
+
+ +
+ + +
+ +

+ Бесплатный старт · Без кредитной карты · Настройка за 5 минут +

+
+ +
+
+
+

+ Пример доски «Магазин» +

+ +
+
+
+
+ ); +} + +export { LandingHero }; diff --git a/src/pages/main/ui/LandingProblem.tsx b/src/pages/main/ui/LandingProblem.tsx new file mode 100644 index 0000000..7a47c5b --- /dev/null +++ b/src/pages/main/ui/LandingProblem.tsx @@ -0,0 +1,42 @@ +import { XCircle } from 'lucide-react'; + +const painPoints = [ + 'Спринты, бэклоги и story points — непонятно большинству сотрудников', + 'Нужно проходить обучение, прежде чем можно начать работать', + 'Пустая доска: неясно, с чего начать и как назвать этапы', +] as const; + +function LandingProblem() { + return ( +
+
+
+
+

Знакомо?

+

+ Обычные трекеры созданы для разработчиков, а не для вашей команды +

+

+ Владельцы бизнеса и сотрудники хотят просто видеть, что нужно сделать сегодня. Без + переводчика с «айтишного» на человеческий. +

+
+ +
    + {painPoints.map((point) => ( +
  • + + {point} +
  • + ))} +
+
+
+
+ ); +} + +export { LandingProblem }; diff --git a/src/pages/main/ui/LandingSteps.tsx b/src/pages/main/ui/LandingSteps.tsx new file mode 100644 index 0000000..5dea150 --- /dev/null +++ b/src/pages/main/ui/LandingSteps.tsx @@ -0,0 +1,37 @@ +import { landingSteps } from '../config/landing'; + +function LandingSteps() { + return ( +
+
+

Как начать

+

+ Три шага — и команда уже в работе +

+
+ +
    + {landingSteps.map((step, index) => ( +
  1. + + {String(index + 1).padStart(2, '0')} + +

    {step.title}

    +

    {step.description}

    +
  2. + ))} +
+
+ ); +} + +export { LandingSteps }; diff --git a/src/pages/main/ui/LandingTemplates.tsx b/src/pages/main/ui/LandingTemplates.tsx new file mode 100644 index 0000000..83d1cbc --- /dev/null +++ b/src/pages/main/ui/LandingTemplates.tsx @@ -0,0 +1,50 @@ +import { landingTemplates } from '../config/landing'; +import { BoardPreview } from './BoardPreview'; + +function LandingTemplates() { + return ( +
+
+

Шаблоны

+

+ Готовые доски для разных сфер бизнеса +

+

+ Выберите шаблон под свою отрасль — этапы и формулировки уже подобраны. Останется только + добавить свои задачи. +

+
+ +
+ {landingTemplates.map((template) => { + const Icon = template.icon; + + return ( +
+
+
+ +
+
+

{template.title}

+

{template.description}

+
+
+ +
+ ); + })} +
+
+ ); +} + +export { LandingTemplates }; diff --git a/src/pages/main/ui/LandingTestimonials.tsx b/src/pages/main/ui/LandingTestimonials.tsx new file mode 100644 index 0000000..8a1fa64 --- /dev/null +++ b/src/pages/main/ui/LandingTestimonials.tsx @@ -0,0 +1,37 @@ +import { Quote } from 'lucide-react'; +import { landingTestimonials } from '../config/landing'; + +function LandingTestimonials() { + return ( +
+
+

Отзывы

+

+ Уже помогает реальным командам +

+
+ +
+ {landingTestimonials.map((testimonial) => ( +
+ +

“{testimonial.quote}”

+
+ + {testimonial.name} + + {testimonial.role}, {testimonial.business} + + +
+
+ ))} +
+
+ ); +} + +export { LandingTestimonials }; diff --git a/src/pages/main/ui/MainPage.tsx b/src/pages/main/ui/MainPage.tsx index 2e4317c..e5af2ec 100644 --- a/src/pages/main/ui/MainPage.tsx +++ b/src/pages/main/ui/MainPage.tsx @@ -1,5 +1,12 @@ -import { routes } from 'shared/config'; -import { Button, Link, Logo, Separator } from 'shared/ui'; +import { LandingCta } from './LandingCta'; +import { LandingFeatures } from './LandingFeatures'; +import { LandingFooter } from './LandingFooter'; +import { LandingHeader } from './LandingHeader'; +import { LandingHero } from './LandingHero'; +import { LandingProblem } from './LandingProblem'; +import { LandingSteps } from './LandingSteps'; +import { LandingTemplates } from './LandingTemplates'; +import { LandingTestimonials } from './LandingTestimonials'; interface MainPageProps { className?: string; @@ -7,77 +14,26 @@ interface MainPageProps { function MainPage({ className }: MainPageProps) { return ( -
-
+
+
-
-
-
+
+
-
-
-
-
- - Open Source Task Tracker -
- -
- - -
-
- -
-

- Планируйте спринты и держите фокус команды в одной команде -

-

- Один трекер для продукта, разработки и QA. Все статусы и приоритеты прозрачны в - реальном времени. -

-
-
- - - -
-
-

- С чего начать -

-

- Три шага до первого спринта -

-
- - -
-
- -
    -
  1. -

    01. Создайте проект

    -
  2. -
  3. -

    02. Добавьте команду

    -
  4. -
  5. -

    03. Запустите спринт

    -
  6. -
-
-
-
+ + +
+ + + + + + + + +
+
); } diff --git a/src/pages/profile/api/useConnectedAccounts.ts b/src/pages/profile/api/useConnectedAccounts.ts deleted file mode 100644 index 8518a9d..0000000 --- a/src/pages/profile/api/useConnectedAccounts.ts +++ /dev/null @@ -1,22 +0,0 @@ -'use client'; -import { useQuery } from '@tanstack/react-query'; -import { AuthQueries } from 'entities/auth'; -import { useMemo } from 'react'; - -export function useConnectedAccounts() { - const available = useQuery(AuthQueries.getOAuthProviders()); - const connected = useQuery(AuthQueries.getConnectedOAuthProviders()); - - const providers = useMemo(() => { - if (!available.data) return []; - - const connectedSet = new Set(connected.data?.map((v) => v.provider)); - - return available.data.map((item) => ({ - ...item, - isConnected: connectedSet.has(item.value), - })); - }, [available.data, connected.data]); - - return { providers }; -} diff --git a/src/pages/profile/config/tabs.ts b/src/pages/profile/config/tabs.ts deleted file mode 100644 index 1ae5728..0000000 --- a/src/pages/profile/config/tabs.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { routes } from 'shared/config'; - -export const profileTabs = [ - { key: routes.user.profile(), label: 'Мой профиль' }, - { key: routes.user.security(), label: 'Безопасность' }, - { key: routes.user.notifications(), label: 'Уведомления' }, -]; diff --git a/src/pages/profile/index.ts b/src/pages/profile/index.ts deleted file mode 100644 index 8922138..0000000 --- a/src/pages/profile/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { profileTabs } from './config/tabs'; -export { MePage } from './ui/me-page/MePage'; -export { NotificationsPage } from './ui/notifications-page/NotificationsPage'; -export { SecurityPage } from './ui/security-page/SecurityPage'; diff --git a/src/pages/profile/ui/me-page/IdentityItem.tsx b/src/pages/profile/ui/me-page/IdentityItem.tsx deleted file mode 100644 index e7475c0..0000000 --- a/src/pages/profile/ui/me-page/IdentityItem.tsx +++ /dev/null @@ -1,47 +0,0 @@ -'use client'; - -import { Item, ItemActions, ItemContent, ItemMedia } from 'shared/ui'; -import { UploadAvatar } from 'features/upload-avatar'; -import { SignOut } from 'features/auth/sign-out'; -import { type TUser, UserAvatar } from 'entities/user'; - -type AccountIdentityItemProps = { - profile: TUser.UserResponse['profile']; - email: string; -}; - -function IdentityItem({ profile, email }: AccountIdentityItemProps) { - const fullName = `${profile.firstName} ${profile.lastName}`; - - return ( - - - - } - /> - - -
-

{fullName}

-

{email}

-

- {profile.bio?.trim() || 'Добавьте информацию о себе в профиле'} -

-
-
- - - -
- ); -} - -export { IdentityItem }; diff --git a/src/pages/profile/ui/me-page/MePage.tsx b/src/pages/profile/ui/me-page/MePage.tsx deleted file mode 100644 index 97952ff..0000000 --- a/src/pages/profile/ui/me-page/MePage.tsx +++ /dev/null @@ -1,61 +0,0 @@ -'use client'; - -import { - Card, - CardDescription, - CardHeader, - CardSection, - CardTitle, - FloatingSaveBar, - Separator, -} from 'shared/ui'; -import { IdentityItem } from './IdentityItem'; -import { ProfileForm } from './ProfileForm'; -import { useMePage } from '../../model/useMePage'; -import { AccountSection } from './account-section/AccountsSection'; -import { Suspense } from 'react'; -import { QueryParamsHandler } from 'features/handle-query-params'; - -function MePage() { - const { form, profile, email, isDirty, isPending, onSubmit, onDiscard } = useMePage(); - - if (!profile || !email) { - return ( - - - Профиль - Данные профиля пока недоступны. - - - ); - } - - return ( - <> - - - -
- - - - - - - -
- - ); -} - -export { MePage }; diff --git a/src/pages/profile/ui/me-page/account-section/AccountsSection.tsx b/src/pages/profile/ui/me-page/account-section/AccountsSection.tsx deleted file mode 100644 index 1744497..0000000 --- a/src/pages/profile/ui/me-page/account-section/AccountsSection.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { OAuthManageButton } from './OAuthManageButton'; -import { useConnectedAccounts } from '../../../api/useConnectedAccounts'; -import { CardSection } from 'shared/ui'; - -export function AccountSection() { - const { providers } = useConnectedAccounts(); - - return ( - - {providers?.map((provider) => { - return ( - - ); - })} - - ); -} diff --git a/src/pages/profile/ui/me-page/account-section/OAuthManageButton.tsx b/src/pages/profile/ui/me-page/account-section/OAuthManageButton.tsx deleted file mode 100644 index d2a3d4f..0000000 --- a/src/pages/profile/ui/me-page/account-section/OAuthManageButton.tsx +++ /dev/null @@ -1,63 +0,0 @@ -'use client'; - -import { authFabricKeys, OAUTH_PROVIDERS, type TAuth } from 'entities/auth'; -import { type ComponentProps, useCallback } from 'react'; -import { Button } from 'shared/ui'; -import { useConnectOAuthProvider } from '../../../api/useConnectOauthProvider'; -import { useDisconnectOAuthProvider } from '../../../api/useDisconnectOauthProvider'; -import { env } from 'shared/config'; -import { toast } from 'sonner'; -import Image from 'next/image'; - -type OAuthManageButtonProps = ComponentProps & { - provider: TAuth.OAuthProvider; - label: string; - isLinked: boolean; -}; - -export function OAuthManageButton({ provider, label, isLinked, ...props }: OAuthManageButtonProps) { - const connect = useConnectOAuthProvider(); - const disconnect = useDisconnectOAuthProvider(); - - const isLoading = connect.isPending || disconnect.isPending; - - const handleToggleConnect = useCallback(() => { - if (isLinked) { - disconnect.mutate(provider, { - onSuccess: (data, _v, _m, context) => { - context.client.invalidateQueries({ queryKey: authFabricKeys.connectedProviders() }); - toast.success(data.message); - }, - }); - } else { - connect.mutate(provider, { - onSuccess: (data) => { - const url = data.url.startsWith('http') - ? data.url - : new URL(data.url, env.NEXT_PUBLIC_API_BASE_URL).toString(); - window.location.href = url; - }, - }); - } - }, [connect, disconnect, isLinked, provider]); - - const meta = OAUTH_PROVIDERS[provider]; - - return ( - - ); -} diff --git a/src/pages/profile/ui/security-page/SecurityPage.tsx b/src/pages/profile/ui/security-page/SecurityPage.tsx deleted file mode 100644 index bb2f9f5..0000000 --- a/src/pages/profile/ui/security-page/SecurityPage.tsx +++ /dev/null @@ -1,39 +0,0 @@ -'use client'; - -import { CardSection, OptionItem, Separator, Switch } from 'shared/ui'; -import { formatDate } from 'shared/lib/utils'; -import { useQuery } from '@tanstack/react-query'; -import { UserQueries } from 'entities/user'; - -function SecurityPage() { - const query = useQuery(UserQueries.getMe()); - const is2faEnabled = query.data?.security.is2faEnabled ?? false; - const lastPasswordChange = formatDate(query.data?.security.lastPasswordChange ?? ''); - - return ( - - ( - - )} - /> - -
- Последняя смена пароля - {lastPasswordChange} -
-
- ); -} - -export { SecurityPage }; diff --git a/src/pages/profile/ui/teams-page/TeamList.tsx b/src/pages/profile/ui/teams-page/TeamList.tsx deleted file mode 100644 index fbf5b00..0000000 --- a/src/pages/profile/ui/teams-page/TeamList.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { TeamAvatar, useTeamStore } from 'entities/team'; -import { UserQueries } from 'entities/user'; -import { RemoveTeamDialog } from 'features/teams/remove'; -import { Trash2Icon } from 'lucide-react'; -import { - Badge, - Button, - Item, - ItemActions, - ItemContent, - ItemDescription, - ItemMedia, - ItemTitle, -} from 'shared/ui'; -import { useSwitchTeam } from 'features/teams/active-team'; - -export function TeamsList() { - const teamsQuery = useQuery(UserQueries.getMyTeams()); - const teamId = useTeamStore.use.teamId(); - - const { switchTeam } = useSwitchTeam({ - teams: teamsQuery.data, - defaultOptions: { redirect: true }, - }); - - if (teamsQuery.isError) { - return ( -

- {teamsQuery.error.message} -

- ); - } - - const teams = teamsQuery.data ?? []; - - return ( -
    - {teams.map((team) => ( -
  • - - {team.permissions.isOwner ? ( - - Owner - - ) : null} - - - - - {team.name} - {team.description} - - -
    - - - - -
    -
    -
    -
  • - ))} -
- ); -} diff --git a/src/pages/project/boards/ui/column/ColumnHeaderActions.tsx b/src/pages/project/boards/ui/column/ColumnHeaderActions.tsx index e080aef..aa15462 100644 --- a/src/pages/project/boards/ui/column/ColumnHeaderActions.tsx +++ b/src/pages/project/boards/ui/column/ColumnHeaderActions.tsx @@ -30,7 +30,7 @@ export function ColumnHeaderActions({ return ( - diff --git a/src/pages/project/boards/ui/task/TaskCard.tsx b/src/pages/project/boards/ui/task/TaskCard.tsx index 8036629..7479004 100644 --- a/src/pages/project/boards/ui/task/TaskCard.tsx +++ b/src/pages/project/boards/ui/task/TaskCard.tsx @@ -40,7 +40,6 @@ export function TaskCard({ task, onClick }: TaskCardProps) {
-
{task.position}
{task.assignee && ( diff --git a/src/pages/team/api/useQueryInvitations.ts b/src/pages/team/api/useQueryInvitations.ts deleted file mode 100644 index ee1d4ea..0000000 --- a/src/pages/team/api/useQueryInvitations.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { TeamQueries, useTeamStore } from 'entities/team'; - -export function useQueryInvitations() { - const teamId = useTeamStore.use.teamId(); - - return useQuery({ - ...TeamQueries.getInvitations(teamId!), - enabled: Boolean(teamId), - }); -} diff --git a/src/pages/team/api/useQueryTeam.ts b/src/pages/team/api/useQueryTeam.ts deleted file mode 100644 index 596f758..0000000 --- a/src/pages/team/api/useQueryTeam.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { TeamQueries, useTeamStore } from 'entities/team'; - -export function useQueryTeam() { - const teamId = useTeamStore.use.teamId(); - - return useQuery({ - ...TeamQueries.getTeam(teamId!), - enabled: Boolean(teamId), - }); -} diff --git a/src/pages/team/config/member.ts b/src/pages/team/config/member.ts deleted file mode 100644 index 457a9f5..0000000 --- a/src/pages/team/config/member.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { type TTeam } from 'entities/team'; -import { ComponentProps } from 'react'; -import { Badge } from 'shared/ui'; - -interface IMemberCardConfig { - ringColor: Record; - bgColor: Record; - workloadColor: (w: number) => string; - statusBadgeVariant: (s: TTeam.MemberStatus) => ComponentProps['variant']; - workloadLabel: (w: number) => { text: string; color: string }; -} - -type EnsureAllStatuses = { - [K in TTeam.MemberStatus]: T; -}; - -export const memberCardConfig: IMemberCardConfig = { - ringColor: { - active: 'ring-primary', - banned: 'ring-destructive', - blocked: 'ring-destructive', - inactive: 'ring-muted', - pending: 'ring-muted', - } satisfies EnsureAllStatuses, - bgColor: { - active: 'bg-card', - banned: 'bg-destructive/10', - blocked: 'bg-destructive/10', - inactive: 'bg-muted/90', - pending: 'bg-muted/90', - } satisfies EnsureAllStatuses, - workloadColor: (w) => { - if (w === 0) return 'bg-muted/90'; - if (w <= 60) return 'bg-primary/40'; - if (w <= 80) return 'bg-primary'; - return 'bg-orange-500'; - }, - statusBadgeVariant: (s) => { - if (s === 'banned') return 'destructive'; - if (s === 'active') return 'default'; - if (s === 'inactive') return 'outline'; - }, - workloadLabel: (w) => { - if (w === 0) return { text: 'Не загружен', color: 'text-muted-foreground' }; - if (w <= 60) return { text: 'Низкая', color: 'text-muted-foreground' }; - if (w <= 80) return { text: 'Оптимальная', color: 'text-primary' }; - return { text: 'Перегружен', color: 'text-orange-600' }; - }, -}; diff --git a/src/pages/team/config/tabs.ts b/src/pages/team/config/tabs.ts deleted file mode 100644 index 192faf3..0000000 --- a/src/pages/team/config/tabs.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { routes } from 'shared/config'; -import { TabNavItem } from 'widgets/tabs-nav'; - -export const teamTabs: TabNavItem[] = [ - { key: routes.team.members(), label: 'Участники', badge: { value: '8', variant: 'default' } }, - { key: routes.team.projects.all(), label: 'Проекты' }, - { - key: routes.team.invitations(), - label: 'Приглашения', - badge: { value: '3', variant: 'default' }, - }, - { - key: routes.team.roles(), - label: 'Роли и права', - badge: { value: 'Не реализовано', variant: 'destructive' }, - }, - { key: routes.team.settings(), label: 'Настройки' }, -]; diff --git a/src/pages/team/index.ts b/src/pages/team/index.ts deleted file mode 100644 index 3b6cf02..0000000 --- a/src/pages/team/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { teamTabs } from './config/tabs'; -export { InvitationsPage } from './ui/invitations/InvitationsPage'; -export { MembersPage } from './ui/members/MembersPage'; -export { RolesPage } from './ui/roles/RolesPage'; -export { Settings } from './ui/settings/SettingsPage'; diff --git a/src/pages/team/api/useRemoveMemberInvitation.ts b/src/pages/team/invitations/api/useRemoveInvitation.ts similarity index 89% rename from src/pages/team/api/useRemoveMemberInvitation.ts rename to src/pages/team/invitations/api/useRemoveInvitation.ts index 64ce1ec..fb08bf3 100644 --- a/src/pages/team/api/useRemoveMemberInvitation.ts +++ b/src/pages/team/invitations/api/useRemoveInvitation.ts @@ -7,10 +7,7 @@ export type UseRemoveMemberInvitationOptions = Omit< 'mutationFn' >; -export function useRemoveMemberInvitation({ - onSuccess, - ...rest -}: UseRemoveMemberInvitationOptions = {}) { +export function useRemoveInvitation({ onSuccess, ...rest }: UseRemoveMemberInvitationOptions = {}) { const teamId = useTeamStore.use.teamId(); return useMutation({ diff --git a/src/pages/team/api/useUpdateInvitation.ts b/src/pages/team/invitations/api/useUpdateInvitation.ts similarity index 100% rename from src/pages/team/api/useUpdateInvitation.ts rename to src/pages/team/invitations/api/useUpdateInvitation.ts diff --git a/src/pages/invitations/index.ts b/src/pages/team/invitations/index.ts similarity index 100% rename from src/pages/invitations/index.ts rename to src/pages/team/invitations/index.ts diff --git a/src/pages/team/invitations/lib/get-pending-members-count-text.ts b/src/pages/team/invitations/lib/get-pending-members-count-text.ts new file mode 100644 index 0000000..29e0a65 --- /dev/null +++ b/src/pages/team/invitations/lib/get-pending-members-count-text.ts @@ -0,0 +1,14 @@ +import { getPluralForm, type PluralForms } from 'shared/lib/utils'; + +const pendingMembersCountForms: PluralForms = { + one: 'участника', + few: 'участника', + two: 'участника', + many: 'участников', + zero: 'участников', + other: 'участников', +}; + +export function getPendingMembersCountText(count: number) { + return `${count} ${getPluralForm(count, pendingMembersCountForms)}`; +} diff --git a/src/pages/team/invitations/ui/InvitationCard.tsx b/src/pages/team/invitations/ui/InvitationCard.tsx new file mode 100644 index 0000000..27281b7 --- /dev/null +++ b/src/pages/team/invitations/ui/InvitationCard.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { ROLE_LABELS, type TTeam } from 'entities/team'; +import { formatDate } from 'shared/lib/utils'; +import { + Avatar, + AvatarFallback, + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemMedia, + ItemTitle, +} from 'shared/ui'; +import { InvitationCardActions } from './InvitationCardActions'; + +interface InvitationCardProps { + invitation: TTeam.TeamInvitationResponse; +} + +export function InvitationCard({ invitation }: InvitationCardProps) { + const emailInitial = invitation.email.charAt(0).toUpperCase(); + + return ( + + + + {emailInitial} + + + + {invitation.email} + + {ROLE_LABELS[invitation.role as Exclude]} · Истекает{' '} + {formatDate(invitation.expiresAt)} + + + + + + + ); +} diff --git a/src/pages/team/invitations/ui/InvitationCardActions.tsx b/src/pages/team/invitations/ui/InvitationCardActions.tsx new file mode 100644 index 0000000..009d011 --- /dev/null +++ b/src/pages/team/invitations/ui/InvitationCardActions.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { INVITATION_ROLES, ROLE_LABELS, type TTeam } from 'entities/team'; +import { Check, MoreHorizontal, Shield, Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import { classNames } from 'shared/lib/utils'; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from 'shared/ui'; +import { useUpdateInvitation } from '../api/useUpdateInvitation'; +import { RemoveInvitationDialog } from './RemoveInvitationDialog'; + +interface InvitationCardActionsProps { + invitation: TTeam.TeamInvitationResponse; +} + +export function InvitationCardActions({ invitation }: InvitationCardActionsProps) { + const [removeDialogOpen, setRemoveDialogOpen] = useState(false); + const { mutate: updateInvitation, isPending } = useUpdateInvitation(); + + return ( +
{ + event.stopPropagation(); + }} + > + + + + + + + + + Роль + + + {INVITATION_ROLES.map((role) => ( + updateInvitation({ code: invitation.code, role })} + > + + {ROLE_LABELS[role]} + + ))} + + + + setRemoveDialogOpen(true)}> + + Отозвать + + + + + +
+ ); +} diff --git a/src/pages/team/ui/invitations/InvitationsEmpty.tsx b/src/pages/team/invitations/ui/InvitationsEmpty.tsx similarity index 100% rename from src/pages/team/ui/invitations/InvitationsEmpty.tsx rename to src/pages/team/invitations/ui/InvitationsEmpty.tsx diff --git a/src/pages/invitations/ui/InvitationsPage.tsx b/src/pages/team/invitations/ui/InvitationsPage.tsx similarity index 100% rename from src/pages/invitations/ui/InvitationsPage.tsx rename to src/pages/team/invitations/ui/InvitationsPage.tsx diff --git a/src/pages/team/invitations/ui/InvitationsPageContent.tsx b/src/pages/team/invitations/ui/InvitationsPageContent.tsx new file mode 100644 index 0000000..c523d46 --- /dev/null +++ b/src/pages/team/invitations/ui/InvitationsPageContent.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { useSuspenseQuery } from '@tanstack/react-query'; +import { TeamQueries, useTeamStore } from 'entities/team'; +import { InviteTeamMemberDialog } from 'features/teams/invite'; +import { Plus } from 'lucide-react'; +import { Button } from 'shared/ui'; +import { PageWrapper } from 'widgets/page-wrapper'; +import { getPendingMembersCountText } from '../lib/get-pending-members-count-text'; +import { InvitationCard } from './InvitationCard'; +import { InvitationsEmpty } from './InvitationsEmpty'; + +export function InvitationsPageContent() { + const teamId = useTeamStore.use.teamId() ?? ''; + const { data } = useSuspenseQuery(TeamQueries.getInvitations(teamId)); + const invitations = data ?? []; + const total = invitations.length; + + return ( + + + + } + > + {total === 0 ? ( + + ) : ( +
+ {invitations.map((invitation) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/pages/team/invitations/ui/InvitationsPageFallback.tsx b/src/pages/team/invitations/ui/InvitationsPageFallback.tsx new file mode 100644 index 0000000..c4fdb09 --- /dev/null +++ b/src/pages/team/invitations/ui/InvitationsPageFallback.tsx @@ -0,0 +1,45 @@ +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemMedia, + ItemTitle, + Skeleton, +} from 'shared/ui'; +import { PageWrapper } from 'widgets/page-wrapper'; + +export function InvitationsPageFallback() { + return ( + } + action={} + > +
+ {Array.from({ length: 4 }).map((_, index) => ( + + + + + + + + + + + + + + + + + ))} +
+
+ ); +} diff --git a/src/pages/team/ui/invitations/RemoveInvitationDialog.tsx b/src/pages/team/invitations/ui/RemoveInvitationDialog.tsx similarity index 58% rename from src/pages/team/ui/invitations/RemoveInvitationDialog.tsx rename to src/pages/team/invitations/ui/RemoveInvitationDialog.tsx index 8879af1..e0be53d 100644 --- a/src/pages/team/ui/invitations/RemoveInvitationDialog.tsx +++ b/src/pages/team/invitations/ui/RemoveInvitationDialog.tsx @@ -1,5 +1,8 @@ +'use client'; + import { MailX } from 'lucide-react'; -import { PropsWithChildren } from 'react'; +import { ComponentProps } from 'react'; +import { useControllableState } from 'shared/lib/hooks'; import { AlertDialog, AlertDialogAction, @@ -12,18 +15,35 @@ import { AlertDialogTitle, AlertDialogTrigger, } from 'shared/ui'; -import { useRemoveMemberInvitation } from '../../api/useRemoveMemberInvitation'; +import { useRemoveInvitation } from '../api/useRemoveInvitation'; -interface RemoveInvitationDialogProps extends PropsWithChildren { +interface RemoveInvitationDialogProps extends ComponentProps { code: string; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + open?: boolean; } -export function RemoveInvitationDialog({ code, children }: RemoveInvitationDialogProps) { - const { mutate, isPending } = useRemoveMemberInvitation(); +export function RemoveInvitationDialog({ + code, + defaultOpen, + onOpenChange, + open, + ...props +}: RemoveInvitationDialogProps) { + const [dialogOpen, setDialogOpen] = useControllableState({ + defaultValue: defaultOpen, + value: open, + onChange: onOpenChange, + }); + + const { mutate, isPending } = useRemoveInvitation({ + onSuccess: () => setDialogOpen(false), + }); return ( - - {children} + + {props.children ? : null} diff --git a/src/pages/team/api/useRemoveMember.ts b/src/pages/team/members/api/useRemoveMember.ts similarity index 100% rename from src/pages/team/api/useRemoveMember.ts rename to src/pages/team/members/api/useRemoveMember.ts diff --git a/src/pages/team/api/useUpdateMember.ts b/src/pages/team/members/api/useUpdateMember.ts similarity index 100% rename from src/pages/team/api/useUpdateMember.ts rename to src/pages/team/members/api/useUpdateMember.ts diff --git a/src/pages/team/members/config/member.ts b/src/pages/team/members/config/member.ts new file mode 100644 index 0000000..82d99b3 --- /dev/null +++ b/src/pages/team/members/config/member.ts @@ -0,0 +1,27 @@ +import { type TTeam } from 'entities/team'; + +interface IMemberCardConfig { + ringColor: Record; + bgColor: Record; +} + +type EnsureAllStatuses = { + [K in TTeam.MemberStatus]: T; +}; + +export const memberCardConfig: IMemberCardConfig = { + ringColor: { + active: 'ring-primary', + banned: 'ring-destructive', + blocked: 'ring-destructive', + inactive: 'ring-muted', + pending: 'ring-muted', + } satisfies EnsureAllStatuses, + bgColor: { + active: 'bg-card', + banned: 'bg-destructive/10', + blocked: 'bg-destructive/10', + inactive: 'bg-muted/90', + pending: 'bg-muted/90', + } satisfies EnsureAllStatuses, +}; diff --git a/src/pages/team/members/index.ts b/src/pages/team/members/index.ts new file mode 100644 index 0000000..f9509aa --- /dev/null +++ b/src/pages/team/members/index.ts @@ -0,0 +1 @@ +export { MembersPage } from './ui/MembersPage'; diff --git a/src/pages/team/model/useMembersPage.ts b/src/pages/team/members/model/useMembersPage.ts similarity index 84% rename from src/pages/team/model/useMembersPage.ts rename to src/pages/team/members/model/useMembersPage.ts index 1e472ee..eb10d14 100644 --- a/src/pages/team/model/useMembersPage.ts +++ b/src/pages/team/members/model/useMembersPage.ts @@ -1,13 +1,13 @@ 'use client'; -import { useQuery } from '@tanstack/react-query'; +import { useSuspenseQuery } from '@tanstack/react-query'; import { TeamQueries, type TTeam, useTeamStore } from 'entities/team'; import { ChangeEvent, useEffect, useMemo, useRef, useState } from 'react'; import { debounce } from 'shared/lib/utils'; export function useMembersPage() { - const teamId = useTeamStore.use.teamId(); - const { data, isPending } = useQuery(TeamQueries.getMembers(teamId!)); + const teamId = useTeamStore.use.teamId() ?? ''; + const { data } = useSuspenseQuery(TeamQueries.getMembers(teamId)); const [search, setSearch] = useState(''); const [filtered, setFiltered] = useState([]); @@ -45,7 +45,6 @@ export function useMembersPage() { search, onChange, filtered, - total: data?.items?.length ?? 0, - isPending, + total: data.items.length, }; } diff --git a/src/pages/team/members/ui/MemberCard.tsx b/src/pages/team/members/ui/MemberCard.tsx new file mode 100644 index 0000000..c8ba95e --- /dev/null +++ b/src/pages/team/members/ui/MemberCard.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { ROLE_LABELS, STATUS_LABELS, type TTeam } from 'entities/team'; +import { classNames } from 'shared/lib/utils'; +import { + Avatar, + AvatarFallback, + AvatarImage, + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemMedia, + ItemTitle, + OwnerWrap, +} from 'shared/ui'; +import { memberCardConfig as cfg } from '../config/member'; +import { MemberCardActions } from './MemberCardActions'; + +interface MemberCardProps { + member: TTeam.TeamMemberResponse; +} + +export function MemberCard({ member }: MemberCardProps) { + const isOwner = member.role === 'owner'; + + return ( + + + + + + + + + + + {member.fullName} + + {isOwner + ? 'Владелец' + : `${ROLE_LABELS[member.role as Exclude]} · ${STATUS_LABELS[member.status]}`} + + + + + + + ); +} diff --git a/src/pages/team/members/ui/MemberCardActions.tsx b/src/pages/team/members/ui/MemberCardActions.tsx new file mode 100644 index 0000000..1ae8f7d --- /dev/null +++ b/src/pages/team/members/ui/MemberCardActions.tsx @@ -0,0 +1,118 @@ +'use client'; + +import { + INVITATION_ROLES, + MEMBER_STATUSES, + ROLE_LABELS, + STATUS_LABELS, + type TTeam, +} from 'entities/team'; +import { Check, CircleDot, MoreHorizontal, Shield, Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import { classNames } from 'shared/lib/utils'; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from 'shared/ui'; +import { useUpdateMember } from '../api/useUpdateMember'; +import { RemoveMemberDialog } from './RemoveMemberDialog'; + +interface MemberCardActionsProps { + member: TTeam.TeamMemberResponse; +} + +export function MemberCardActions({ member }: MemberCardActionsProps) { + const [removeDialogOpen, setRemoveDialogOpen] = useState(false); + const { mutate: updateMember, isPending } = useUpdateMember(); + + return ( +
{ + event.stopPropagation(); + }} + > + + + + + + + + + Роль + + + {INVITATION_ROLES.map((role) => ( + updateMember({ userId: member.id, role })} + > + + {ROLE_LABELS[role]} + + ))} + + + + + + + Статус + + + {MEMBER_STATUSES.map((status) => ( + updateMember({ userId: member.id, status })} + > + + {STATUS_LABELS[status]} + + ))} + + + + setRemoveDialogOpen(true)}> + + Удалить + + + + + +
+ ); +} diff --git a/src/pages/team/members/ui/MembersPage.tsx b/src/pages/team/members/ui/MembersPage.tsx new file mode 100644 index 0000000..2cc1faa --- /dev/null +++ b/src/pages/team/members/ui/MembersPage.tsx @@ -0,0 +1,16 @@ +'use client'; + +import dynamic from 'next/dynamic'; +import { MembersPageFallback } from './MembersPageFallback'; + +const MembersPageContent = dynamic( + () => import('./MembersPageContent').then((mod) => mod.MembersPageContent), + { + ssr: false, + loading: () => , + } +); + +export function MembersPage() { + return ; +} diff --git a/src/pages/team/members/ui/MembersPageContent.tsx b/src/pages/team/members/ui/MembersPageContent.tsx new file mode 100644 index 0000000..cd8c517 --- /dev/null +++ b/src/pages/team/members/ui/MembersPageContent.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { InviteTeamMemberDialog } from 'features/teams/invite'; +import { Plus } from 'lucide-react'; +import { Button, Search } from 'shared/ui'; +import { PageWrapper } from 'widgets/page-wrapper'; +import { useMembersPage } from '../model/useMembersPage'; +import { MemberCard } from './MemberCard'; + +export function MembersPageContent() { + const { search, onChange, filtered, total } = useMembersPage(); + + return ( + + Показано {filtered.length} из {total} + + } + action={ + + + + } + > +
+ +
+ +
+ {filtered.map((member) => ( + + ))} +
+
+ ); +} diff --git a/src/pages/team/members/ui/MembersPageFallback.tsx b/src/pages/team/members/ui/MembersPageFallback.tsx new file mode 100644 index 0000000..57b9c19 --- /dev/null +++ b/src/pages/team/members/ui/MembersPageFallback.tsx @@ -0,0 +1,49 @@ +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemMedia, + ItemTitle, + Skeleton, +} from 'shared/ui'; +import { PageWrapper } from 'widgets/page-wrapper'; + +export function MembersPageFallback() { + return ( + } + action={} + > +
+ +
+ +
+ {Array.from({ length: 8 }).map((_, index) => ( + + + + + + + + + + + + + + + + + ))} +
+
+ ); +} diff --git a/src/pages/team/ui/members/RemoveMemberDialog.tsx b/src/pages/team/members/ui/RemoveMemberDialog.tsx similarity index 68% rename from src/pages/team/ui/members/RemoveMemberDialog.tsx rename to src/pages/team/members/ui/RemoveMemberDialog.tsx index 192990c..3485856 100644 --- a/src/pages/team/ui/members/RemoveMemberDialog.tsx +++ b/src/pages/team/members/ui/RemoveMemberDialog.tsx @@ -1,5 +1,8 @@ +'use client'; + import { UserX } from 'lucide-react'; import { ComponentProps } from 'react'; +import { useControllableState } from 'shared/lib/hooks'; import { AlertDialog, AlertDialogAction, @@ -12,18 +15,36 @@ import { AlertDialogTitle, AlertDialogTrigger, } from 'shared/ui'; -import { useRemoveMember } from '../../api/useRemoveMember'; +import { useRemoveMember } from '../api/useRemoveMember'; interface RemoveMemberDialogProps extends ComponentProps { userId: string; name: string; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + open?: boolean; } -export function RemoveMemberDialog({ userId, name, ...props }: RemoveMemberDialogProps) { - const { mutate, isPending } = useRemoveMember(); +export function RemoveMemberDialog({ + userId, + name, + defaultOpen, + onOpenChange, + open, + ...props +}: RemoveMemberDialogProps) { + const [dialogOpen, setDialogOpen] = useControllableState({ + defaultValue: defaultOpen, + value: open, + onChange: onOpenChange, + }); + + const { mutate, isPending } = useRemoveMember({ + onSuccess: () => setDialogOpen(false), + }); return ( - + {props.children ? : null} diff --git a/src/pages/team/model/roles-mock.ts b/src/pages/team/model/roles-mock.ts deleted file mode 100644 index ff0a70d..0000000 --- a/src/pages/team/model/roles-mock.ts +++ /dev/null @@ -1,81 +0,0 @@ -export type RoleKey = 'Admin' | 'Member' | 'Guest' | 'Viewer'; - -export type PermissionKey = - | 'task.create' - | 'task.edit' - | 'task.delete' - | 'project.invite' - | 'project.create' - | 'billing.view'; - -export const PERMISSION_GROUPS: { - label: string; - items: { key: PermissionKey; label: string }[]; -}[] = [ - { - label: 'Управление задачами', - items: [ - { key: 'task.create', label: 'Создавать задачи' }, - { key: 'task.edit', label: 'Редактировать задачи' }, - { key: 'task.delete', label: 'Удалять задачи' }, - ], - }, - { - label: 'Доступ к проектам', - items: [ - { key: 'project.invite', label: 'Приглашать участников' }, - { key: 'project.create', label: 'Создавать проекты' }, - ], - }, - { - label: 'Биллинг', - items: [{ key: 'billing.view', label: 'Просмотр счетов' }], - }, -]; - -export const ROLES: { key: RoleKey; description: string; members: number; locked?: boolean }[] = [ - { - key: 'Admin', - description: 'Полный контроль команды и биллинг.', - members: 2, - locked: true, - }, - { key: 'Member', description: 'Стандартный доступ участника.', members: 12 }, - { key: 'Guest', description: 'Внешний, ограниченный доступ к проектам.', members: 4 }, - { key: 'Viewer', description: 'Доступ к проектам только для чтения.', members: 7 }, -]; - -export const DEFAULTS: Record> = { - Admin: { - 'task.create': true, - 'task.edit': true, - 'task.delete': true, - 'project.invite': true, - 'project.create': true, - 'billing.view': true, - }, - Member: { - 'task.create': true, - 'task.edit': true, - 'task.delete': false, - 'project.invite': false, - 'project.create': true, - 'billing.view': false, - }, - Guest: { - 'task.create': true, - 'task.edit': false, - 'task.delete': false, - 'project.invite': false, - 'project.create': false, - 'billing.view': false, - }, - Viewer: { - 'task.create': false, - 'task.edit': false, - 'task.delete': false, - 'project.invite': false, - 'project.create': false, - 'billing.view': false, - }, -}; diff --git a/src/pages/team/model/team-identity.ts b/src/pages/team/model/team-identity.ts deleted file mode 100644 index 82f78e5..0000000 --- a/src/pages/team/model/team-identity.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function getTeamPathPrefix() { - const origin = window?.location.origin; - - return origin ? `${origin}/team/` : '/team/'; -} diff --git a/src/pages/projects/config/project-status-map.ts b/src/pages/team/projects/config/project-status-map.ts similarity index 100% rename from src/pages/projects/config/project-status-map.ts rename to src/pages/team/projects/config/project-status-map.ts diff --git a/src/pages/projects/index.ts b/src/pages/team/projects/index.ts similarity index 100% rename from src/pages/projects/index.ts rename to src/pages/team/projects/index.ts diff --git a/src/pages/projects/lib/get-projects-count-text.ts b/src/pages/team/projects/lib/get-projects-count-text.ts similarity index 100% rename from src/pages/projects/lib/get-projects-count-text.ts rename to src/pages/team/projects/lib/get-projects-count-text.ts diff --git a/src/pages/projects/ui/ProjectCard.tsx b/src/pages/team/projects/ui/ProjectCard.tsx similarity index 100% rename from src/pages/projects/ui/ProjectCard.tsx rename to src/pages/team/projects/ui/ProjectCard.tsx diff --git a/src/pages/projects/ui/ProjectsPage.tsx b/src/pages/team/projects/ui/ProjectsPage.tsx similarity index 100% rename from src/pages/projects/ui/ProjectsPage.tsx rename to src/pages/team/projects/ui/ProjectsPage.tsx diff --git a/src/pages/projects/ui/ProjectsPageContent.tsx b/src/pages/team/projects/ui/ProjectsPageContent.tsx similarity index 100% rename from src/pages/projects/ui/ProjectsPageContent.tsx rename to src/pages/team/projects/ui/ProjectsPageContent.tsx diff --git a/src/pages/projects/ui/ProjectsPageFallback.tsx b/src/pages/team/projects/ui/ProjectsPageFallback.tsx similarity index 100% rename from src/pages/projects/ui/ProjectsPageFallback.tsx rename to src/pages/team/projects/ui/ProjectsPageFallback.tsx diff --git a/src/pages/team/settings/api/useQueryTeam.ts b/src/pages/team/settings/api/useQueryTeam.ts new file mode 100644 index 0000000..3370e20 --- /dev/null +++ b/src/pages/team/settings/api/useQueryTeam.ts @@ -0,0 +1,8 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; +import { TeamQueries, useTeamStore } from 'entities/team'; + +export function useQueryTeam() { + const teamId = useTeamStore.use.teamId() ?? ''; + + return useSuspenseQuery(TeamQueries.getTeam(teamId)); +} diff --git a/src/pages/team/api/useUpdateTeam.ts b/src/pages/team/settings/api/useUpdateTeam.ts similarity index 100% rename from src/pages/team/api/useUpdateTeam.ts rename to src/pages/team/settings/api/useUpdateTeam.ts diff --git a/src/pages/team/api/useUploadCover.ts b/src/pages/team/settings/api/useUploadCover.ts similarity index 100% rename from src/pages/team/api/useUploadCover.ts rename to src/pages/team/settings/api/useUploadCover.ts diff --git a/src/pages/team/settings/index.ts b/src/pages/team/settings/index.ts new file mode 100644 index 0000000..c730dcd --- /dev/null +++ b/src/pages/team/settings/index.ts @@ -0,0 +1 @@ +export { SettingsPage } from './ui/SettingsPage'; diff --git a/src/pages/team/model/settings.ts b/src/pages/team/settings/model/types.ts similarity index 100% rename from src/pages/team/model/settings.ts rename to src/pages/team/settings/model/types.ts diff --git a/src/pages/team/ui/settings/DangerZone.tsx b/src/pages/team/settings/ui/DangerZone.tsx similarity index 100% rename from src/pages/team/ui/settings/DangerZone.tsx rename to src/pages/team/settings/ui/DangerZone.tsx diff --git a/src/pages/team/ui/settings/SaveBar.tsx b/src/pages/team/settings/ui/SaveBar.tsx similarity index 88% rename from src/pages/team/ui/settings/SaveBar.tsx rename to src/pages/team/settings/ui/SaveBar.tsx index 0992d04..e0d36cd 100644 --- a/src/pages/team/ui/settings/SaveBar.tsx +++ b/src/pages/team/settings/ui/SaveBar.tsx @@ -1,8 +1,8 @@ import { type TTeam } from 'entities/team'; import { useFormContext, useFormState } from 'react-hook-form'; import { FloatingSaveBar } from 'shared/ui'; -import { useUpdateTeam } from '../../api/useUpdateTeam'; -import { type TeamSettingsFormValues } from '../../model/settings'; +import { useUpdateTeam } from '../api/useUpdateTeam'; +import { type TeamSettingsFormValues } from '../model/types'; export function SaveBar({ team }: { team: TTeam.TeamDetailsResponse }) { const form = useFormContext(); diff --git a/src/pages/team/settings/ui/SettingsPage.tsx b/src/pages/team/settings/ui/SettingsPage.tsx new file mode 100644 index 0000000..ccc8a4c --- /dev/null +++ b/src/pages/team/settings/ui/SettingsPage.tsx @@ -0,0 +1,16 @@ +'use client'; + +import dynamic from 'next/dynamic'; +import { SettingsPageFallback } from './SettingsPageFallback'; + +const SettingsPageContent = dynamic( + () => import('./SettingsPageContent').then((mod) => mod.SettingsPageContent), + { + ssr: false, + loading: () => , + } +); + +export function SettingsPage() { + return ; +} diff --git a/src/pages/team/settings/ui/SettingsPageContent.tsx b/src/pages/team/settings/ui/SettingsPageContent.tsx new file mode 100644 index 0000000..c439f29 --- /dev/null +++ b/src/pages/team/settings/ui/SettingsPageContent.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useLayoutEffect } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { PageWrapper } from 'widgets/page-wrapper'; +import { useQueryTeam } from '../api/useQueryTeam'; +import { TeamSettingsFormSchema, type TeamSettingsFormValues } from '../model/types'; +import { DangerZone } from './DangerZone'; +import { SaveBar } from './SaveBar'; +import { TeamIdentity } from './TeamIdentity'; + +export function SettingsPageContent() { + const { data: team } = useQueryTeam(); + + const form = useForm({ + resolver: zodResolver(TeamSettingsFormSchema), + mode: 'onChange', + defaultValues: { + name: '', + description: '', + }, + }); + + const { reset } = form; + + useLayoutEffect(() => { + reset({ + name: team.name, + description: team.description || '', + }); + }, [reset, team]); + + return ( + + +
+ + + + +
+
+ ); +} diff --git a/src/pages/team/settings/ui/SettingsPageFallback.tsx b/src/pages/team/settings/ui/SettingsPageFallback.tsx new file mode 100644 index 0000000..9662412 --- /dev/null +++ b/src/pages/team/settings/ui/SettingsPageFallback.tsx @@ -0,0 +1,60 @@ +import { ComponentProps } from 'react'; +import { classNames } from 'shared/lib/utils'; +import { CardSection, Item, ItemActions, ItemContent, ItemMedia, Skeleton } from 'shared/ui'; +import { PageWrapper } from 'widgets/page-wrapper'; + +export function SettingsPageFallback() { + return ( + +
+ +
+ + +
+ +
+ + +
+
+ + + +
+
+ + + + + + + + + + + + + +
+
+ ); +} + +function FieldSkeleton({ className, ...props }: ComponentProps) { + return ( +
+ + +
+ ); +} diff --git a/src/pages/team/ui/settings/TeamCover.tsx b/src/pages/team/settings/ui/TeamCover.tsx similarity index 95% rename from src/pages/team/ui/settings/TeamCover.tsx rename to src/pages/team/settings/ui/TeamCover.tsx index da0627b..6dc9f36 100644 --- a/src/pages/team/ui/settings/TeamCover.tsx +++ b/src/pages/team/settings/ui/TeamCover.tsx @@ -2,7 +2,7 @@ import { ImagePlus } from 'lucide-react'; import Image from 'next/image'; import { type ChangeEvent, useRef } from 'react'; import { Button } from 'shared/ui'; -import { useUploadCover, type UseUploadFileOptions } from '../../api/useUploadCover'; +import { useUploadCover, type UseUploadFileOptions } from '../api/useUploadCover'; interface TeamCoverProps { mutationOptions?: UseUploadFileOptions; diff --git a/src/pages/team/ui/settings/TeamIdentity.tsx b/src/pages/team/settings/ui/TeamIdentity.tsx similarity index 100% rename from src/pages/team/ui/settings/TeamIdentity.tsx rename to src/pages/team/settings/ui/TeamIdentity.tsx diff --git a/src/pages/team/ui/settings/TeamIdentityForm.tsx b/src/pages/team/settings/ui/TeamIdentityForm.tsx similarity index 100% rename from src/pages/team/ui/settings/TeamIdentityForm.tsx rename to src/pages/team/settings/ui/TeamIdentityForm.tsx diff --git a/src/pages/team/ui/invitations/InvitationCard.skeleton.tsx b/src/pages/team/ui/invitations/InvitationCard.skeleton.tsx deleted file mode 100644 index 0eed609..0000000 --- a/src/pages/team/ui/invitations/InvitationCard.skeleton.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Skeleton } from 'shared/ui'; - -export function InvitationCardSkeleton() { - return ( -
-
-
- -
- - -
-
-
- -
- - -
- -
- - -
-
- ); -} diff --git a/src/pages/team/ui/invitations/InvitationCard.tsx b/src/pages/team/ui/invitations/InvitationCard.tsx deleted file mode 100644 index 72b1e2a..0000000 --- a/src/pages/team/ui/invitations/InvitationCard.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { type TTeam } from 'entities/team'; -import { Clock, Copy, MailIcon, RotateCw, X } from 'lucide-react'; -import { ComponentProps } from 'react'; -import { classNames, formatDate } from 'shared/lib/utils'; -import { - Badge, - Button, - Item, - ItemActions, - ItemContent, - ItemFooter, - ItemGroup, - ItemHeader, -} from 'shared/ui'; -import { InvitationRoleSelect } from './InvitationRoleSelect'; -import { RemoveInvitationDialog } from './RemoveInvitationDialog'; - -interface InvitationCardProps extends Omit, 'children'> { - inv: TTeam.TeamInvitationResponse; -} - -export function InvitationCard({ className, inv, ...props }: InvitationCardProps) { - return ( - - - -
-
{inv.email}
-
-
- Роль: - -
-
- - - Отправлено {formatDate(inv.createdAt)} - -
-
-
- - - - - -
- -
- - - Ссылка истекает{' '} - {formatDate(inv.expiresAt)} - -
-
- - - - -
-
- ); -} diff --git a/src/pages/team/ui/invitations/InvitationRoleSelect.tsx b/src/pages/team/ui/invitations/InvitationRoleSelect.tsx deleted file mode 100644 index 2803897..0000000 --- a/src/pages/team/ui/invitations/InvitationRoleSelect.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { INVITATION_ROLES, ROLE_LABELS, type TTeam } from 'entities/team'; -import { ComponentProps } from 'react'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from 'shared/ui'; -import { useUpdateInvitation } from '../../api/useUpdateInvitation'; - -interface InvitationRoleSelectProps extends Omit< - ComponentProps, - 'children' | 'value' | 'onValueChange' | 'disabled' -> { - code: string; - role: TTeam.TeamRole; -} - -export function InvitationRoleSelect({ code, role, ...props }: InvitationRoleSelectProps) { - const { mutate: updateInvitation, isPending } = useUpdateInvitation(); - - return ( - - ); -} diff --git a/src/pages/team/ui/invitations/InvitationsPage.tsx b/src/pages/team/ui/invitations/InvitationsPage.tsx deleted file mode 100644 index 77016d6..0000000 --- a/src/pages/team/ui/invitations/InvitationsPage.tsx +++ /dev/null @@ -1,22 +0,0 @@ -'use client'; - -import { useQueryInvitations } from '../../api/useQueryInvitations'; -import { InvitationCard } from './InvitationCard'; -import { InvitationCardSkeleton } from './InvitationCard.skeleton'; -import { InvitationsEmpty } from './InvitationsEmpty'; - -export function InvitationsPage() { - const { data, isPending } = useQueryInvitations(); - - if (!isPending && !data?.length) { - return ; - } - - return ( -
- {isPending - ? Array.from({ length: 6 }).map((_, i) => ) - : data?.map((inv) => )} -
- ); -} diff --git a/src/pages/team/ui/members/MemberCard.skeleton.tsx b/src/pages/team/ui/members/MemberCard.skeleton.tsx deleted file mode 100644 index 342f751..0000000 --- a/src/pages/team/ui/members/MemberCard.skeleton.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Skeleton } from 'shared/ui'; - -export function MemberCardSkeleton() { - return ( -
-
- -
- - -
-
-
- - -
-
- - -
- -
- ); -} diff --git a/src/pages/team/ui/members/MemberCard.tsx b/src/pages/team/ui/members/MemberCard.tsx deleted file mode 100644 index ce311df..0000000 --- a/src/pages/team/ui/members/MemberCard.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { type TTeam } from 'entities/team'; -import { X } from 'lucide-react'; -import { ComponentProps } from 'react'; -import { classNames } from 'shared/lib/utils'; -import { - Avatar, - AvatarFallback, - AvatarImage, - Badge, - Button, - Item, - ItemActions, - ItemContent, - ItemFooter, - ItemGroup, - ItemHeader, - Progress, -} from 'shared/ui'; -import { memberCardConfig as cfg } from '../../config/member'; -import { MemberRoleSelect } from './MemberRoleSelect'; -import { MemberStatusSelect } from './MemberStatusSelect'; -import { RemoveMemberDialog } from './RemoveMemberDialog'; - -const workload = 61; //todo: mock -const skills = ['Design System', 'Sprint Plan']; //todo: mock -const backOn = '2026-05-10'; //todo: mock - -interface MemberCardProps extends Omit, 'children'> { - member: TTeam.TeamMemberResponse; -} - -export function MemberCard({ className, member, ...props }: MemberCardProps) { - const wl = cfg.workloadLabel(workload); - - return ( - - - - - - - -
-

{member.fullName}

-
- {member.role !== 'owner' && ( - - - - - - )} -
- -
- {member.role !== 'owner' ? ( - - ) : null} - {member.role !== 'owner' ? ( - - ) : null} -
-
- {member.role === 'owner' && Владелец} - {(skills ?? []).map((s) => ( - - {s} - - ))} -
-
- -
- - Загруженность - -
- {wl.text} - {workload}% -
-
- [data-slot=progress-indicator]]:' + cfg.workloadColor(workload), - ])} - value={workload} - /> - {backOn && ( -
- В отпуске - Вернётся {backOn} -
- )} -
-
-
- ); -} diff --git a/src/pages/team/ui/members/MemberRoleSelect.tsx b/src/pages/team/ui/members/MemberRoleSelect.tsx deleted file mode 100644 index 65e007a..0000000 --- a/src/pages/team/ui/members/MemberRoleSelect.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { INVITATION_ROLES, ROLE_LABELS, type TTeam } from 'entities/team'; -import { ComponentProps } from 'react'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from 'shared/ui'; -import { useUpdateMember } from '../../api/useUpdateMember'; - -interface MemberRoleSelectProps extends Omit< - ComponentProps, - 'children' | 'value' | 'onValueChange' | 'disabled' -> { - userId: string; - role: TTeam.TeamRole; -} - -export function MemberRoleSelect({ userId, role, ...props }: MemberRoleSelectProps) { - const { mutate: updateMember, isPending } = useUpdateMember(); - - return ( - - ); -} diff --git a/src/pages/team/ui/members/MemberStatusSelect.tsx b/src/pages/team/ui/members/MemberStatusSelect.tsx deleted file mode 100644 index 597a5c3..0000000 --- a/src/pages/team/ui/members/MemberStatusSelect.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { MEMBER_STATUSES, STATUS_LABELS, type TTeam } from 'entities/team'; -import { ComponentProps } from 'react'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from 'shared/ui'; -import { useUpdateMember } from '../../api/useUpdateMember'; - -interface MemberStatusSelectProps extends Omit< - ComponentProps, - 'children' | 'value' | 'onValueChange' | 'disabled' -> { - userId: string; - status: TTeam.MemberStatus; -} - -export function MemberStatusSelect({ userId, status, ...props }: MemberStatusSelectProps) { - const { mutate: updateMember, isPending } = useUpdateMember(); - - return ( - - ); -} diff --git a/src/pages/team/ui/members/MembersPage.tsx b/src/pages/team/ui/members/MembersPage.tsx deleted file mode 100644 index 8e90d92..0000000 --- a/src/pages/team/ui/members/MembersPage.tsx +++ /dev/null @@ -1,45 +0,0 @@ -'use client'; - -import { Filter, Plus, SlidersHorizontal } from 'lucide-react'; -import { InviteTeamMemberDialog } from 'features/teams/invite'; -import { Button, Search } from 'shared/ui'; -import { MemberCard } from './MemberCard'; -import { MemberCardSkeleton } from './MemberCard.skeleton'; -import { useMembersPage } from '../../model/useMembersPage'; - -export function MembersPage() { - const { search, onChange, filtered, total, isPending } = useMembersPage(); - - return ( - <> -
- - - - -
- -
-

- Показано {filtered.length} из {total} -

-
- - -
-
- -
- {isPending - ? Array.from({ length: 8 }).map((_, i) => ) - : filtered.map((m) => )} -
- - ); -} diff --git a/src/pages/team/ui/roles/Permissions.tsx b/src/pages/team/ui/roles/Permissions.tsx deleted file mode 100644 index 564c85c..0000000 --- a/src/pages/team/ui/roles/Permissions.tsx +++ /dev/null @@ -1,56 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { Badge, CardSection, OptionGroup, Switch } from 'shared/ui'; -import { DEFAULTS, PERMISSION_GROUPS, PermissionKey, RoleKey, ROLES } from '../../model/roles-mock'; - -interface PermissionsProps { - className?: string; - active: RoleKey; -} - -export function Permissions({ className, active }: PermissionsProps) { - const [matrix, setMatrix] = useState(DEFAULTS); - - const update = (role: RoleKey, key: PermissionKey, v: boolean) => { - setMatrix((m) => ({ ...m, [role]: { ...m[role], [key]: v } })); - }; - - return ( - - Права роли {active} - {ROLES.find((r) => r.key === active)?.locked && ( - Системная роль - )} - - } - description={`Настройте действия, разрешённые для роли ${active}.`} - > - {PERMISSION_GROUPS.map((group) => ( - { - const lockedPerm = p.key === 'billing.view' && active !== 'Admin'; - return { - key: p.key, - label: p.label, - hint: lockedPerm ? 'Зарезервировано для роли Admin' : undefined, - input: (props) => ( - update(active, p.key, v)} - disabled={lockedPerm} - {...props} - /> - ), - }; - })} - /> - ))} - - ); -} diff --git a/src/pages/team/ui/roles/RolesList.tsx b/src/pages/team/ui/roles/RolesList.tsx deleted file mode 100644 index cc95c58..0000000 --- a/src/pages/team/ui/roles/RolesList.tsx +++ /dev/null @@ -1,52 +0,0 @@ -'use client'; - -import { Plus, Shield } from 'lucide-react'; -import { Dispatch, SetStateAction } from 'react'; -import { - Button, - Field, - FieldContent, - FieldDescription, - FieldLabel, - FieldTitle, - RadioGroup, - RadioGroupItem, -} from 'shared/ui'; -import { RoleKey, ROLES } from '../../model/roles-mock'; - -interface RolesListProps { - className?: string; - active: RoleKey; - setActive: Dispatch>; -} - -export function RolesList({ className, active, setActive }: RolesListProps) { - return ( -
- setActive(v)}> - {ROLES.map(({ key, description, members }) => ( - - - - - ))} - - -
- ); -} diff --git a/src/pages/team/ui/roles/RolesPage.tsx b/src/pages/team/ui/roles/RolesPage.tsx deleted file mode 100644 index 7fbbeab..0000000 --- a/src/pages/team/ui/roles/RolesPage.tsx +++ /dev/null @@ -1,30 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { FloatingSaveBar } from 'shared/ui'; -import { DEFAULTS, RoleKey } from '../../model/roles-mock'; -import { RolesList } from './RolesList'; -import { Permissions } from './Permissions'; - -export function RolesPage() { - const [matrix, setMatrix] = useState(DEFAULTS); - const [saved, setSaved] = useState(DEFAULTS); - const [active, setActive] = useState('Member'); - - const dirty = JSON.stringify(matrix) !== JSON.stringify(saved); - - return ( - <> -
- - -
- - setSaved(matrix)} - onDiscard={() => setMatrix(saved)} - /> - - ); -} diff --git a/src/pages/team/ui/settings/DefaultSettings.tsx b/src/pages/team/ui/settings/DefaultSettings.tsx deleted file mode 100644 index dd3c8b3..0000000 --- a/src/pages/team/ui/settings/DefaultSettings.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { useId } from 'react'; -import { - Badge, - CardSection, - Field, - FieldLabel, - Input, - OptionItem, - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, - Switch, -} from 'shared/ui'; - -export function DefaultSettings() { - const roleId = useId(); - const autoJoinId = useId(); - - return ( - - Настройки по умолчанию - Не реализовано - - } - description="Применяется к новым участникам." - > - - Роль по умолчанию для новых участников - - - - Домен для автоматического входа - - - } - /> - - ); -} diff --git a/src/pages/team/ui/settings/InvitationSecurity.tsx b/src/pages/team/ui/settings/InvitationSecurity.tsx deleted file mode 100644 index 89e590c..0000000 --- a/src/pages/team/ui/settings/InvitationSecurity.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { useId } from 'react'; -import { - Badge, - CardSection, - Field, - FieldLabel, - OptionItem, - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, - Switch, -} from 'shared/ui'; - -export function InvitationSecurity() { - const id = useId(); - - return ( - - Безопасность приглашений - Не реализовано - - } - description="Настройте поведение приглашений." - > - - Срок действия ссылки приглашения - - - } - /> - - ); -} diff --git a/src/pages/team/ui/settings/SettingsPage.tsx b/src/pages/team/ui/settings/SettingsPage.tsx deleted file mode 100644 index 94c4045..0000000 --- a/src/pages/team/ui/settings/SettingsPage.tsx +++ /dev/null @@ -1,63 +0,0 @@ -'use client'; - -import { useEffect } from 'react'; -import { FormProvider, useForm } from 'react-hook-form'; -import { useQueryTeam } from '../../api/useQueryTeam'; -import { TeamSettingsFormSchema, type TeamSettingsFormValues } from '../../model/settings'; -import { DangerZone } from './DangerZone'; -import { DefaultSettings } from './DefaultSettings'; -import { InvitationSecurity } from './InvitationSecurity'; -import { SaveBar } from './SaveBar'; -import { TeamIdentity } from './TeamIdentity'; -import { DangerZoneSkeleton } from './skeletons/DangerZone.skeleton'; -import { DefaultSettingsSkeleton } from './skeletons/DefaultSettings.skeleton'; -import { InvitationSecuritySkeleton } from './skeletons/InvitationSecurity.skeleton'; -import { TeamIdentitySkeleton } from './skeletons/TeamIdentity.skeleton'; -import { zodResolver } from '@hookform/resolvers/zod'; - -export function Settings() { - const teamQuery = useQueryTeam(); - const team = teamQuery.data; - - const form = useForm({ - resolver: zodResolver(TeamSettingsFormSchema), - mode: 'onChange', - defaultValues: { - name: '', - description: '', - }, - }); - - const { reset } = form; - - useEffect(() => { - if (team) { - reset({ - name: team.name, - description: team.description || '', - }); - } - }, [reset, team]); - - if (teamQuery.isError) { - return ( -

- Не удалось загрузить данные команды. Попробуйте обновить страницу. -

- ); - } - - return ( - <> - -
- {team ? : } - {team ? : } - {team ? : } - {team ? : } - - {team ? : null} -
- - ); -} diff --git a/src/pages/team/ui/settings/skeletons/DangerZone.skeleton.tsx b/src/pages/team/ui/settings/skeletons/DangerZone.skeleton.tsx deleted file mode 100644 index b3f97b9..0000000 --- a/src/pages/team/ui/settings/skeletons/DangerZone.skeleton.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Item, ItemActions, ItemContent, ItemMedia, Skeleton } from 'shared/ui'; - -export function DangerZoneSkeleton() { - return ( - - - - - - - - - - - - - ); -} diff --git a/src/pages/team/ui/settings/skeletons/DefaultSettings.skeleton.tsx b/src/pages/team/ui/settings/skeletons/DefaultSettings.skeleton.tsx deleted file mode 100644 index 8f74eee..0000000 --- a/src/pages/team/ui/settings/skeletons/DefaultSettings.skeleton.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { CardSection, Skeleton } from 'shared/ui'; - -export function DefaultSettingsSkeleton() { - return ( - -
- - -
-
- - -
-
-
- - -
- -
-
- ); -} diff --git a/src/pages/team/ui/settings/skeletons/InvitationSecurity.skeleton.tsx b/src/pages/team/ui/settings/skeletons/InvitationSecurity.skeleton.tsx deleted file mode 100644 index 689d413..0000000 --- a/src/pages/team/ui/settings/skeletons/InvitationSecurity.skeleton.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { CardSection, Skeleton } from 'shared/ui'; - -export function InvitationSecuritySkeleton() { - return ( - -
- - -
-
-
- - -
- -
-
- ); -} diff --git a/src/pages/team/ui/settings/skeletons/TeamIdentity.skeleton.tsx b/src/pages/team/ui/settings/skeletons/TeamIdentity.skeleton.tsx deleted file mode 100644 index 616c01f..0000000 --- a/src/pages/team/ui/settings/skeletons/TeamIdentity.skeleton.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { CardSection, Skeleton } from 'shared/ui'; - -export function TeamIdentitySkeleton() { - return ( - -
- -
- -
- - -
-
- -
-
- ); -} diff --git a/src/pages/invitations/api/useAcceptTeamInvitation.ts b/src/pages/user/invitations/api/useAcceptTeamInvitation.ts similarity index 100% rename from src/pages/invitations/api/useAcceptTeamInvitation.ts rename to src/pages/user/invitations/api/useAcceptTeamInvitation.ts diff --git a/src/pages/user/invitations/index.ts b/src/pages/user/invitations/index.ts new file mode 100644 index 0000000..f384caa --- /dev/null +++ b/src/pages/user/invitations/index.ts @@ -0,0 +1 @@ +export { InvitationsPage } from './ui/InvitationsPage'; diff --git a/src/pages/invitations/lib/get-invitations-count-text.ts b/src/pages/user/invitations/lib/get-invitations-count-text.ts similarity index 100% rename from src/pages/invitations/lib/get-invitations-count-text.ts rename to src/pages/user/invitations/lib/get-invitations-count-text.ts diff --git a/src/pages/invitations/ui/InvitationCard.skeleton.tsx b/src/pages/user/invitations/ui/InvitationCard.skeleton.tsx similarity index 100% rename from src/pages/invitations/ui/InvitationCard.skeleton.tsx rename to src/pages/user/invitations/ui/InvitationCard.skeleton.tsx diff --git a/src/pages/invitations/ui/InvitationCard.tsx b/src/pages/user/invitations/ui/InvitationCard.tsx similarity index 100% rename from src/pages/invitations/ui/InvitationCard.tsx rename to src/pages/user/invitations/ui/InvitationCard.tsx diff --git a/src/pages/user/invitations/ui/InvitationsPage.tsx b/src/pages/user/invitations/ui/InvitationsPage.tsx new file mode 100644 index 0000000..ac9cd20 --- /dev/null +++ b/src/pages/user/invitations/ui/InvitationsPage.tsx @@ -0,0 +1,16 @@ +'use client'; + +import dynamic from 'next/dynamic'; +import { InvitationsPageFallback } from './InvitationsPageFallback'; + +const InvitationsPageContent = dynamic( + () => import('./InvitationsPageContent').then((mod) => mod.InvitationsPageContent), + { + ssr: false, + loading: () => , + } +); + +export function InvitationsPage() { + return ; +} diff --git a/src/pages/invitations/ui/InvitationsPageContent.tsx b/src/pages/user/invitations/ui/InvitationsPageContent.tsx similarity index 100% rename from src/pages/invitations/ui/InvitationsPageContent.tsx rename to src/pages/user/invitations/ui/InvitationsPageContent.tsx diff --git a/src/pages/invitations/ui/InvitationsPageFallback.tsx b/src/pages/user/invitations/ui/InvitationsPageFallback.tsx similarity index 100% rename from src/pages/invitations/ui/InvitationsPageFallback.tsx rename to src/pages/user/invitations/ui/InvitationsPageFallback.tsx diff --git a/src/pages/profile/api/useUpdateNotifications.ts b/src/pages/user/notifications/api/useUpdateNotifications.ts similarity index 100% rename from src/pages/profile/api/useUpdateNotifications.ts rename to src/pages/user/notifications/api/useUpdateNotifications.ts diff --git a/src/pages/profile/config/notifications.ts b/src/pages/user/notifications/config/notifications.ts similarity index 100% rename from src/pages/profile/config/notifications.ts rename to src/pages/user/notifications/config/notifications.ts diff --git a/src/pages/user/notifications/index.ts b/src/pages/user/notifications/index.ts new file mode 100644 index 0000000..cf6f382 --- /dev/null +++ b/src/pages/user/notifications/index.ts @@ -0,0 +1 @@ +export { NotificationsPage } from './ui/NotificationsPage'; diff --git a/src/pages/profile/model/notifications.ts b/src/pages/user/notifications/model/notifications.ts similarity index 100% rename from src/pages/profile/model/notifications.ts rename to src/pages/user/notifications/model/notifications.ts diff --git a/src/pages/profile/ui/notifications-page/NotificationsPage.tsx b/src/pages/user/notifications/ui/NotificationsPage.tsx similarity index 100% rename from src/pages/profile/ui/notifications-page/NotificationsPage.tsx rename to src/pages/user/notifications/ui/NotificationsPage.tsx diff --git a/src/pages/profile/ui/notifications-page/NotificationsPageContent.tsx b/src/pages/user/notifications/ui/NotificationsPageContent.tsx similarity index 93% rename from src/pages/profile/ui/notifications-page/NotificationsPageContent.tsx rename to src/pages/user/notifications/ui/NotificationsPageContent.tsx index 05f18da..0690019 100644 --- a/src/pages/profile/ui/notifications-page/NotificationsPageContent.tsx +++ b/src/pages/user/notifications/ui/NotificationsPageContent.tsx @@ -3,10 +3,10 @@ import { useReducer } from 'react'; import { CardSection, FloatingSaveBar, OptionGroup, Switch } from 'shared/ui'; import { UserQueries } from 'entities/user'; -import { useUpdateNotifications } from '../../api/useUpdateNotifications'; +import { useUpdateNotifications } from '../api/useUpdateNotifications'; import { useSuspenseQuery } from '@tanstack/react-query'; -import { notificationItems } from '../../config/notifications'; -import { NotificationChannel, type Notifications } from '../../model/notifications'; +import { notificationItems } from '../config/notifications'; +import { NotificationChannel, type Notifications } from '../model/notifications'; type NotificationsState = Notifications | null; diff --git a/src/pages/profile/ui/notifications-page/NotificationsPageFallback.tsx b/src/pages/user/notifications/ui/NotificationsPageFallback.tsx similarity index 93% rename from src/pages/profile/ui/notifications-page/NotificationsPageFallback.tsx rename to src/pages/user/notifications/ui/NotificationsPageFallback.tsx index 339c750..0658884 100644 --- a/src/pages/profile/ui/notifications-page/NotificationsPageFallback.tsx +++ b/src/pages/user/notifications/ui/NotificationsPageFallback.tsx @@ -1,4 +1,4 @@ -import { CardSection, OptionGroup, Skeleton } from 'shared/ui'; +import { CardSection, Skeleton } from 'shared/ui'; export function NotificationsPageFallback() { return ( @@ -11,6 +11,7 @@ export function NotificationsPageFallback() { ); } + function OptionGroupSkeleton({ items = 3 }: { items?: number }) { return (
diff --git a/src/pages/profile/api/useConnectOauthProvider.ts b/src/pages/user/profile/api/useConnectOauthProvider.ts similarity index 100% rename from src/pages/profile/api/useConnectOauthProvider.ts rename to src/pages/user/profile/api/useConnectOauthProvider.ts diff --git a/src/pages/profile/api/useDisconnectOauthProvider.ts b/src/pages/user/profile/api/useDisconnectOauthProvider.ts similarity index 100% rename from src/pages/profile/api/useDisconnectOauthProvider.ts rename to src/pages/user/profile/api/useDisconnectOauthProvider.ts diff --git a/src/pages/profile/api/useUpdateProfile.ts b/src/pages/user/profile/api/useUpdateProfile.ts similarity index 100% rename from src/pages/profile/api/useUpdateProfile.ts rename to src/pages/user/profile/api/useUpdateProfile.ts diff --git a/src/pages/user/profile/config/oauth-status.ts b/src/pages/user/profile/config/oauth-status.ts new file mode 100644 index 0000000..3587eb8 --- /dev/null +++ b/src/pages/user/profile/config/oauth-status.ts @@ -0,0 +1,13 @@ +import { ComponentProps } from 'react'; +import { Badge } from 'shared/ui'; +import { OAuthConnectionStatus } from '../model/profile'; + +export const OAUTH_STATUS_LABELS = { + connected: 'Подключен', + disconnected: 'Не подключен', +} as const satisfies Record; + +export const OAUTH_STATUS_BADGE_VARIANT = { + connected: 'success', + disconnected: 'destructive', +} as const satisfies Record['variant']>; diff --git a/src/pages/user/profile/index.ts b/src/pages/user/profile/index.ts new file mode 100644 index 0000000..8bf014f --- /dev/null +++ b/src/pages/user/profile/index.ts @@ -0,0 +1 @@ +export { UserPage } from './ui/UserPage'; diff --git a/src/pages/profile/model/profile.ts b/src/pages/user/profile/model/profile.ts similarity index 92% rename from src/pages/profile/model/profile.ts rename to src/pages/user/profile/model/profile.ts index 91d5ebe..e05616a 100644 --- a/src/pages/profile/model/profile.ts +++ b/src/pages/user/profile/model/profile.ts @@ -18,3 +18,5 @@ export const ProfileForm = z.object({ }); export type ProfileFormValues = z.infer; + +export type OAuthConnectionStatus = 'connected' | 'disconnected'; diff --git a/src/pages/profile/model/useMePage.ts b/src/pages/user/profile/model/useMePage.ts similarity index 90% rename from src/pages/profile/model/useMePage.ts rename to src/pages/user/profile/model/useMePage.ts index a9fb11d..d698fd7 100644 --- a/src/pages/profile/model/useMePage.ts +++ b/src/pages/user/profile/model/useMePage.ts @@ -1,15 +1,15 @@ 'use client'; import { zodResolver } from '@hookform/resolvers/zod'; -import { useQuery } from '@tanstack/react-query'; +import { useSuspenseQuery } from '@tanstack/react-query'; import { type TUser, UserQueries } from 'entities/user'; -import { useEffect } from 'react'; +import { useLayoutEffect } from 'react'; import { useForm, useFormState } from 'react-hook-form'; import { useUpdateProfile } from '../api/useUpdateProfile'; import { ProfileForm as ProfileFormSchema, type ProfileFormValues } from './profile'; export function useMePage() { - const query = useQuery(UserQueries.getMe()); + const query = useSuspenseQuery(UserQueries.getMe()); const profile = query.data?.profile; const email = query.data?.email; @@ -26,7 +26,7 @@ export function useMePage() { const { dirtyFields, isDirty } = useFormState({ control: form.control }); const updateProfileMutation = useUpdateProfile(); - useEffect(() => { + useLayoutEffect(() => { if (!profile) return; form.reset({ diff --git a/src/pages/user/profile/model/useOAuthManage.ts b/src/pages/user/profile/model/useOAuthManage.ts new file mode 100644 index 0000000..bc61116 --- /dev/null +++ b/src/pages/user/profile/model/useOAuthManage.ts @@ -0,0 +1,37 @@ +import { useCallback } from 'react'; +import { useConnectOAuthProvider } from '../api/useConnectOauthProvider'; +import { useDisconnectOAuthProvider } from '../api/useDisconnectOauthProvider'; +import { authFabricKeys, TAuth } from 'entities/auth'; +import { toast } from 'sonner'; +import { OAuthConnectionStatus } from './profile'; +import { env } from 'shared/config'; + +export function useOAuthManage(provider: TAuth.OAuthProvider, status: OAuthConnectionStatus) { + const connect = useConnectOAuthProvider(); + const disconnect = useDisconnectOAuthProvider(); + + const isPending = connect.isPending || disconnect.isPending; + + const handleToggleConnect = useCallback(() => { + if (status === 'connected') { + disconnect.mutate(provider, { + onSuccess: (data, _v, _m, context) => { + context.client.invalidateQueries({ queryKey: authFabricKeys.connectedProviders() }); + toast.success(data.message); + }, + }); + } else if (status === 'disconnected') { + connect.mutate(provider, { + onSuccess: (data) => { + localStorage.setItem('test', JSON.stringify(data)); + const url = data.url.startsWith('http') + ? data.url + : new URL(data.url, env.NEXT_PUBLIC_API_BASE_URL).toString(); + window.location.href = url; + }, + }); + } + }, [connect, disconnect, status, provider]); + + return { handleToggleConnect, isPending }; +} diff --git a/src/pages/user/profile/ui/UserPage.tsx b/src/pages/user/profile/ui/UserPage.tsx new file mode 100644 index 0000000..730ce18 --- /dev/null +++ b/src/pages/user/profile/ui/UserPage.tsx @@ -0,0 +1,13 @@ +import { OAuthSection } from './oauth-section/OAuthSection'; +import { ProfileSection } from './profile-section/ProfileSection'; + +function UserPage() { + return ( +
+ + +
+ ); +} + +export { UserPage }; diff --git a/src/pages/user/profile/ui/oauth-section/OAuthItem.tsx b/src/pages/user/profile/ui/oauth-section/OAuthItem.tsx new file mode 100644 index 0000000..2e72153 --- /dev/null +++ b/src/pages/user/profile/ui/oauth-section/OAuthItem.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { OAUTH_PROVIDERS, type TAuth } from 'entities/auth'; +import Image from 'next/image'; +import { useOAuthManage } from '../../model/useOAuthManage'; +import { cn } from 'shared/lib/utils'; +import { + Button, + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemMedia, + ItemTitle, + Spinner, +} from 'shared/ui'; +import { type OAuthConnectionStatus } from '../../model/profile'; +import { OAuthStatusBadge } from './OAuthStatusBadge'; + +interface OAuthItemProps { + provider: TAuth.OAuthProvider; + label: string; + status: OAuthConnectionStatus; +} + +export function OAuthItem({ provider, label, status }: OAuthItemProps) { + const { isPending, handleToggleConnect } = useOAuthManage(provider, status); + const isConnected = status === 'connected'; + const meta = OAUTH_PROVIDERS[provider]; + + return ( + + + {provider} + + + {label} + + + + + + + + + + ); +} diff --git a/src/pages/user/profile/ui/oauth-section/OAuthSection.tsx b/src/pages/user/profile/ui/oauth-section/OAuthSection.tsx new file mode 100644 index 0000000..8e030bb --- /dev/null +++ b/src/pages/user/profile/ui/oauth-section/OAuthSection.tsx @@ -0,0 +1,21 @@ +'use client'; + +import dynamic from 'next/dynamic'; +import { ErrorFallback } from 'widgets/error-state'; +import { OAuthSectionFallback } from './OAuthSectionFallback'; + +const OAuthSectionContent = dynamic( + () => import('./OAuthSectionContent').then((mod) => mod.OAuthSectionContent), + { + ssr: false, + loading: () => , + } +); + +export function OAuthSection() { + return ( + + + + ); +} diff --git a/src/pages/user/profile/ui/oauth-section/OAuthSectionContent.tsx b/src/pages/user/profile/ui/oauth-section/OAuthSectionContent.tsx new file mode 100644 index 0000000..6f7e447 --- /dev/null +++ b/src/pages/user/profile/ui/oauth-section/OAuthSectionContent.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { useSuspenseQuery } from '@tanstack/react-query'; +import { AuthQueries } from 'entities/auth'; +import { QueryParamsHandler } from 'features/handle-query-params'; +import { Suspense } from 'react'; +import { CardSection } from 'shared/ui'; +import { OAuthItem } from './OAuthItem'; + +export function OAuthSectionContent() { + const providers = useSuspenseQuery(AuthQueries.getOAuthProviders()); + const connected = useSuspenseQuery(AuthQueries.getConnectedOAuthProviders()); + + return ( + <> + + + + +
    + {providers.data?.map((provider) => { + const isConnected = connected.data[provider.value]; + return ( +
  • + +
  • + ); + })} +
+
+ + ); +} diff --git a/src/pages/user/profile/ui/oauth-section/OAuthSectionFallback.tsx b/src/pages/user/profile/ui/oauth-section/OAuthSectionFallback.tsx new file mode 100644 index 0000000..4169c8c --- /dev/null +++ b/src/pages/user/profile/ui/oauth-section/OAuthSectionFallback.tsx @@ -0,0 +1,35 @@ +import { CardSection, Item, ItemActions, ItemContent, ItemMedia, Skeleton } from 'shared/ui'; + +export function OAuthSectionFallback() { + return ( + +
    + {Array.from({ length: 3 }).map((_, index) => ( +
  • + + + + + + + + + + + + + +
  • + ))} +
+
+ ); +} diff --git a/src/pages/user/profile/ui/oauth-section/OAuthStatusBadge.tsx b/src/pages/user/profile/ui/oauth-section/OAuthStatusBadge.tsx new file mode 100644 index 0000000..138eb28 --- /dev/null +++ b/src/pages/user/profile/ui/oauth-section/OAuthStatusBadge.tsx @@ -0,0 +1,19 @@ +import { type ComponentProps } from 'react'; +import { Badge } from 'shared/ui'; +import { OAUTH_STATUS_BADGE_VARIANT, OAUTH_STATUS_LABELS } from '../../config/oauth-status'; +import { type OAuthConnectionStatus } from '../../model/profile'; + +export interface OAuthStatusBadgeProps extends Omit< + ComponentProps, + 'variant' | 'children' +> { + status: OAuthConnectionStatus; +} + +export function OAuthStatusBadge({ status, ...props }: OAuthStatusBadgeProps) { + return ( + + {OAUTH_STATUS_LABELS[status]} + + ); +} diff --git a/src/pages/user/profile/ui/profile-section/IdentityItem.tsx b/src/pages/user/profile/ui/profile-section/IdentityItem.tsx new file mode 100644 index 0000000..0ed10a2 --- /dev/null +++ b/src/pages/user/profile/ui/profile-section/IdentityItem.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { Item, ItemActions, ItemMedia } from 'shared/ui'; +import { UploadAvatar } from 'features/upload-avatar'; +import { SignOut } from 'features/auth/sign-out'; +import { type TUser, UserAvatar } from 'entities/user'; + +type AccountIdentityItemProps = { + profile: TUser.UserResponse['profile']; +}; + +function IdentityItem({ profile }: AccountIdentityItemProps) { + const fullName = `${profile.firstName} ${profile.lastName}`; + + return ( + + + + } + /> + + + + + + ); +} + +export { IdentityItem }; diff --git a/src/pages/profile/ui/me-page/ProfileForm.tsx b/src/pages/user/profile/ui/profile-section/ProfileForm.tsx similarity index 97% rename from src/pages/profile/ui/me-page/ProfileForm.tsx rename to src/pages/user/profile/ui/profile-section/ProfileForm.tsx index c02b809..75b481b 100644 --- a/src/pages/profile/ui/me-page/ProfileForm.tsx +++ b/src/pages/user/profile/ui/profile-section/ProfileForm.tsx @@ -18,6 +18,7 @@ function ProfileForm({ form, onSubmit }: ProfileFormProps) { Имя Фамилия import('./ProfileSectionContent').then((mod) => mod.ProfileSectionContent), + { + ssr: false, + loading: () => , + } +); + +export function ProfileSection() { + return ( + + + + ); +} diff --git a/src/pages/user/profile/ui/profile-section/ProfileSectionContent.tsx b/src/pages/user/profile/ui/profile-section/ProfileSectionContent.tsx new file mode 100644 index 0000000..d047384 --- /dev/null +++ b/src/pages/user/profile/ui/profile-section/ProfileSectionContent.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { CardSection, FloatingSaveBar } from 'shared/ui'; +import { useMePage } from '../../model/useMePage'; +import { IdentityItem } from './IdentityItem'; +import { ProfileForm } from './ProfileForm'; + +export function ProfileSectionContent() { + const { form, profile, isDirty, isPending, onSubmit, onDiscard } = useMePage(); + + return ( + <> + + + + + + + ); +} diff --git a/src/pages/user/profile/ui/profile-section/ProfileSectionFallback.tsx b/src/pages/user/profile/ui/profile-section/ProfileSectionFallback.tsx new file mode 100644 index 0000000..15e7b81 --- /dev/null +++ b/src/pages/user/profile/ui/profile-section/ProfileSectionFallback.tsx @@ -0,0 +1,36 @@ +import { CardSection, Skeleton } from 'shared/ui'; + +export function ProfileSectionFallback() { + return ( + <> + +
+
+ +
+ +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + ); +} diff --git a/src/pages/teams/index.ts b/src/pages/user/teams/index.ts similarity index 100% rename from src/pages/teams/index.ts rename to src/pages/user/teams/index.ts diff --git a/src/pages/teams/lib/get-teams-count-text.ts b/src/pages/user/teams/lib/get-teams-count-text.ts similarity index 100% rename from src/pages/teams/lib/get-teams-count-text.ts rename to src/pages/user/teams/lib/get-teams-count-text.ts diff --git a/src/pages/teams/ui/TeamCard.skeleton.tsx b/src/pages/user/teams/ui/TeamCard.skeleton.tsx similarity index 100% rename from src/pages/teams/ui/TeamCard.skeleton.tsx rename to src/pages/user/teams/ui/TeamCard.skeleton.tsx diff --git a/src/pages/teams/ui/TeamCard.tsx b/src/pages/user/teams/ui/TeamCard.tsx similarity index 100% rename from src/pages/teams/ui/TeamCard.tsx rename to src/pages/user/teams/ui/TeamCard.tsx diff --git a/src/pages/teams/ui/TeamCardActions.tsx b/src/pages/user/teams/ui/TeamCardActions.tsx similarity index 100% rename from src/pages/teams/ui/TeamCardActions.tsx rename to src/pages/user/teams/ui/TeamCardActions.tsx diff --git a/src/pages/teams/ui/TeamsPage.tsx b/src/pages/user/teams/ui/TeamsPage.tsx similarity index 100% rename from src/pages/teams/ui/TeamsPage.tsx rename to src/pages/user/teams/ui/TeamsPage.tsx diff --git a/src/pages/teams/ui/TeamsPageContent.tsx b/src/pages/user/teams/ui/TeamsPageContent.tsx similarity index 100% rename from src/pages/teams/ui/TeamsPageContent.tsx rename to src/pages/user/teams/ui/TeamsPageContent.tsx diff --git a/src/pages/teams/ui/TeamsPageFallback.tsx b/src/pages/user/teams/ui/TeamsPageFallback.tsx similarity index 100% rename from src/pages/teams/ui/TeamsPageFallback.tsx rename to src/pages/user/teams/ui/TeamsPageFallback.tsx diff --git a/src/shared/config/routes.ts b/src/shared/config/routes.ts index ac0c9c1..dfca8e1 100644 --- a/src/shared/config/routes.ts +++ b/src/shared/config/routes.ts @@ -5,7 +5,6 @@ export const routes = { user: { root: (): Route => '/user', profile: (): Route => '/user/profile', - security: (): Route => '/user/security', notifications: (): Route => '/user/notifications', teams: (): Route => '/user/teams', }, @@ -13,7 +12,6 @@ export const routes = { root: (): Route => '/team', members: (): Route => '/team/members', invitations: (): Route => '/team/invitations', - roles: (): Route => '/team/roles', settings: (): Route => '/team/settings', projects: { all: (): Route => '/team/projects', diff --git a/src/shared/ui/Badge.tsx b/src/shared/ui/Badge.tsx index c77e528..d682d37 100644 --- a/src/shared/ui/Badge.tsx +++ b/src/shared/ui/Badge.tsx @@ -10,6 +10,7 @@ const badgeVariants = cva( variants: { variant: { default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80', + success: 'bg-green-600/30 text-foreground [a]:hover:bg-green-600/50', secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80', destructive: 'bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20', diff --git a/src/shared/ui/card-section/CardSection.tsx b/src/shared/ui/card-section/CardSection.tsx index 3f6e778..081faed 100644 --- a/src/shared/ui/card-section/CardSection.tsx +++ b/src/shared/ui/card-section/CardSection.tsx @@ -12,7 +12,7 @@ function CardSection({ title, description, ...props }: ICardSectionProps) { {title} - {description} + {description} diff --git a/src/widgets/app-sidebar/config/sidebar.ts b/src/widgets/app-sidebar/config/sidebar.ts index 7652ac1..9396e0a 100644 --- a/src/widgets/app-sidebar/config/sidebar.ts +++ b/src/widgets/app-sidebar/config/sidebar.ts @@ -1,13 +1,9 @@ -import { Mail, Settings, ShieldUser, UsersRound } from 'lucide-react'; +import { BriefcaseBusiness, Mail, Settings, UsersRound } from 'lucide-react'; import { routes } from 'shared/config'; export const team = [ - { - url: routes.team.members(), - title: 'Участники', - icon: UsersRound, - }, + { url: routes.team.members(), title: 'Участники', icon: UsersRound }, + { url: routes.team.projects.all(), title: 'Проекты', icon: BriefcaseBusiness }, { url: routes.team.invitations(), title: 'Приглашения', icon: Mail }, - { url: routes.team.roles(), title: 'Роли', icon: ShieldUser }, { url: routes.team.settings(), title: 'Настройки', icon: Settings }, ] as const; diff --git a/src/widgets/app-sidebar/lib/useTeamHotkeys.ts b/src/widgets/app-sidebar/lib/useTeamHotkeys.ts index 1a0f251..7fa4721 100644 --- a/src/widgets/app-sidebar/lib/useTeamHotkeys.ts +++ b/src/widgets/app-sidebar/lib/useTeamHotkeys.ts @@ -12,7 +12,7 @@ export function useTeamHotkeys( const slot = parseInt(e.key, 10); - if (slot < 1 || slot > MAX_VISIBLE_TEAMS) return; + if (slot < 1 || slot > MAX_VISIBLE_TEAMS || Number.isNaN(slot)) return; e.preventDefault(); diff --git a/src/widgets/app-sidebar/ui/projects/ProjectsContent.tsx b/src/widgets/app-sidebar/ui/projects/ProjectsContent.tsx index 3bc9fa8..4cb48fe 100644 --- a/src/widgets/app-sidebar/ui/projects/ProjectsContent.tsx +++ b/src/widgets/app-sidebar/ui/projects/ProjectsContent.tsx @@ -66,7 +66,8 @@ export function ProjectsContent() { - {projectIconCodeToEmoji(project.icon)} {project.name} + {projectIconCodeToEmoji(project.icon)} + {project.name} @@ -79,15 +80,15 @@ export function ProjectsContent() { - Новый проект + Новый проект {totalProjects > 0 ? ( - + - Все проекты ({totalProjects}) + Все проекты ({totalProjects}) diff --git a/src/widgets/app-sidebar/ui/teams/TeamContent.tsx b/src/widgets/app-sidebar/ui/teams/TeamContent.tsx index 974a57e..68573f8 100644 --- a/src/widgets/app-sidebar/ui/teams/TeamContent.tsx +++ b/src/widgets/app-sidebar/ui/teams/TeamContent.tsx @@ -49,7 +49,7 @@ export function TeamContent() { {team.map((subItem) => ( - + {subItem.title} @@ -60,7 +60,7 @@ export function TeamContent() { - Добавить участника + Добавить участника diff --git a/src/widgets/error-state/index.ts b/src/widgets/error-state/index.ts index 0ac7db5..d1b01b6 100644 --- a/src/widgets/error-state/index.ts +++ b/src/widgets/error-state/index.ts @@ -1 +1,2 @@ +export { default as ErrorFallback } from './ui/ErrorFallback'; export { ErrorState } from './ui/ErrorState'; diff --git a/src/widgets/error-state/ui/ErrorFallback.tsx b/src/widgets/error-state/ui/ErrorFallback.tsx new file mode 100644 index 0000000..841b5a2 --- /dev/null +++ b/src/widgets/error-state/ui/ErrorFallback.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { unstable_catchError as catchError, type ErrorInfo } from 'next/error'; +import { ComponentProps } from 'react'; +import { ErrorState } from './ErrorState'; + +function ErrorFallback(props: ComponentProps, { unstable_retry }: ErrorInfo) { + return unstable_retry()} {...props} />; +} + +export default catchError(ErrorFallback); diff --git a/src/widgets/error-state/ui/ErrorState.tsx b/src/widgets/error-state/ui/ErrorState.tsx index 8430015..746236e 100644 --- a/src/widgets/error-state/ui/ErrorState.tsx +++ b/src/widgets/error-state/ui/ErrorState.tsx @@ -1,4 +1,5 @@ import { TriangleAlert } from 'lucide-react'; +import { classNames } from 'shared/lib/utils'; import { Button, Empty, @@ -25,7 +26,7 @@ export function ErrorState({ className, }: ErrorStateProps) { return ( - + diff --git a/src/widgets/page-layout/index.ts b/src/widgets/page-layout/index.ts deleted file mode 100644 index 32121a9..0000000 --- a/src/widgets/page-layout/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PageLayout } from './ui/PageLayout'; diff --git a/src/widgets/page-layout/ui/PageLayout.tsx b/src/widgets/page-layout/ui/PageLayout.tsx deleted file mode 100644 index f7d5533..0000000 --- a/src/widgets/page-layout/ui/PageLayout.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { ReactNode } from 'react'; - -interface PageLayoutProps { - title: string | ReactNode; - description?: string; - badge?: ReactNode; - headerSlot?: ReactNode; - nav?: ReactNode; - children: ReactNode; -} - -export function PageLayout({ - title, - description, - badge, - headerSlot, - nav, - children, -}: PageLayoutProps) { - return ( -
-
-
-
-

{title}

- {badge} -
- {description &&

{description}

} -
- - {headerSlot &&
{headerSlot}
} - - {nav &&
{nav}
} - -
{children}
-
-
- ); -} diff --git a/src/widgets/page-wrapper/ui/PageWrapper.tsx b/src/widgets/page-wrapper/ui/PageWrapper.tsx index fcce9e6..d6f4c0d 100644 --- a/src/widgets/page-wrapper/ui/PageWrapper.tsx +++ b/src/widgets/page-wrapper/ui/PageWrapper.tsx @@ -8,7 +8,7 @@ interface PageWrapperProps extends React.ComponentProps { export function PageWrapper({ title, description, action, children, ...props }: PageWrapperProps) { return ( - + {(title || description || action) && ( {title && {title}} diff --git a/src/widgets/sidebar-header-title/config/route-definitions.ts b/src/widgets/sidebar-header-title/config/route-definitions.ts index 9a6d2bd..5285f97 100644 --- a/src/widgets/sidebar-header-title/config/route-definitions.ts +++ b/src/widgets/sidebar-header-title/config/route-definitions.ts @@ -17,12 +17,10 @@ export const routeDefinitions = [ ['home', (pathname) => pathname === routes.home(), 'Главная'], ['user.root', (pathname) => pathname === routes.user.root(), 'Профиль'], ['user.profile', (pathname) => pathname === routes.user.profile(), 'Мой профиль'], - ['user.security', (pathname) => pathname === routes.user.security(), 'Безопасность'], ['user.notifications', (pathname) => pathname === routes.user.notifications(), 'Уведомления'], ['team.root', (pathname) => pathname === routes.team.root(), 'Команда'], ['team.members', (pathname) => pathname === routes.team.members(), 'Участники'], ['team.invitations', (pathname) => pathname === routes.team.invitations(), 'Приглашения'], - ['team.roles', (pathname) => pathname === routes.team.roles(), 'Роли и права'], ['team.settings', (pathname) => pathname === routes.team.settings(), 'Настройки'], ['team.projects.all', (pathname) => pathname === routes.team.projects.all(), 'Проекты'], ['auth.signin', (pathname) => pathname === routes.auth.signin(), 'Вход'], diff --git a/src/widgets/tabs-nav/index.ts b/src/widgets/tabs-nav/index.ts index 408ab4f..9b60f71 100644 --- a/src/widgets/tabs-nav/index.ts +++ b/src/widgets/tabs-nav/index.ts @@ -1,2 +1,2 @@ export { type TabNavItem } from './model/types'; -export { TabsNav } from './ui/TabsNav'; +export { VerticalTabsNav } from './ui/VerticalTabsNav'; diff --git a/src/widgets/tabs-nav/model/types.ts b/src/widgets/tabs-nav/model/types.ts index 6404556..693be45 100644 --- a/src/widgets/tabs-nav/model/types.ts +++ b/src/widgets/tabs-nav/model/types.ts @@ -1,10 +1,9 @@ import type { Route } from 'next'; -import { ComponentProps, ReactNode } from 'react'; -import { Badge } from 'shared/ui'; +import { ReactNode } from 'react'; export type TabNavItem = { key: Route; label: string; matchPrefix?: boolean; - badge?: { value: string | ReactNode; variant: ComponentProps['variant'] }; + icon?: ReactNode; }; diff --git a/src/widgets/tabs-nav/ui/TabsNav.tsx b/src/widgets/tabs-nav/ui/TabsNav.tsx deleted file mode 100644 index cb0739f..0000000 --- a/src/widgets/tabs-nav/ui/TabsNav.tsx +++ /dev/null @@ -1,57 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import { usePathname } from 'next/navigation'; -import { ComponentProps } from 'react'; -import { classNames } from 'shared/lib/utils'; -import { Badge } from 'shared/ui'; -import { TabNavItem } from '../model/types'; - -interface TabsNavProps extends Omit, 'children'> { - tabs: TabNavItem[]; -} - -export function TabsNav({ className, tabs, ...props }: TabsNavProps) { - const pathname = usePathname(); - - if (tabs.length === 0) { - return null; - } - - return ( -
- {tabs.map((tab) => { - const active = tab.matchPrefix - ? (pathname ?? '').startsWith(tab.key) - : pathname === tab.key; - - return ( - - {tab.label} - {tab.badge && ( - - {tab.badge.value} - - )} - {active && } - - ); - })} -
- ); -} diff --git a/src/widgets/tabs-nav/ui/VerticalTabsNav.tsx b/src/widgets/tabs-nav/ui/VerticalTabsNav.tsx new file mode 100644 index 0000000..2e25e07 --- /dev/null +++ b/src/widgets/tabs-nav/ui/VerticalTabsNav.tsx @@ -0,0 +1,44 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { ComponentProps } from 'react'; +import { classNames } from 'shared/lib/utils'; +import { Button } from 'shared/ui'; +import { TabNavItem } from '../model/types'; + +interface TabsNavProps extends Omit, 'children'> { + tabs: TabNavItem[]; +} + +export function VerticalTabsNav({ className, tabs, ...props }: TabsNavProps) { + const pathname = usePathname(); + + if (tabs.length === 0) { + return null; + } + + return ( +
+ {tabs.map((tab) => { + const active = tab.matchPrefix + ? (pathname ?? '').startsWith(tab.key) + : pathname === tab.key; + + return ( + + ); + })} +
+ ); +} diff --git a/steiger.config.ts b/steiger.config.ts index 26e9bd6..8bc1425 100644 --- a/steiger.config.ts +++ b/steiger.config.ts @@ -21,6 +21,7 @@ export default defineConfig([ './src/features/projects/remove/**', './src/features/projects/share/**', './src/widgets/task/**', + './src/widgets/tabs-nav/**', ], rules: { 'fsd/insignificant-slice': 'off',