From ef0c78c475f4eebe36aa860ad6dbaae4e41b304f Mon Sep 17 00:00:00 2001 From: kapitulin24 Date: Tue, 12 May 2026 16:54:18 +0300 Subject: [PATCH 01/26] feat(layout): add SidebarLayout and reorganize sidebar module --- app/(protected)/layout.tsx | 29 +++++++-------------- src/app/layouts/BaseLayout.tsx | 1 - src/app/layouts/SidebarLayout.tsx | 21 +++++++++++++++ src/pages/team/ui/members/MemberCard.tsx | 2 +- src/pages/team/ui/settings/DangerZone.tsx | 23 ++++++++++++---- src/pages/team/ui/settings/SettingsPage.tsx | 2 +- src/shared/ui/index.ts | 2 +- src/shared/ui/sidebar/index.ts | 2 ++ src/shared/ui/sidebar/model/const.ts | 6 +++++ src/shared/ui/{ => sidebar/ui}/Sidebar.tsx | 27 ++++++++++--------- 10 files changed, 74 insertions(+), 41 deletions(-) delete mode 100644 src/app/layouts/BaseLayout.tsx create mode 100644 src/app/layouts/SidebarLayout.tsx create mode 100644 src/shared/ui/sidebar/index.ts create mode 100644 src/shared/ui/sidebar/model/const.ts rename src/shared/ui/{ => sidebar/ui}/Sidebar.tsx (97%) diff --git a/app/(protected)/layout.tsx b/app/(protected)/layout.tsx index 9500797..62e05de 100644 --- a/app/(protected)/layout.tsx +++ b/app/(protected)/layout.tsx @@ -1,21 +1,12 @@ -import 'app/styles/global.css'; -import { Separator, SidebarInset, SidebarProvider, SidebarTrigger } from 'shared/ui'; -import { AppSidebar } from 'app/layouts/BaseLayout'; +import { SidebarLayout } from 'app/layouts/SidebarLayout'; +import { cookies } from 'next/headers'; +import type { ReactNode } from 'react'; +import { SIDEBAR_COOKIE_NAME } from 'shared/ui'; -export default async function RootLayout({ children }: { children: React.ReactNode }) { - return ( - - - -
- - -
-
{children}
-
-
- ); +export default async function Layout({ children }: { children: ReactNode }) { + const cookieStore = await cookies(); + const stored = cookieStore.get(SIDEBAR_COOKIE_NAME)?.value === 'true'; + const defaultSidebarOpen = stored ?? true; + + return {children}; } diff --git a/src/app/layouts/BaseLayout.tsx b/src/app/layouts/BaseLayout.tsx deleted file mode 100644 index f21a566..0000000 --- a/src/app/layouts/BaseLayout.tsx +++ /dev/null @@ -1 +0,0 @@ -export { AppSidebar } from 'widgets/app-sidebar'; diff --git a/src/app/layouts/SidebarLayout.tsx b/src/app/layouts/SidebarLayout.tsx new file mode 100644 index 0000000..8e4191a --- /dev/null +++ b/src/app/layouts/SidebarLayout.tsx @@ -0,0 +1,21 @@ +import { ComponentProps } from 'react'; +import { Separator, SidebarInset, SidebarProvider, SidebarTrigger } from 'shared/ui'; +import { AppSidebar } from 'widgets/app-sidebar'; + +export function SidebarLayout({ children, ...props }: ComponentProps) { + return ( + + + +
+ + +
+
{children}
+
+
+ ); +} diff --git a/src/pages/team/ui/members/MemberCard.tsx b/src/pages/team/ui/members/MemberCard.tsx index fc04381..6eccb5c 100644 --- a/src/pages/team/ui/members/MemberCard.tsx +++ b/src/pages/team/ui/members/MemberCard.tsx @@ -33,7 +33,7 @@ export function MemberCard({ className, member, ...props }: MemberCardProps) { className={classNames( 'border-border bg-card rounded-xl border transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[0_12px_28px_-14px_rgba(15,23,42,0.18)]', { - grayscale: member.status === 'inactive', + 'opacity-50 grayscale': member.status === 'inactive', }, [cfg.bgColor[member.status], className] )} diff --git a/src/pages/team/ui/settings/DangerZone.tsx b/src/pages/team/ui/settings/DangerZone.tsx index fb4b5ff..5e3f8ac 100644 --- a/src/pages/team/ui/settings/DangerZone.tsx +++ b/src/pages/team/ui/settings/DangerZone.tsx @@ -1,12 +1,21 @@ import { AlertTriangle } from 'lucide-react'; -import { Item, ItemActions, ItemContent, ItemDescription, ItemMedia, ItemTitle } from 'shared/ui'; -import { DeleteWorkspaceDialog } from './DeleteWorkspaceDialog'; +import { + Button, + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemMedia, + ItemTitle, +} from 'shared/ui'; +import { RemoveTeamDialog } from 'features/teams'; interface Props { - workspaceName: string; + teamName: string; + slug: string; } -export function DangerZone({ workspaceName }: Props) { +export function DangerZone({ teamName, slug }: Props) { return ( @@ -20,7 +29,11 @@ export function DangerZone({ workspaceName }: Props) { - + + + ); diff --git a/src/pages/team/ui/settings/SettingsPage.tsx b/src/pages/team/ui/settings/SettingsPage.tsx index 2f07655..2a563ea 100644 --- a/src/pages/team/ui/settings/SettingsPage.tsx +++ b/src/pages/team/ui/settings/SettingsPage.tsx @@ -32,7 +32,7 @@ export function Settings() { - + Date: Tue, 12 May 2026 16:59:37 +0300 Subject: [PATCH 02/26] feat(store): add createStore factory and team slug store --- package.json | 1 + pnpm-lock.yaml | 14 +++++++--- src/features/teams/model/store.ts | 28 +++++++++++++++++++ src/shared/lib/store/create-selectors.ts | 21 +++++++++++++++ src/shared/lib/store/create-store.ts | 34 ++++++++++++++++++++++++ src/shared/lib/store/index.ts | 3 +++ 6 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 src/features/teams/model/store.ts create mode 100644 src/shared/lib/store/create-selectors.ts create mode 100644 src/shared/lib/store/create-store.ts create mode 100644 src/shared/lib/store/index.ts diff --git a/package.json b/package.json index 51f5c36..ecce46c 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "axios": "^1.15.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "immer": "^11.1.7", "input-otp": "^1.4.2", "lucide-react": "^0.574.0", "next": "^16.1.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97ccd71..f2f06c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + immer: + specifier: ^11.1.7 + version: 11.1.7 input-otp: specifier: ^1.4.2 version: 1.4.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -82,7 +85,7 @@ importers: version: 4.3.6 zustand: specifier: ^5.0.11 - version: 5.0.12(@types/react@19.2.14)(immer@10.2.0)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)) + version: 5.0.12(@types/react@19.2.14)(immer@11.1.7)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)) devDependencies: '@eslint/js': specifier: 9.39.2 @@ -3639,6 +3642,9 @@ packages: immer@10.2.0: resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + immer@11.1.7: + resolution: {integrity: sha512-LFVFtAROHcDy1er5UI6nodRFnZ2SgdCXhfNSI+DpObO8N7Pur/muBGsjzH5wpnFHCYhYVQxZskCkV4koQ//3/Q==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -9106,6 +9112,8 @@ snapshots: immer@10.2.0: {} + immer@11.1.7: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -11042,9 +11050,9 @@ snapshots: zod@4.3.6: {} - zustand@5.0.12(@types/react@19.2.14)(immer@10.2.0)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)): + zustand@5.0.12(@types/react@19.2.14)(immer@11.1.7)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)): optionalDependencies: '@types/react': 19.2.14 - immer: 10.2.0 + immer: 11.1.7 react: 19.2.5 use-sync-external-store: 1.6.0(react@19.2.5) diff --git a/src/features/teams/model/store.ts b/src/features/teams/model/store.ts new file mode 100644 index 0000000..05d491d --- /dev/null +++ b/src/features/teams/model/store.ts @@ -0,0 +1,28 @@ +import { createStore } from 'shared/lib/store'; +import { persist } from 'zustand/middleware'; + +type TeamState = { + slug: string | null; + setSlug: (slug: string | null) => void; + clearSlug: () => void; +}; + +export const useTeamStore = createStore( + (set) => ({ + slug: null, + setSlug: (slug) => + set((state) => { + state.slug = slug; + }), + clearSlug: () => + set((state) => { + state.slug = null; + }), + }), + [ + (inner) => + persist(inner, { + name: 'team', + }), + ] +); diff --git a/src/shared/lib/store/create-selectors.ts b/src/shared/lib/store/create-selectors.ts new file mode 100644 index 0000000..9dfd0be --- /dev/null +++ b/src/shared/lib/store/create-selectors.ts @@ -0,0 +1,21 @@ +import { StoreApi, UseBoundStore } from 'zustand'; + +type WithSelectors = S extends { getState: () => infer T } + ? S & { use: { [K in keyof T]: () => T[K] } } + : never; + +export const createSelectors = >>( + _store: S +) => { + const store = _store as WithSelectors; + store.use = {} as WithSelectors['use']; + + const keys = Object.keys(store.getState()) as Array; + + for (const key of keys) { + (store.use as { [K in keyof TState]: () => TState[K] })[key] = () => + store((state) => state[key]); + } + + return store; +}; diff --git a/src/shared/lib/store/create-store.ts b/src/shared/lib/store/create-store.ts new file mode 100644 index 0000000..19b1346 --- /dev/null +++ b/src/shared/lib/store/create-store.ts @@ -0,0 +1,34 @@ +'use client'; + +import { create, StateCreator, StoreMutatorIdentifier } from 'zustand'; +import { immer } from 'zustand/middleware/immer'; +import { createSelectors } from './create-selectors'; + +type Mutators = [StoreMutatorIdentifier, unknown][]; + +export type StoreCreator = StateCreator< + TStore, + TMutators, + [], + TStore +>; + +type ChainedCreator = StateCreator; + +type StoreMiddleware = (creator: ChainedCreator) => ChainedCreator; + +type ImmerCreator = StateCreator; + +export const createStore = ( + creator: ImmerCreator, + middlewares: StoreMiddleware[] = [] +) => { + const enhancedCreator = middlewares.reduceRight>( + (accumulator, middleware) => middleware(accumulator), + immer(creator) as ChainedCreator + ); + + return createSelectors(create()(enhancedCreator)); +}; + +export type { StoreMiddleware, StoreMutatorIdentifier }; diff --git a/src/shared/lib/store/index.ts b/src/shared/lib/store/index.ts new file mode 100644 index 0000000..9518ef2 --- /dev/null +++ b/src/shared/lib/store/index.ts @@ -0,0 +1,3 @@ +export { createStore } from './create-store'; +export { createSelectors } from './create-selectors'; +export type { StoreCreator, StoreMiddleware, StoreMutatorIdentifier } from './create-store'; From 25f4ac52c1973d62bc29da2d646ae5acfecd4c22 Mon Sep 17 00:00:00 2001 From: kapitulin24 Date: Tue, 12 May 2026 17:01:49 +0300 Subject: [PATCH 03/26] refactor(entities): mark type namespaces as type-only exports --- src/entities/auth/index.ts | 2 +- src/entities/file/index.ts | 2 +- src/entities/team/index.ts | 2 +- src/entities/user/index.ts | 2 +- src/entities/user/model/schemas.ts | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/entities/auth/index.ts b/src/entities/auth/index.ts index 7bdacbe..e75adc2 100644 --- a/src/entities/auth/index.ts +++ b/src/entities/auth/index.ts @@ -1,4 +1,4 @@ export * as SAuth from './model/schemas'; -export * as TAuth from './model/types'; +export type * as TAuth from './model/types'; export * as CAuth from './model/const'; export { AuthHttp } from './api/http'; diff --git a/src/entities/file/index.ts b/src/entities/file/index.ts index 6965323..84a250a 100644 --- a/src/entities/file/index.ts +++ b/src/entities/file/index.ts @@ -1,3 +1,3 @@ export * as SFile from './model/schemas'; -export * as TFile from './model/types'; +export type * as TFile from './model/types'; export { UploadHttp } from './api/http'; diff --git a/src/entities/team/index.ts b/src/entities/team/index.ts index ca8b6fa..bcbb9fa 100644 --- a/src/entities/team/index.ts +++ b/src/entities/team/index.ts @@ -1,5 +1,5 @@ export * as STeam from './model/schemas'; -export * as TTeam from './model/types'; +export type * as TTeam from './model/types'; export { TeamHttp } from './api/http'; export { TeamQueries } from './api/queries'; export { teamFabricKeys } from './model/const'; diff --git a/src/entities/user/index.ts b/src/entities/user/index.ts index 464f23d..0f56c5e 100644 --- a/src/entities/user/index.ts +++ b/src/entities/user/index.ts @@ -1,5 +1,5 @@ export * as SUser from './model/schemas'; -export * as TUser from './model/types'; +export type * as TUser from './model/types'; export { UserHttp } from './api/http'; export { UserQueries } from './api/queries'; export { userFabricKeys } from './model/const'; diff --git a/src/entities/user/model/schemas.ts b/src/entities/user/model/schemas.ts index d4504da..ac63f7d 100644 --- a/src/entities/user/model/schemas.ts +++ b/src/entities/user/model/schemas.ts @@ -79,11 +79,11 @@ export const TeamPermissions = z.object({ }); export const UserTeamResponse = z.object({ - id: z.string().uuid(), + id: z.string(), name: z.string(), slug: z.string(), description: z.string().nullable(), - avatarUrl: z.string().nullable(), + avatar: UserAvatarSchema, role: z.string(), joinedAt: z.iso.datetime({}), permissions: TeamPermissions, From b791c70dff0cca057d752e457dc489570079bf13 Mon Sep 17 00:00:00 2001 From: kapitulin24 Date: Tue, 12 May 2026 17:03:11 +0300 Subject: [PATCH 04/26] style(theme): migrate global palette to OKLCH and align sidebar tokens --- src/app/styles/global.css | 131 ++++++++++++++++++++------------------ 1 file changed, 68 insertions(+), 63 deletions(-) diff --git a/src/app/styles/global.css b/src/app/styles/global.css index ae7823f..e6a368c 100644 --- a/src/app/styles/global.css +++ b/src/app/styles/global.css @@ -47,74 +47,79 @@ } :root { - --radius: 0.5rem; - --background: hsl(0 0% 100%); - --foreground: hsl(222 47% 11%); - --card: hsl(0 0% 100%); - --card-foreground: hsl(222 47% 11%); - --popover: hsl(0 0% 100%); - --popover-foreground: hsl(222 47% 11%); - --primary: hsl(207 100% 52%); - --primary-foreground: hsl(0 0% 100%); - --secondary: hsl(210 40% 96.1%); - --secondary-foreground: hsl(222.2 47.4% 11.2%); - --muted: hsl(210 40% 96.1%); - --muted-foreground: hsl(215.4 16.3% 46.9%); - --accent: hsl(210 40% 96.1%); - --accent-foreground: hsl(222.2 47.4% 11.2%); - --destructive: hsl(0 84.2% 60.2%); - --border: hsl(214.3 31.8% 91.4%); - --input: hsl(214.3 31.8% 91.4%); - --ring: hsl(207 100% 52%); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: hsl(0 0% 98%); - --sidebar-foreground: hsl(240 5.3% 26.1%); - --sidebar-primary: hsl(240 5.9% 10%); - --sidebar-primary-foreground: hsl(0 0% 98%); - --sidebar-accent: hsl(240 4.8% 95.9%); - --sidebar-accent-foreground: hsl(240 5.9% 10%); - --sidebar-border: hsl(220 13% 91%); - --sidebar-ring: hsl(217.2 91.2% 59.8%); + --background: oklch(1 0 0); + --foreground: oklch(0.3211 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.3211 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.3211 0 0); + --primary: oklch(0.6231 0.188 259.8145); + --primary-foreground: oklch(1 0 0); + --secondary: oklch(0.967 0.0029 264.5419); + --secondary-foreground: oklch(0.4461 0.0263 256.8018); + --muted: oklch(0.9846 0.0017 247.8389); + --muted-foreground: oklch(0.551 0.0234 264.3637); + --accent: oklch(0.9514 0.025 236.8242); + --accent-foreground: oklch(0.3791 0.1378 265.5222); + --destructive: oklch(0.6368 0.2078 25.3313); + --destructive-foreground: oklch(1 0 0); + --border: oklch(0.9276 0.0058 264.5313); + --input: oklch(0.9276 0.0058 264.5313); + --ring: oklch(0.6231 0.188 259.8145); + --chart-1: oklch(0.6231 0.188 259.8145); + --chart-2: oklch(0.5461 0.2152 262.8809); + --chart-3: oklch(0.4882 0.2172 264.3763); + --chart-4: oklch(0.4244 0.1809 265.6377); + --chart-5: oklch(0.3791 0.1378 265.5222); + --sidebar: oklch(0.9846 0.0017 247.8389); + --sidebar-foreground: oklch(0.3211 0 0); + --sidebar-primary: oklch(0.6231 0.188 259.8145); + --sidebar-primary-foreground: oklch(1 0 0); + --sidebar-accent: oklch(0.9514 0.025 236.8242); + --sidebar-accent-foreground: oklch(0.3791 0.1378 265.5222); + --sidebar-border: oklch(0.9276 0.0058 264.5313); + --sidebar-ring: oklch(0.6231 0.188 259.8145); + --font-sans: Inter, sans-serif; + --font-serif: Source Serif 4, serif; + --font-mono: JetBrains Mono, monospace; + --radius: 0.375rem; --link: oklch(1 0 89.876 / 0); --link-foreground: oklch(54.65% 0.246 262.87); } .dark { - --background: hsl(222.2 84% 4.9%); - --foreground: hsl(210 40% 98%); - --card: hsl(222.2 84% 4.9%); - --card-foreground: hsl(210 40% 98%); - --popover: hsl(222.2 84% 4.9%); - --popover-foreground: hsl(210 40% 98%); - --primary: hsl(210 40% 98%); - --primary-foreground: hsl(222.2 47.4% 11.2%); - --secondary: hsl(217.2 32.6% 17.5%); - --secondary-foreground: hsl(210 40% 98%); - --muted: hsl(217.2 32.6% 17.5%); - --muted-foreground: hsl(215 20.2% 65.1%); - --accent: hsl(217.2 32.6% 17.5%); - --accent-foreground: hsl(210 40% 98%); - --destructive: hsl(0 62.8% 30.6%); - --border: hsl(217.2 32.6% 17.5%); - --input: hsl(217.2 32.6% 17.5%); - --ring: hsl(212.7 26.8% 83.9%); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: hsl(240 5.9% 10%); - --sidebar-foreground: hsl(240 4.8% 95.9%); - --sidebar-primary: hsl(224.3 76.3% 48%); - --sidebar-primary-foreground: hsl(0 0% 100%); - --sidebar-accent: hsl(240 3.7% 15.9%); - --sidebar-accent-foreground: hsl(240 4.8% 95.9%); - --sidebar-border: hsl(240 3.7% 15.9%); - --sidebar-ring: hsl(217.2 91.2% 59.8%); + --background: oklch(0.2046 0 0); + --foreground: oklch(0.9219 0 0); + --card: oklch(0.2686 0 0); + --card-foreground: oklch(0.9219 0 0); + --popover: oklch(0.2686 0 0); + --popover-foreground: oklch(0.9219 0 0); + --primary: oklch(0.6231 0.188 259.8145); + --primary-foreground: oklch(1 0 0); + --secondary: oklch(0.2686 0 0); + --secondary-foreground: oklch(0.9219 0 0); + --muted: oklch(0.2393 0 0); + --muted-foreground: oklch(0.7155 0 0); + --accent: oklch(0.3791 0.1378 265.5222); + --accent-foreground: oklch(0.8823 0.0571 254.1284); + --destructive: oklch(0.6368 0.2078 25.3313); + --destructive-foreground: oklch(1 0 0); + --border: oklch(0.3715 0 0); + --input: oklch(0.3715 0 0); + --ring: oklch(0.6231 0.188 259.8145); + --chart-1: oklch(0.7137 0.1434 254.624); + --chart-2: oklch(0.6231 0.188 259.8145); + --chart-3: oklch(0.5461 0.2152 262.8809); + --chart-4: oklch(0.4882 0.2172 264.3763); + --chart-5: oklch(0.4244 0.1809 265.6377); + --sidebar: oklch(0.2046 0 0); + --sidebar-foreground: oklch(0.9219 0 0); + --sidebar-primary: oklch(0.6231 0.188 259.8145); + --sidebar-primary-foreground: oklch(1 0 0); + --sidebar-accent: oklch(0.3791 0.1378 265.5222); + --sidebar-accent-foreground: oklch(0.8823 0.0571 254.1284); + --sidebar-border: oklch(0.3715 0 0); + --sidebar-ring: oklch(0.6231 0.188 259.8145); --link: oklch(1 0 89.876 / 0); --link-foreground: oklch(54.65% 0.246 262.87); } From 4ead4408f697863d3037b623e3d56d29f7e6c7c4 Mon Sep 17 00:00:00 2001 From: kapitulin24 Date: Tue, 12 May 2026 17:03:56 +0300 Subject: [PATCH 05/26] chore(infra): add Imagor env vars to dev .env.example --- infra/dev/.env.example | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/infra/dev/.env.example b/infra/dev/.env.example index 02cb0df..1ef6246 100644 --- a/infra/dev/.env.example +++ b/infra/dev/.env.example @@ -37,4 +37,7 @@ S3_BUCKET_NAME='' S3_ENDPOINT='' S3_REGION='' S3_ACCESS_KEY='' -S3_SECRET_KEY='' \ No newline at end of file +S3_SECRET_KEY='' + +IMAGOR_URL=http://localhost +IMAGOR_SECRET=super-secret \ No newline at end of file From 7f250d9a453734b5d48e2eedf09537a628161f73 Mon Sep 17 00:00:00 2001 From: kapitulin24 Date: Fri, 15 May 2026 00:54:44 +0300 Subject: [PATCH 06/26] feat(entities): structured team avatars, invitation URLs without slug, user invite schema --- src/entities/team/api/http.ts | 8 ++++---- src/entities/team/index.ts | 2 ++ src/entities/team/model/schemas.ts | 15 ++++++++++++--- .../teams => entities/team}/model/store.ts | 0 src/entities/team/model/types.ts | 2 ++ src/entities/team/ui/TeamAvatar.tsx | 14 ++++++++++++++ src/entities/user/model/schemas.ts | 4 ++-- 7 files changed, 36 insertions(+), 9 deletions(-) rename src/{features/teams => entities/team}/model/store.ts (100%) create mode 100644 src/entities/team/ui/TeamAvatar.tsx diff --git a/src/entities/team/api/http.ts b/src/entities/team/api/http.ts index de37977..db04040 100644 --- a/src/entities/team/api/http.ts +++ b/src/entities/team/api/http.ts @@ -93,9 +93,9 @@ export class TeamHttp { }); } - static acceptInvitation(slug: string, code: string) { + static acceptInvitation(code: string) { return api({ - url: `/teams/${slug}/invitations/${code}/accept`, + url: `/teams/invitations/${code}/accept`, method: 'POST', contracts: { response: STeam.ActionResponse, @@ -115,9 +115,9 @@ export class TeamHttp { }); } - static removeInvitation(slug: string, code: string) { + static removeInvitation(code: string) { return api({ - url: `/teams/${slug}/invitations/${code}`, + url: `/teams/invitations/${code}`, method: 'DELETE', contracts: { response: STeam.ActionResponse, diff --git a/src/entities/team/index.ts b/src/entities/team/index.ts index bcbb9fa..8d201ac 100644 --- a/src/entities/team/index.ts +++ b/src/entities/team/index.ts @@ -3,3 +3,5 @@ export type * as TTeam from './model/types'; export { TeamHttp } from './api/http'; export { TeamQueries } from './api/queries'; export { teamFabricKeys } from './model/const'; +export { useTeamStore } from './model/store'; +export { TeamAvatar } from './ui/TeamAvatar'; diff --git a/src/entities/team/model/schemas.ts b/src/entities/team/model/schemas.ts index f3602e4..e524fbc 100644 --- a/src/entities/team/model/schemas.ts +++ b/src/entities/team/model/schemas.ts @@ -1,6 +1,15 @@ import { z } from 'zod/v4'; import { GlobalSuccess } from 'shared/api'; +export const TeamAvatarSchema = z + .object({ + small: z.string().url(), + medium: z.string().url(), + large: z.string().url(), + original: z.string().url(), + }) + .nullish(); + export const TeamRole = z.enum([ 'owner', 'admin', // управление юзерами, настройками @@ -52,7 +61,7 @@ export const TeamDetailsResponse = z.object({ name: z.string(), slug: z.string(), description: z.string().nullable(), - avatarUrl: z.string().nullable(), + avatar: TeamAvatarSchema, coverUrl: z.string().nullable(), ownerId: z.string().nullable(), createdAt: z.iso.datetime({}), @@ -64,7 +73,7 @@ export const TeamInvitationResponse = z.object({ code: z.string(), teamId: z.string(), teamName: z.string(), - teamAvatar: z.string().nullable(), + avatar: TeamAvatarSchema, email: z.email(), role: TeamRole, inviterId: z.string(), @@ -89,7 +98,7 @@ export const TeamMemberResponse = z.object({ fullName: z.string(), firstName: z.string(), lastName: z.string(), - avatarUrl: z.url().nullable(), + avatar: TeamAvatarSchema, initials: z.string().max(2), joinedAt: z.iso.datetime({}), }); diff --git a/src/features/teams/model/store.ts b/src/entities/team/model/store.ts similarity index 100% rename from src/features/teams/model/store.ts rename to src/entities/team/model/store.ts diff --git a/src/entities/team/model/types.ts b/src/entities/team/model/types.ts index 55cfafe..38316d9 100644 --- a/src/entities/team/model/types.ts +++ b/src/entities/team/model/types.ts @@ -1,6 +1,8 @@ import { z } from 'zod/v4'; import * as STeam from './schemas'; +import { TeamAvatarSchema } from './schemas'; +export type TeamAvatar = z.infer; export type TeamRole = z.infer; export type MemberStatus = z.infer; diff --git a/src/entities/team/ui/TeamAvatar.tsx b/src/entities/team/ui/TeamAvatar.tsx new file mode 100644 index 0000000..abce6fb --- /dev/null +++ b/src/entities/team/ui/TeamAvatar.tsx @@ -0,0 +1,14 @@ +import { UsersIcon } from 'lucide-react'; +import { ComponentProps } from 'react'; +import { Avatar, AvatarFallback, AvatarImage } from 'shared/ui'; + +export function TeamAvatar(props: ComponentProps) { + return ( + + + + + + + ); +} diff --git a/src/entities/user/model/schemas.ts b/src/entities/user/model/schemas.ts index ac63f7d..d12687a 100644 --- a/src/entities/user/model/schemas.ts +++ b/src/entities/user/model/schemas.ts @@ -82,7 +82,7 @@ export const UserTeamResponse = z.object({ id: z.string(), name: z.string(), slug: z.string(), - description: z.string().nullable(), + description: z.string(), avatar: UserAvatarSchema, role: z.string(), joinedAt: z.iso.datetime({}), @@ -92,7 +92,7 @@ export const UserTeamResponse = z.object({ export const UserInviteResponse = z.object({ code: z.string(), teamName: z.string(), - teamAvatar: z.string().nullable(), + teamAvatar: UserAvatarSchema, role: z.string(), inviterName: z.string(), expiresAt: z.iso.datetime({}), From 17910837cf910d384e5cce386400a98ae7e8fb7f Mon Sep 17 00:00:00 2001 From: kapitulin24 Date: Fri, 15 May 2026 00:58:49 +0300 Subject: [PATCH 07/26] feat(shared): add profile teams route and export Empty from shared ui --- src/shared/config/routes.ts | 1 + src/shared/ui/Empty.tsx | 94 +++++++++++++++++++++++++++++++++++++ src/shared/ui/Item.tsx | 15 ++---- src/shared/ui/index.ts | 1 + 4 files changed, 100 insertions(+), 11 deletions(-) create mode 100644 src/shared/ui/Empty.tsx diff --git a/src/shared/config/routes.ts b/src/shared/config/routes.ts index 8a9e90a..37d387b 100644 --- a/src/shared/config/routes.ts +++ b/src/shared/config/routes.ts @@ -7,6 +7,7 @@ export const routes = { me: (): Route => '/profile/me', security: (): Route => '/profile/security', notifications: (): Route => '/profile/notifications', + teams: (): Route => '/profile/teams', }, team: { root: (): Route => '/team', diff --git a/src/shared/ui/Empty.tsx b/src/shared/ui/Empty.tsx new file mode 100644 index 0000000..84a0223 --- /dev/null +++ b/src/shared/ui/Empty.tsx @@ -0,0 +1,94 @@ +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from 'shared/lib/utils'; + +function Empty({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function EmptyHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +const emptyMediaVariants = cva( + 'mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0', + { + variants: { + variant: { + default: 'bg-transparent', + icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4", + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +function EmptyMedia({ + className, + variant = 'default', + ...props +}: React.ComponentProps<'div'> & VariantProps) { + return ( +
+ ); +} + +function EmptyTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) { + return ( +
a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4', + className + )} + {...props} + /> + ); +} + +function EmptyContent({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +export { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia }; diff --git a/src/shared/ui/Item.tsx b/src/shared/ui/Item.tsx index 59d22c3..546e9dd 100644 --- a/src/shared/ui/Item.tsx +++ b/src/shared/ui/Item.tsx @@ -110,7 +110,7 @@ function ItemContent({ className, ...props }: React.ComponentProps<'div'>) {
) { } export { - Item, - ItemMedia, - ItemContent, - ItemActions, - ItemGroup, - ItemSeparator, - ItemTitle, - ItemDescription, - ItemHeader, - ItemFooter, + Item, ItemActions, ItemContent, ItemDescription, ItemFooter, ItemGroup, ItemHeader, ItemMedia, ItemSeparator, + ItemTitle }; + diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 4de3c9e..761f1d8 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -35,3 +35,4 @@ export * from './search/Search'; export * from './option-group/OptionGroup'; export * from './card-section/CardSection'; export * from './Select'; +export * from './Empty'; From 7f420f85ed82adf22aa8e2dedf16c58e1f523bfc Mon Sep 17 00:00:00 2001 From: kapitulin24 Date: Fri, 15 May 2026 01:06:34 +0300 Subject: [PATCH 08/26] refactor(asset): rename file entity --- src/entities/{file => asset}/api/http.ts | 10 +++++----- src/entities/asset/index.ts | 3 +++ src/entities/asset/model/schemas.ts | 3 +++ src/entities/asset/model/types.ts | 9 +++++++++ src/entities/file/index.ts | 3 --- src/entities/file/model/schemas.ts | 3 --- src/entities/file/model/types.ts | 9 --------- src/features/upload-avatar/model/useUploadAvatar.ts | 12 ++++++------ src/features/upload-avatar/ui/UploadAvatar.tsx | 4 ++-- 9 files changed, 28 insertions(+), 28 deletions(-) rename src/entities/{file => asset}/api/http.ts (60%) create mode 100644 src/entities/asset/index.ts create mode 100644 src/entities/asset/model/schemas.ts create mode 100644 src/entities/asset/model/types.ts delete mode 100644 src/entities/file/index.ts delete mode 100644 src/entities/file/model/schemas.ts delete mode 100644 src/entities/file/model/types.ts diff --git a/src/entities/file/api/http.ts b/src/entities/asset/api/http.ts similarity index 60% rename from src/entities/file/api/http.ts rename to src/entities/asset/api/http.ts index d38b4ae..c428f11 100644 --- a/src/entities/file/api/http.ts +++ b/src/entities/asset/api/http.ts @@ -1,16 +1,16 @@ import { api } from 'shared/api'; -import { UploadFileData, UploadResponse } from '../model/types'; -import { UploadResponse as UploadResponseSchema } from '../model/schemas'; +import { UploadAssetData, UploadAssetResponse } from '../model/types'; +import { UploadAssetResponse as UploadResponseSchema } from '../model/schemas'; -export class UploadHttp { - static uploadFile(data: UploadFileData): Promise { +export class AssetHttp { + static uploadFile(data: UploadAssetData): Promise { const formData = new FormData(); formData.append('file', data.file); // INCLUDED: SEE AT SWAGGER DOCS TO CONTEXT AND PROPS TOO formData.append('context', data.context); - return api({ + return api({ url: '/upload', method: 'POST', data: formData, diff --git a/src/entities/asset/index.ts b/src/entities/asset/index.ts new file mode 100644 index 0000000..82b2d98 --- /dev/null +++ b/src/entities/asset/index.ts @@ -0,0 +1,3 @@ +export * as SAsset from './model/schemas'; +export type * as TAsset from './model/types'; +export { AssetHttp } from './api/http'; diff --git a/src/entities/asset/model/schemas.ts b/src/entities/asset/model/schemas.ts new file mode 100644 index 0000000..a533137 --- /dev/null +++ b/src/entities/asset/model/schemas.ts @@ -0,0 +1,3 @@ +import { GlobalSuccess } from 'shared/api'; + +export const UploadAssetResponse = GlobalSuccess; diff --git a/src/entities/asset/model/types.ts b/src/entities/asset/model/types.ts new file mode 100644 index 0000000..ab406fb --- /dev/null +++ b/src/entities/asset/model/types.ts @@ -0,0 +1,9 @@ +import { z } from 'zod/v4'; +import { UploadAssetResponse } from './schemas'; + +export type UploadAssetResponse = z.infer; + +export type UploadAssetData = { + file: File; + context: string; //TODO: typify +}; diff --git a/src/entities/file/index.ts b/src/entities/file/index.ts deleted file mode 100644 index 84a250a..0000000 --- a/src/entities/file/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * as SFile from './model/schemas'; -export type * as TFile from './model/types'; -export { UploadHttp } from './api/http'; diff --git a/src/entities/file/model/schemas.ts b/src/entities/file/model/schemas.ts deleted file mode 100644 index 077dcb8..0000000 --- a/src/entities/file/model/schemas.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { GlobalSuccess } from 'shared/api'; - -export const UploadResponse = GlobalSuccess; diff --git a/src/entities/file/model/types.ts b/src/entities/file/model/types.ts deleted file mode 100644 index c6da511..0000000 --- a/src/entities/file/model/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { z } from 'zod/v4'; -import { UploadResponse } from './schemas'; - -export type UploadResponse = z.infer; - -export type UploadFileData = { - file: File; - context: string; //TODO: typify -}; diff --git a/src/features/upload-avatar/model/useUploadAvatar.ts b/src/features/upload-avatar/model/useUploadAvatar.ts index 58030f1..b146ebc 100644 --- a/src/features/upload-avatar/model/useUploadAvatar.ts +++ b/src/features/upload-avatar/model/useUploadAvatar.ts @@ -1,15 +1,16 @@ import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; import { toast } from 'sonner'; -import { TFile, UploadHttp } from 'entities/file'; +import { AssetHttp, TAsset } from 'entities/asset'; export type UseUploadFileOptions = Omit< - UseMutationOptions, + UseMutationOptions, 'mutationFn' >; -export function useUploadAvatar({ onSuccess, onError, ...props }: UseUploadFileOptions = {}) { - return useMutation({ - mutationFn: UploadHttp.uploadFile, +export function useUploadAvatar({ onSuccess, onError, ...rest }: UseUploadFileOptions = {}) { + return useMutation({ + ...rest, + mutationFn: AssetHttp.uploadFile, onError: (...args) => { onError?.(...args); }, @@ -17,6 +18,5 @@ export function useUploadAvatar({ onSuccess, onError, ...props }: UseUploadFileO onSuccess?.(res, ...args); toast.success(res.message ?? 'Аватар успешно загружен'); }, - ...props, }); } diff --git a/src/features/upload-avatar/ui/UploadAvatar.tsx b/src/features/upload-avatar/ui/UploadAvatar.tsx index 7614bda..ef29b95 100644 --- a/src/features/upload-avatar/ui/UploadAvatar.tsx +++ b/src/features/upload-avatar/ui/UploadAvatar.tsx @@ -3,14 +3,14 @@ import { type ChangeEvent, ComponentProps, useRef } from 'react'; import { Avatar, AvatarFallback, AvatarImage, Button } from 'shared/ui'; import { classNames } from 'shared/lib/utils'; import { useUploadAvatar, UseUploadFileOptions } from '../model/useUploadAvatar'; -import { TFile } from 'entities/file'; +import { TAsset } from 'entities/asset'; interface UploadAvatarProps { className?: string; avatar: string | null; alt: string; fallback?: ComponentProps; - context: TFile.UploadFileData['context']; + context: TAsset.UploadAssetData['context']; mutationOptions?: UseUploadFileOptions; } From d99323d5b34081b3922fd4c55e5c30bb447bd560 Mon Sep 17 00:00:00 2001 From: kapitulin24 Date: Fri, 15 May 2026 01:51:55 +0300 Subject: [PATCH 09/26] feat(pages/profile): teams tab, split teams/invites UI, profile model and mutation hooks --- src/pages/profile/api/useAcceptTeamInvite.ts | 27 +++++++ .../profile/api/useUpdateNotifications.ts | 35 +++++++++ src/pages/profile/api/useUpdateProfile.ts | 23 ++++++ .../config.ts => config/notifications.ts} | 2 +- src/pages/profile/config/tabs.ts | 8 +++ src/pages/profile/index.ts | 10 +-- .../model/{types.ts => notifications.ts} | 4 -- src/pages/profile/model/profile.ts | 20 ++++++ src/pages/profile/model/schemas.ts | 17 ----- src/pages/profile/model/useNavigateToTeam.ts | 19 +++++ .../profile/model/useUpdateNotifications.ts | 27 ------- src/pages/profile/model/useUpdateProfile.ts | 23 ------ .../ui/{me => me-page}/IdentityItem.tsx | 0 .../profile/ui/{me => me-page}/MePage.tsx | 17 ++--- .../ui/{me => me-page}/ProfileForm.tsx | 2 +- .../NotificationsPage.tsx | 9 +-- .../SecurityPage.tsx | 0 .../profile/ui/teams-page/InviteItem.tsx | 49 +++++++++++++ src/pages/profile/ui/teams-page/Invites.tsx | 52 ++++++++++++++ src/pages/profile/ui/teams-page/TeamList.tsx | 72 +++++++++++++++++++ .../profile/ui/teams-page/TeamsEmpty.tsx | 33 +++++++++ src/pages/profile/ui/teams-page/TeamsPage.tsx | 41 +++++++++++ .../skeletons/InviteItem.skeleton.tsx | 19 +++++ .../skeletons/TeamItem.skeleton.tsx | 19 +++++ 24 files changed, 435 insertions(+), 93 deletions(-) create mode 100644 src/pages/profile/api/useAcceptTeamInvite.ts create mode 100644 src/pages/profile/api/useUpdateNotifications.ts create mode 100644 src/pages/profile/api/useUpdateProfile.ts rename src/pages/profile/{model/config.ts => config/notifications.ts} (93%) create mode 100644 src/pages/profile/config/tabs.ts rename src/pages/profile/model/{types.ts => notifications.ts} (69%) create mode 100644 src/pages/profile/model/profile.ts delete mode 100644 src/pages/profile/model/schemas.ts create mode 100644 src/pages/profile/model/useNavigateToTeam.ts delete mode 100644 src/pages/profile/model/useUpdateNotifications.ts delete mode 100644 src/pages/profile/model/useUpdateProfile.ts rename src/pages/profile/ui/{me => me-page}/IdentityItem.tsx (100%) rename src/pages/profile/ui/{me => me-page}/MePage.tsx (89%) rename src/pages/profile/ui/{me => me-page}/ProfileForm.tsx (97%) rename src/pages/profile/ui/{notifications => notifications-page}/NotificationsPage.tsx (92%) rename src/pages/profile/ui/{security => security-page}/SecurityPage.tsx (100%) create mode 100644 src/pages/profile/ui/teams-page/InviteItem.tsx create mode 100644 src/pages/profile/ui/teams-page/Invites.tsx create mode 100644 src/pages/profile/ui/teams-page/TeamList.tsx create mode 100644 src/pages/profile/ui/teams-page/TeamsEmpty.tsx create mode 100644 src/pages/profile/ui/teams-page/TeamsPage.tsx create mode 100644 src/pages/profile/ui/teams-page/skeletons/InviteItem.skeleton.tsx create mode 100644 src/pages/profile/ui/teams-page/skeletons/TeamItem.skeleton.tsx diff --git a/src/pages/profile/api/useAcceptTeamInvite.ts b/src/pages/profile/api/useAcceptTeamInvite.ts new file mode 100644 index 0000000..9480397 --- /dev/null +++ b/src/pages/profile/api/useAcceptTeamInvite.ts @@ -0,0 +1,27 @@ +import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { TeamHttp, type TTeam } from 'entities/team'; +import { userFabricKeys } from 'entities/user'; +import { toast } from 'sonner'; + +type UseAcceptTeamInviteOptions = Omit< + UseMutationOptions, + 'mutationFn' +>; + +export function useAcceptTeamInvite(options: UseAcceptTeamInviteOptions = {}) { + const { onSuccess, onSettled, ...rest } = options; + + return useMutation({ + ...rest, + mutationFn: TeamHttp.acceptInvitation, + onSuccess: (res, ...args) => { + onSuccess?.(res, ...args); + toast.success(res.message ?? 'Приглашение принято'); + }, + onSettled: async (_d, _e, _v, _m, context) => { + onSettled?.(_d, _e, _v, _m, context); + context.client.invalidateQueries({ queryKey: userFabricKeys.myTeams() }); + context.client.invalidateQueries({ queryKey: userFabricKeys.myInvites() }); + }, + }); +} diff --git a/src/pages/profile/api/useUpdateNotifications.ts b/src/pages/profile/api/useUpdateNotifications.ts new file mode 100644 index 0000000..63a7554 --- /dev/null +++ b/src/pages/profile/api/useUpdateNotifications.ts @@ -0,0 +1,35 @@ +import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { TUser, userFabricKeys, UserHttp } from 'entities/user'; +import { toast } from 'sonner'; + +type UseUpdateNotificationsProps = Omit< + UseMutationOptions< + TUser.NotificationsUpdateResponse, + DefaultError, + TUser.NotificationsUpdateBody + >, + 'mutationFn' +>; + +export function useUpdateNotifications({ + onSettled, + onSuccess, + ...props +}: UseUpdateNotificationsProps = {}) { + return useMutation< + TUser.NotificationsUpdateResponse, + DefaultError, + TUser.NotificationsUpdateBody + >({ + ...props, + mutationFn: UserHttp.updateNotificationsConfig, + onSuccess: (...args) => { + onSuccess?.(...args); + toast.success('Настройки уведомлений обновлены'); + }, + onSettled: async (_d, _e, _v, _m, context) => { + onSettled?.(_d, _e, _v, _m, context); + context.client.invalidateQueries({ queryKey: userFabricKeys.me() }); + }, + }); +} diff --git a/src/pages/profile/api/useUpdateProfile.ts b/src/pages/profile/api/useUpdateProfile.ts new file mode 100644 index 0000000..7ae5a07 --- /dev/null +++ b/src/pages/profile/api/useUpdateProfile.ts @@ -0,0 +1,23 @@ +import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { type TUser, userFabricKeys, UserHttp } from 'entities/user'; +import { toast } from 'sonner'; + +type UseUpdateProfileProps = Omit< + UseMutationOptions, + 'mutationFn' +>; + +export function useUpdateProfile({ onSuccess, onSettled, ...rest }: UseUpdateProfileProps = {}) { + return useMutation({ + ...rest, + mutationFn: UserHttp.updateUserConfig, + onSuccess: (res, ...args) => { + onSuccess?.(res, ...args); + toast.success(res.message ?? 'Профиль успешно обновлен'); + }, + onSettled: async (_d, _e, _v, _m, context) => { + onSettled?.(_d, _e, _v, _m, context); + context.client.invalidateQueries({ queryKey: userFabricKeys.me() }); + }, + }); +} diff --git a/src/pages/profile/model/config.ts b/src/pages/profile/config/notifications.ts similarity index 93% rename from src/pages/profile/model/config.ts rename to src/pages/profile/config/notifications.ts index 68a0386..325c231 100644 --- a/src/pages/profile/model/config.ts +++ b/src/pages/profile/config/notifications.ts @@ -1,4 +1,4 @@ -import { NotificationItem } from './types'; +import { NotificationItem } from '../model/notifications'; export const notificationItems: { email: NotificationItem<'email'>[]; diff --git a/src/pages/profile/config/tabs.ts b/src/pages/profile/config/tabs.ts new file mode 100644 index 0000000..fb521f2 --- /dev/null +++ b/src/pages/profile/config/tabs.ts @@ -0,0 +1,8 @@ +import { routes } from 'shared/config'; + +export const profileTabs = [ + { key: routes.profile.me(), label: 'Мой профиль' }, + { key: routes.profile.teams(), label: 'Команды' }, + { key: routes.profile.security(), label: 'Безопасность' }, + { key: routes.profile.notifications(), label: 'Уведомления' }, +]; diff --git a/src/pages/profile/index.ts b/src/pages/profile/index.ts index 0c042f3..fc13b06 100644 --- a/src/pages/profile/index.ts +++ b/src/pages/profile/index.ts @@ -1,4 +1,6 @@ -export { TabsNav } from './ui/TabsNav'; -export { MePage } from 'pages/profile/ui/me/MePage'; -export { SecurityPage } from './ui/security/SecurityPage'; -export { NotificationsPage } from './ui/notifications/NotificationsPage'; +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'; +export { TeamsPage } from './ui/teams-page/TeamsPage'; + diff --git a/src/pages/profile/model/types.ts b/src/pages/profile/model/notifications.ts similarity index 69% rename from src/pages/profile/model/types.ts rename to src/pages/profile/model/notifications.ts index c394947..e1bdb4f 100644 --- a/src/pages/profile/model/types.ts +++ b/src/pages/profile/model/notifications.ts @@ -1,5 +1,3 @@ -import { z } from 'zod/v4'; -import * as SProfile from './schemas'; import { TUser } from 'entities/user'; export type Notifications = TUser.UserResponse['notifications']; @@ -10,5 +8,3 @@ export type NotificationItem = { label: string; ariaLabel: string; }; - -export type ProfileFormValues = z.infer; diff --git a/src/pages/profile/model/profile.ts b/src/pages/profile/model/profile.ts new file mode 100644 index 0000000..93fbdaf --- /dev/null +++ b/src/pages/profile/model/profile.ts @@ -0,0 +1,20 @@ +import { CAuth } from 'entities/auth'; +import { z } from 'zod/v4'; + +export const ProfileForm = z.object({ + firstName: z + .string() + .trim() + .min(1, 'Обязательное поле') + .min(CAuth.MIN_NAME_LENGTH, 'Слишком короткое имя') + .max(CAuth.MAX_NAME_LENGTH, 'Слишком длинное имя'), + lastName: z + .string() + .trim() + .min(1, 'Обязательное поле') + .min(CAuth.MIN_NAME_LENGTH, 'Слишком короткая фамилия') + .max(CAuth.MAX_NAME_LENGTH, 'Слишком длинная фамилия'), + bio: z.string().trim().max(512, 'Слишком длинное описание').optional().or(z.literal('')), +}); + +export type ProfileFormValues = z.infer; \ No newline at end of file diff --git a/src/pages/profile/model/schemas.ts b/src/pages/profile/model/schemas.ts deleted file mode 100644 index 13c8155..0000000 --- a/src/pages/profile/model/schemas.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { z } from 'zod/v4'; - -export const ProfileForm = z.object({ - firstName: z - .string() - .trim() - .min(1, 'Обязательное поле') - .min(2, 'Слишком короткое имя') - .max(100, 'Слишком длинное имя'), - lastName: z - .string() - .trim() - .min(1, 'Обязательное поле') - .min(2, 'Слишком короткая фамилия') - .max(100, 'Слишком длинная фамилия'), - bio: z.string().trim().max(512, 'Слишком длинное описание').optional().or(z.literal('')), -}); diff --git a/src/pages/profile/model/useNavigateToTeam.ts b/src/pages/profile/model/useNavigateToTeam.ts new file mode 100644 index 0000000..5d53a95 --- /dev/null +++ b/src/pages/profile/model/useNavigateToTeam.ts @@ -0,0 +1,19 @@ +'use client'; + +import { useTeamStore } from 'entities/team'; +import { useRouter } from 'next/navigation'; +import { useCallback } from 'react'; +import { routes } from 'shared/config'; + +export function useNavigateToTeam() { + const router = useRouter(); + const setTeamSlug = useTeamStore.use.setSlug(); + + return useCallback( + (slug: string) => { + setTeamSlug(slug); + router.push(routes.team.root()); + }, + [router, setTeamSlug], + ); +} diff --git a/src/pages/profile/model/useUpdateNotifications.ts b/src/pages/profile/model/useUpdateNotifications.ts deleted file mode 100644 index 72443d4..0000000 --- a/src/pages/profile/model/useUpdateNotifications.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { type DefaultError, useMutation } from '@tanstack/react-query'; -import { TUser, userFabricKeys, UserHttp } from 'entities/user'; -import { toast } from 'sonner'; - -interface UseUpdateNotificationsProps { - onSuccess?: (body: TUser.NotificationsUpdateBody, res: TUser.NotificationsUpdateResponse) => void; - onError?: (err: Error) => void; -} - -export function useUpdateNotifications({ onSuccess, onError }: UseUpdateNotificationsProps = {}) { - return useMutation< - Awaited, - DefaultError, - TUser.NotificationsUpdateBody - >({ - mutationFn: UserHttp.updateNotificationsConfig, - onError: (err) => { - onError?.(err); - }, - onSuccess: (res, body) => { - onSuccess?.(body, res); - toast.success('Настройки уведомлений обновлены'); - }, - onSettled: async (_d, _e, _v, _m, { client }) => - client.invalidateQueries({ queryKey: userFabricKeys.me() }), - }); -} diff --git a/src/pages/profile/model/useUpdateProfile.ts b/src/pages/profile/model/useUpdateProfile.ts deleted file mode 100644 index f0282c0..0000000 --- a/src/pages/profile/model/useUpdateProfile.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { type DefaultError, useMutation } from '@tanstack/react-query'; -import { type TUser, userFabricKeys, UserHttp } from 'entities/user'; -import { toast } from 'sonner'; - -interface UseUpdateProfileProps { - onSuccess?: (body: TUser.ProfileUpdateBody, res: TUser.ProfileUpdateResponse) => void; - onError?: (err: Error) => void; -} - -export function useUpdateProfile({ onSuccess, onError }: UseUpdateProfileProps = {}) { - return useMutation, DefaultError, TUser.ProfileUpdateBody>({ - mutationFn: UserHttp.updateUserConfig, - onError: (err) => { - onError?.(err); - }, - onSuccess: async (res, body) => { - onSuccess?.(body, res); - toast.success(res.message ?? 'Профиль успешно обновлен'); - }, - onSettled: async (_d, _e, _v, _m, { client }) => - client.invalidateQueries({ queryKey: userFabricKeys.me() }), - }); -} diff --git a/src/pages/profile/ui/me/IdentityItem.tsx b/src/pages/profile/ui/me-page/IdentityItem.tsx similarity index 100% rename from src/pages/profile/ui/me/IdentityItem.tsx rename to src/pages/profile/ui/me-page/IdentityItem.tsx diff --git a/src/pages/profile/ui/me/MePage.tsx b/src/pages/profile/ui/me-page/MePage.tsx similarity index 89% rename from src/pages/profile/ui/me/MePage.tsx rename to src/pages/profile/ui/me-page/MePage.tsx index 69c4d98..4a0fbae 100644 --- a/src/pages/profile/ui/me/MePage.tsx +++ b/src/pages/profile/ui/me-page/MePage.tsx @@ -1,6 +1,10 @@ 'use client'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useQuery } from '@tanstack/react-query'; +import { TUser, UserQueries } from 'entities/user'; import { ComponentProps, useEffect } from 'react'; +import { useForm, useWatch } from 'react-hook-form'; import { Card, CardDescription, @@ -10,16 +14,11 @@ import { FloatingSaveBar, Separator, } from 'shared/ui'; -import { useForm, useWatch } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { TUser, UserQueries } from 'entities/user'; -import { ProfileForm as ProfileFormSchema } from '../../model/schemas'; -import type { ProfileFormValues } from '../../model/types'; -import { useUpdateProfile } from '../../model/useUpdateProfile'; -import { useQuery } from '@tanstack/react-query'; +import { useUpdateProfile } from '../../api/useUpdateProfile'; +import type { ProfileFormValues } from '../../model/profile'; +import { ProfileForm as ProfileFormSchema } from '../../model/profile'; import { IdentityItem } from './IdentityItem'; import { ProfileForm } from './ProfileForm'; -import { UploadHttp } from 'entities/file'; function MePage(props: Omit, 'children'>) { const query = useQuery(UserQueries.getMe()); @@ -75,8 +74,6 @@ function MePage(props: Omit, 'children'>) { updateProfileMutation.mutate(body); }; - void UploadHttp.uploadFile; //todo temporary. fsd linter - return ( <> ; diff --git a/src/pages/profile/ui/notifications/NotificationsPage.tsx b/src/pages/profile/ui/notifications-page/NotificationsPage.tsx similarity index 92% rename from src/pages/profile/ui/notifications/NotificationsPage.tsx rename to src/pages/profile/ui/notifications-page/NotificationsPage.tsx index 0706bc2..99125bf 100644 --- a/src/pages/profile/ui/notifications/NotificationsPage.tsx +++ b/src/pages/profile/ui/notifications-page/NotificationsPage.tsx @@ -3,11 +3,10 @@ import { useReducer } from 'react'; import { CardSection, FloatingSaveBar, OptionGroup, Switch } from 'shared/ui'; import { UserQueries } from 'entities/user'; -import { useUpdateNotifications } from '../../model/useUpdateNotifications'; +import { useUpdateNotifications } from '../../api/useUpdateNotifications'; import { useQuery } from '@tanstack/react-query'; -import { TeamHttp } from 'entities/team'; -import { notificationItems } from '../../model/config'; -import { NotificationChannel, Notifications } from '../../model/types'; +import { notificationItems } from '../../config/notifications'; +import { NotificationChannel, Notifications } from '../../model/notifications'; type NotificationsState = Notifications | null; @@ -58,8 +57,6 @@ function NotificationsPage() { dispatchLocalNotifications({ type: 'set', payload: nextNotifications }); }; - void TeamHttp.getTeam; //todo временно для линтера fsd - return ( <> + + + + + + + + + + {props.teamName} + + От {props.inviterName} · роль: {props.role}. Действует до {formatDate(props.expiresAt)}. + + + + + + + ); +} \ No newline at end of file diff --git a/src/pages/profile/ui/teams-page/Invites.tsx b/src/pages/profile/ui/teams-page/Invites.tsx new file mode 100644 index 0000000..c0fac54 --- /dev/null +++ b/src/pages/profile/ui/teams-page/Invites.tsx @@ -0,0 +1,52 @@ +import { useQuery } from '@tanstack/react-query'; +import { UserQueries } from 'entities/user'; +import { InviteItem } from './InviteItem'; +import { InviteItemSkeleton } from './skeletons/InviteItem.skeleton'; + +export function Invites() { + const invitesQuery = useQuery(UserQueries.getMyInvites()); + + if (invitesQuery.isPending) { + return ( +
+
    + {Array.from({ length: 2 }).map((_, i) => ( +
  • + +
  • + ))} +
+
+ ); + } + + if (invitesQuery.isError) { + return ( +

+ {invitesQuery.error.message} +

+ ); + } + + const invites = invitesQuery.data ?? []; + + return ( +
+
    + { + invites.length === 0 ? ( +

    Входящих приглашений нет.

    + ) : ( + invites.map((inv) => { + return ( +
  • + +
  • + ); + }) + ) + } +
+
+ ); +} diff --git a/src/pages/profile/ui/teams-page/TeamList.tsx b/src/pages/profile/ui/teams-page/TeamList.tsx new file mode 100644 index 0000000..ec84cc9 --- /dev/null +++ b/src/pages/profile/ui/teams-page/TeamList.tsx @@ -0,0 +1,72 @@ +import { useQuery } from '@tanstack/react-query'; +import { TeamAvatar } from 'entities/team'; +import { UserQueries } from 'entities/user'; +import { RemoveTeamDialog } from 'features/teams/remove'; +import { Trash2Icon } from 'lucide-react'; +import { Button, Item, ItemActions, ItemContent, ItemDescription, ItemMedia, ItemTitle, } from 'shared/ui'; +import { useNavigateToTeam } from '../../model/useNavigateToTeam'; +import { TeamsEmpty } from './TeamsEmpty'; +import { TeamItemSkeleton } from './skeletons/TeamItem.skeleton'; + +export function TeamsList() { + const teamsQuery = useQuery(UserQueries.getMyTeams()); + const navigateToTeam = useNavigateToTeam(); + + if (teamsQuery.isPending) { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ); + } + + if (teamsQuery.isError) { + return ( +

+ {teamsQuery.error.message} +

+ ); + } + + const teams = teamsQuery.data ?? []; + if (teams.length === 0) { + return + } + + return ( +
    + {teams.map((team) => ( +
  • + + + + + + {team.name} + {team.description} + + +
    + + + + +
    +
    +
    +
  • + ))} +
+ ); +} diff --git a/src/pages/profile/ui/teams-page/TeamsEmpty.tsx b/src/pages/profile/ui/teams-page/TeamsEmpty.tsx new file mode 100644 index 0000000..670bea0 --- /dev/null +++ b/src/pages/profile/ui/teams-page/TeamsEmpty.tsx @@ -0,0 +1,33 @@ +import { UserRoundXIcon } from 'lucide-react'; + +import { + Button, + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from 'shared/ui'; +import { CreateTeamDialog } from 'features/teams/create'; + +export function TeamsEmpty() { + return ( + + + + + + Команды отсутствуют + + У вас пока нет команд. Начните с создания первой команды. + + + + + + + + + ); +} diff --git a/src/pages/profile/ui/teams-page/TeamsPage.tsx b/src/pages/profile/ui/teams-page/TeamsPage.tsx new file mode 100644 index 0000000..18138cf --- /dev/null +++ b/src/pages/profile/ui/teams-page/TeamsPage.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { CreateTeamDialog } from 'features/teams/create'; +import { Plus } from 'lucide-react'; +import { Button, CardSection, Separator } from 'shared/ui'; +import { Invites } from './Invites'; +import { TeamsList } from './TeamList'; + +function TeamsPage() { + return ( + <> + +
+
+

Мои команды

+ + + +
+ +
+ + + +
+

Приглашения

+ +
+
+ + ); +} + +export { TeamsPage }; diff --git a/src/pages/profile/ui/teams-page/skeletons/InviteItem.skeleton.tsx b/src/pages/profile/ui/teams-page/skeletons/InviteItem.skeleton.tsx new file mode 100644 index 0000000..fdaf3f6 --- /dev/null +++ b/src/pages/profile/ui/teams-page/skeletons/InviteItem.skeleton.tsx @@ -0,0 +1,19 @@ +import { ComponentProps } from 'react'; +import { Item, ItemActions, ItemContent, ItemMedia, Skeleton } from 'shared/ui'; + +export function InviteItemSkeleton(props: Omit, 'children'>) { + return ( + + + + + + + + + + + + + ); +} diff --git a/src/pages/profile/ui/teams-page/skeletons/TeamItem.skeleton.tsx b/src/pages/profile/ui/teams-page/skeletons/TeamItem.skeleton.tsx new file mode 100644 index 0000000..71fafb0 --- /dev/null +++ b/src/pages/profile/ui/teams-page/skeletons/TeamItem.skeleton.tsx @@ -0,0 +1,19 @@ +import { ComponentProps } from 'react'; +import { Item, ItemActions, ItemContent, ItemMedia, Skeleton } from 'shared/ui'; + +export function TeamItemSkeleton(props: Omit, 'children'>) { + return ( + + + + + + + + + + + + + ); +} From 6ba57d361953beacd32cf4976ab72d4aeaa7eb3f Mon Sep 17 00:00:00 2001 From: kapitulin24 Date: Fri, 15 May 2026 01:55:54 +0300 Subject: [PATCH 10/26] refactor(pages/auth): group auth flows into FSD-style slices --- app/(auth)/forgot-password/page.tsx | 2 +- app/(auth)/signin/page.tsx | 2 +- app/(auth)/signup/page.tsx | 2 +- src/entities/auth/model/const.ts | 3 +++ src/entities/auth/model/schemas.ts | 16 ++++++++++++---- src/pages/{ => auth}/forgot-password/index.ts | 0 .../{ => auth}/forgot-password/model/schemas.ts | 0 .../{ => auth}/forgot-password/model/types.ts | 0 .../forgot-password/model/useResetPassword.ts | 0 .../forgot-password/model/useSendCode.ts | 0 .../forgot-password/model/useSendPassword.ts | 0 .../{ => auth}/forgot-password/ui/EmailForm.tsx | 0 .../forgot-password/ui/ForgotPasswordPage.tsx | 0 .../forgot-password/ui/PasswordForm.tsx | 0 src/pages/{ => auth}/signin/index.ts | 0 src/pages/{ => auth}/signin/model/schemas.ts | 0 src/pages/{ => auth}/signin/model/types.ts | 0 src/pages/{ => auth}/signin/model/useSignin.ts | 0 src/pages/{ => auth}/signin/ui/SigninForm.tsx | 0 src/pages/{ => auth}/signin/ui/SigninPage.tsx | 0 src/pages/{ => auth}/signup/index.ts | 0 src/pages/{ => auth}/signup/model/schemas.ts | 6 +++--- src/pages/{ => auth}/signup/model/types.ts | 0 src/pages/{ => auth}/signup/model/useSignup.ts | 0 .../{ => auth}/signup/model/useSignupConfirm.ts | 0 .../signup/model/utils/field-name-mapper.ts | 0 .../signup/model/utils/prepare-fullname.ts | 0 src/pages/{ => auth}/signup/ui/SignupForm.tsx | 0 src/pages/{ => auth}/signup/ui/SignupPage.tsx | 0 29 files changed, 21 insertions(+), 10 deletions(-) rename src/pages/{ => auth}/forgot-password/index.ts (100%) rename src/pages/{ => auth}/forgot-password/model/schemas.ts (100%) rename src/pages/{ => auth}/forgot-password/model/types.ts (100%) rename src/pages/{ => auth}/forgot-password/model/useResetPassword.ts (100%) rename src/pages/{ => auth}/forgot-password/model/useSendCode.ts (100%) rename src/pages/{ => auth}/forgot-password/model/useSendPassword.ts (100%) rename src/pages/{ => auth}/forgot-password/ui/EmailForm.tsx (100%) rename src/pages/{ => auth}/forgot-password/ui/ForgotPasswordPage.tsx (100%) rename src/pages/{ => auth}/forgot-password/ui/PasswordForm.tsx (100%) rename src/pages/{ => auth}/signin/index.ts (100%) rename src/pages/{ => auth}/signin/model/schemas.ts (100%) rename src/pages/{ => auth}/signin/model/types.ts (100%) rename src/pages/{ => auth}/signin/model/useSignin.ts (100%) rename src/pages/{ => auth}/signin/ui/SigninForm.tsx (100%) rename src/pages/{ => auth}/signin/ui/SigninPage.tsx (100%) rename src/pages/{ => auth}/signup/index.ts (100%) rename src/pages/{ => auth}/signup/model/schemas.ts (75%) rename src/pages/{ => auth}/signup/model/types.ts (100%) rename src/pages/{ => auth}/signup/model/useSignup.ts (100%) rename src/pages/{ => auth}/signup/model/useSignupConfirm.ts (100%) rename src/pages/{ => auth}/signup/model/utils/field-name-mapper.ts (100%) rename src/pages/{ => auth}/signup/model/utils/prepare-fullname.ts (100%) rename src/pages/{ => auth}/signup/ui/SignupForm.tsx (100%) rename src/pages/{ => auth}/signup/ui/SignupPage.tsx (100%) diff --git a/app/(auth)/forgot-password/page.tsx b/app/(auth)/forgot-password/page.tsx index f118efc..27f9495 100644 --- a/app/(auth)/forgot-password/page.tsx +++ b/app/(auth)/forgot-password/page.tsx @@ -1 +1 @@ -export { ForgotPasswordPage as default } from 'pages/forgot-password'; +export { ForgotPasswordPage as default } from 'pages/auth/forgot-password'; diff --git a/app/(auth)/signin/page.tsx b/app/(auth)/signin/page.tsx index 8a87638..960dd79 100644 --- a/app/(auth)/signin/page.tsx +++ b/app/(auth)/signin/page.tsx @@ -1 +1 @@ -export { SigninPage as default } from 'pages/signin'; +export { SigninPage as default } from 'pages/auth/signin'; diff --git a/app/(auth)/signup/page.tsx b/app/(auth)/signup/page.tsx index 47b581d..8d0831e 100644 --- a/app/(auth)/signup/page.tsx +++ b/app/(auth)/signup/page.tsx @@ -1 +1 @@ -export { SignupPage as default } from 'pages/signup'; +export { SignupPage as default } from 'pages/auth/signup'; diff --git a/src/entities/auth/model/const.ts b/src/entities/auth/model/const.ts index 7293574..2d40b1f 100644 --- a/src/entities/auth/model/const.ts +++ b/src/entities/auth/model/const.ts @@ -1,3 +1,6 @@ export const MIN_PASS_LENGTH = 8; export const MAX_PASS_LENGTH = 32; export const OTP_LENGTH = 6; + +export const MIN_NAME_LENGTH = 2; +export const MAX_NAME_LENGTH = 50; diff --git a/src/entities/auth/model/schemas.ts b/src/entities/auth/model/schemas.ts index 6342400..5080566 100644 --- a/src/entities/auth/model/schemas.ts +++ b/src/entities/auth/model/schemas.ts @@ -1,6 +1,6 @@ import { z } from 'zod/v4'; import { GlobalSuccess } from 'shared/api'; -import { MAX_PASS_LENGTH, MIN_PASS_LENGTH, OTP_LENGTH } from './const'; +import { MAX_NAME_LENGTH, MAX_PASS_LENGTH, MIN_NAME_LENGTH, MIN_PASS_LENGTH, OTP_LENGTH, } from './const'; export const Email = z.string().min(1, 'Обязательное поле').check(z.email('Неверный формат email')); @@ -26,9 +26,17 @@ export const SignoutResponse = GlobalSuccess; export const SignupBody = z.object({ email: Email, password: Password, - firstName: z.string().min(2, 'Имя должно содержать минимум 2 символа').max(50).trim(), - lastName: z.string().min(2, 'Фамилия должна содержать минимум 2 символа').max(50).trim(), - middleName: z.string().max(50).trim().optional().or(z.literal('')), + firstName: z + .string() + .min(MIN_NAME_LENGTH, `Имя должно содержать минимум ${MIN_NAME_LENGTH} символа`) + .max(MAX_NAME_LENGTH) + .trim(), + lastName: z + .string() + .min(MIN_NAME_LENGTH, `Фамилия должна содержать минимум ${MIN_NAME_LENGTH} символа`) + .max(MAX_NAME_LENGTH) + .trim(), + middleName: z.string().max(MAX_NAME_LENGTH).trim().optional().or(z.literal('')), }); export const SignupResponse = GlobalSuccess; diff --git a/src/pages/forgot-password/index.ts b/src/pages/auth/forgot-password/index.ts similarity index 100% rename from src/pages/forgot-password/index.ts rename to src/pages/auth/forgot-password/index.ts diff --git a/src/pages/forgot-password/model/schemas.ts b/src/pages/auth/forgot-password/model/schemas.ts similarity index 100% rename from src/pages/forgot-password/model/schemas.ts rename to src/pages/auth/forgot-password/model/schemas.ts diff --git a/src/pages/forgot-password/model/types.ts b/src/pages/auth/forgot-password/model/types.ts similarity index 100% rename from src/pages/forgot-password/model/types.ts rename to src/pages/auth/forgot-password/model/types.ts diff --git a/src/pages/forgot-password/model/useResetPassword.ts b/src/pages/auth/forgot-password/model/useResetPassword.ts similarity index 100% rename from src/pages/forgot-password/model/useResetPassword.ts rename to src/pages/auth/forgot-password/model/useResetPassword.ts diff --git a/src/pages/forgot-password/model/useSendCode.ts b/src/pages/auth/forgot-password/model/useSendCode.ts similarity index 100% rename from src/pages/forgot-password/model/useSendCode.ts rename to src/pages/auth/forgot-password/model/useSendCode.ts diff --git a/src/pages/forgot-password/model/useSendPassword.ts b/src/pages/auth/forgot-password/model/useSendPassword.ts similarity index 100% rename from src/pages/forgot-password/model/useSendPassword.ts rename to src/pages/auth/forgot-password/model/useSendPassword.ts diff --git a/src/pages/forgot-password/ui/EmailForm.tsx b/src/pages/auth/forgot-password/ui/EmailForm.tsx similarity index 100% rename from src/pages/forgot-password/ui/EmailForm.tsx rename to src/pages/auth/forgot-password/ui/EmailForm.tsx diff --git a/src/pages/forgot-password/ui/ForgotPasswordPage.tsx b/src/pages/auth/forgot-password/ui/ForgotPasswordPage.tsx similarity index 100% rename from src/pages/forgot-password/ui/ForgotPasswordPage.tsx rename to src/pages/auth/forgot-password/ui/ForgotPasswordPage.tsx diff --git a/src/pages/forgot-password/ui/PasswordForm.tsx b/src/pages/auth/forgot-password/ui/PasswordForm.tsx similarity index 100% rename from src/pages/forgot-password/ui/PasswordForm.tsx rename to src/pages/auth/forgot-password/ui/PasswordForm.tsx diff --git a/src/pages/signin/index.ts b/src/pages/auth/signin/index.ts similarity index 100% rename from src/pages/signin/index.ts rename to src/pages/auth/signin/index.ts diff --git a/src/pages/signin/model/schemas.ts b/src/pages/auth/signin/model/schemas.ts similarity index 100% rename from src/pages/signin/model/schemas.ts rename to src/pages/auth/signin/model/schemas.ts diff --git a/src/pages/signin/model/types.ts b/src/pages/auth/signin/model/types.ts similarity index 100% rename from src/pages/signin/model/types.ts rename to src/pages/auth/signin/model/types.ts diff --git a/src/pages/signin/model/useSignin.ts b/src/pages/auth/signin/model/useSignin.ts similarity index 100% rename from src/pages/signin/model/useSignin.ts rename to src/pages/auth/signin/model/useSignin.ts diff --git a/src/pages/signin/ui/SigninForm.tsx b/src/pages/auth/signin/ui/SigninForm.tsx similarity index 100% rename from src/pages/signin/ui/SigninForm.tsx rename to src/pages/auth/signin/ui/SigninForm.tsx diff --git a/src/pages/signin/ui/SigninPage.tsx b/src/pages/auth/signin/ui/SigninPage.tsx similarity index 100% rename from src/pages/signin/ui/SigninPage.tsx rename to src/pages/auth/signin/ui/SigninPage.tsx diff --git a/src/pages/signup/index.ts b/src/pages/auth/signup/index.ts similarity index 100% rename from src/pages/signup/index.ts rename to src/pages/auth/signup/index.ts diff --git a/src/pages/signup/model/schemas.ts b/src/pages/auth/signup/model/schemas.ts similarity index 75% rename from src/pages/signup/model/schemas.ts rename to src/pages/auth/signup/model/schemas.ts index 59d2cc9..e0d581c 100644 --- a/src/pages/signup/model/schemas.ts +++ b/src/pages/auth/signup/model/schemas.ts @@ -1,5 +1,5 @@ import { z } from 'zod/v4'; -import { SAuth } from 'entities/auth'; +import { CAuth, SAuth } from 'entities/auth'; const MAX_FULL_NAME_WORDS = 2; @@ -9,8 +9,8 @@ export const SignupForm = z .string() .trim() .min(1, 'Обязательное поле') - .min(2, 'Слишком короткое имя') - .max(100, 'Слишком длинное имя') + .min(CAuth.MIN_NAME_LENGTH, 'Слишком короткое имя') + .max(CAuth.MAX_NAME_LENGTH, 'Слишком длинное имя') .refine((name) => name.split(/\s+/).length <= MAX_FULL_NAME_WORDS, { message: 'Введите только имя и фамилию', }), diff --git a/src/pages/signup/model/types.ts b/src/pages/auth/signup/model/types.ts similarity index 100% rename from src/pages/signup/model/types.ts rename to src/pages/auth/signup/model/types.ts diff --git a/src/pages/signup/model/useSignup.ts b/src/pages/auth/signup/model/useSignup.ts similarity index 100% rename from src/pages/signup/model/useSignup.ts rename to src/pages/auth/signup/model/useSignup.ts diff --git a/src/pages/signup/model/useSignupConfirm.ts b/src/pages/auth/signup/model/useSignupConfirm.ts similarity index 100% rename from src/pages/signup/model/useSignupConfirm.ts rename to src/pages/auth/signup/model/useSignupConfirm.ts diff --git a/src/pages/signup/model/utils/field-name-mapper.ts b/src/pages/auth/signup/model/utils/field-name-mapper.ts similarity index 100% rename from src/pages/signup/model/utils/field-name-mapper.ts rename to src/pages/auth/signup/model/utils/field-name-mapper.ts diff --git a/src/pages/signup/model/utils/prepare-fullname.ts b/src/pages/auth/signup/model/utils/prepare-fullname.ts similarity index 100% rename from src/pages/signup/model/utils/prepare-fullname.ts rename to src/pages/auth/signup/model/utils/prepare-fullname.ts diff --git a/src/pages/signup/ui/SignupForm.tsx b/src/pages/auth/signup/ui/SignupForm.tsx similarity index 100% rename from src/pages/signup/ui/SignupForm.tsx rename to src/pages/auth/signup/ui/SignupForm.tsx diff --git a/src/pages/signup/ui/SignupPage.tsx b/src/pages/auth/signup/ui/SignupPage.tsx similarity index 100% rename from src/pages/signup/ui/SignupPage.tsx rename to src/pages/auth/signup/ui/SignupPage.tsx From 435b5348659aec826b2299daae09f9d1e580a2a9 Mon Sep 17 00:00:00 2001 From: kapitulin24 Date: Sat, 16 May 2026 16:12:58 +0300 Subject: [PATCH 11/26] feat(team/settings): migrate to real API with react-hook-form and loading skeletons --- src/pages/team/ui/settings/DangerZone.tsx | 2 +- .../team/ui/settings/DefaultSettings.tsx | 41 +++----- src/pages/team/ui/settings/InviteSecurity.tsx | 33 +++---- src/pages/team/ui/settings/SaveBar.tsx | 30 ++++++ src/pages/team/ui/settings/SettingsPage.tsx | 85 ++++++++++------- src/pages/team/ui/settings/TeamCover.tsx | 73 ++++++++++++++ src/pages/team/ui/settings/TeamIdentity.tsx | 50 ++++++++++ .../team/ui/settings/TeamIdentityForm.tsx | 94 +++++++++++++++++++ .../team/ui/settings/WorkspaceIdentity.tsx | 64 ------------- .../skeletons/DangerZone.skeleton.tsx | 18 ++++ .../skeletons/DefaultSettings.skeleton.tsx | 27 ++++++ .../skeletons/InviteSecurity.skeleton.tsx | 23 +++++ .../skeletons/TeamIdentity.skeleton.tsx | 22 +++++ 13 files changed, 415 insertions(+), 147 deletions(-) create mode 100644 src/pages/team/ui/settings/SaveBar.tsx create mode 100644 src/pages/team/ui/settings/TeamCover.tsx create mode 100644 src/pages/team/ui/settings/TeamIdentity.tsx create mode 100644 src/pages/team/ui/settings/TeamIdentityForm.tsx delete mode 100644 src/pages/team/ui/settings/WorkspaceIdentity.tsx create mode 100644 src/pages/team/ui/settings/skeletons/DangerZone.skeleton.tsx create mode 100644 src/pages/team/ui/settings/skeletons/DefaultSettings.skeleton.tsx create mode 100644 src/pages/team/ui/settings/skeletons/InviteSecurity.skeleton.tsx create mode 100644 src/pages/team/ui/settings/skeletons/TeamIdentity.skeleton.tsx diff --git a/src/pages/team/ui/settings/DangerZone.tsx b/src/pages/team/ui/settings/DangerZone.tsx index 5e3f8ac..e33ea6e 100644 --- a/src/pages/team/ui/settings/DangerZone.tsx +++ b/src/pages/team/ui/settings/DangerZone.tsx @@ -8,7 +8,7 @@ import { ItemMedia, ItemTitle, } from 'shared/ui'; -import { RemoveTeamDialog } from 'features/teams'; +import { RemoveTeamDialog } from 'features/teams/remove'; interface Props { teamName: string; diff --git a/src/pages/team/ui/settings/DefaultSettings.tsx b/src/pages/team/ui/settings/DefaultSettings.tsx index 6a7049f..dd3c8b3 100644 --- a/src/pages/team/ui/settings/DefaultSettings.tsx +++ b/src/pages/team/ui/settings/DefaultSettings.tsx @@ -1,4 +1,6 @@ +import { useId } from 'react'; import { + Badge, CardSection, Field, FieldLabel, @@ -12,30 +14,25 @@ import { SelectValue, Switch, } from 'shared/ui'; -import { type SettingsSetter, type SettingsValues } from '../../model/types'; -import { useId } from 'react'; - -type Props = { - settings: SettingsValues; - set: SettingsSetter; -}; -export function DefaultSettings({ settings, set }: Props) { +export function DefaultSettings() { const roleId = useId(); const autoJoinId = useId(); return ( + Настройки по умолчанию + Не реализовано + + } description="Применяется к новым участникам." > Роль по умолчанию для новых участников - @@ -50,24 +47,12 @@ export function DefaultSettings({ settings, set }: Props) { Домен для автоматического входа - set('autoJoinDomain', e.target.value)} - placeholder="company.com" - /> + ( - set('autoJoin', v)} - {...props} - /> - )} + hint="Все с email 'company.com' входят автоматически." + input={(props) => } /> ); diff --git a/src/pages/team/ui/settings/InviteSecurity.tsx b/src/pages/team/ui/settings/InviteSecurity.tsx index ea12482..8751deb 100644 --- a/src/pages/team/ui/settings/InviteSecurity.tsx +++ b/src/pages/team/ui/settings/InviteSecurity.tsx @@ -1,4 +1,6 @@ +import { useId } from 'react'; import { + Badge, CardSection, Field, FieldLabel, @@ -11,31 +13,24 @@ import { SelectValue, Switch, } from 'shared/ui'; -import { type SettingsSetter, type SettingsValues } from '../../model/types'; -import { useId } from 'react'; - -type Props = { - settings: SettingsValues; - set: SettingsSetter; -}; -export function InviteSecurity({ settings, set }: Props) { +export function InviteSecurity() { const id = useId(); return ( + Безопасность приглашений + Не реализовано + + } description="Настройте поведение приглашений." > Срок действия ссылки приглашения - @@ -51,13 +46,7 @@ export function InviteSecurity({ settings, set }: Props) { ( - set('requireApproval', v)} - {...props} - /> - )} + input={(props) => } /> ); diff --git a/src/pages/team/ui/settings/SaveBar.tsx b/src/pages/team/ui/settings/SaveBar.tsx new file mode 100644 index 0000000..02243b8 --- /dev/null +++ b/src/pages/team/ui/settings/SaveBar.tsx @@ -0,0 +1,30 @@ +import { 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'; + +export function SaveBar({ team }: { team: TTeam.TeamDetailsResponse }) { + const form = useFormContext(); + const { isDirty } = useFormState({ control: form.control }); + + const updateTeam = useUpdateTeam(); + + const onSubmit = (data: TeamSettingsFormValues) => { + const body: TTeam.UpdateTeamBody = { + name: data.name?.trim(), + slug: data.slug?.trim(), + description: data.description?.trim(), + }; + updateTeam.mutateAsync(body); + }; + + return ( + form.reset(team)} + pending={updateTeam.isPending} + /> + ); +} diff --git a/src/pages/team/ui/settings/SettingsPage.tsx b/src/pages/team/ui/settings/SettingsPage.tsx index 2a563ea..4bc600d 100644 --- a/src/pages/team/ui/settings/SettingsPage.tsx +++ b/src/pages/team/ui/settings/SettingsPage.tsx @@ -1,45 +1,66 @@ 'use client'; -import { useState } from 'react'; -import { FloatingSaveBar } from 'shared/ui'; -import { type SettingsValues } from '../../model/types'; -import { WorkspaceIdentity } from './WorkspaceIdentity'; +import { zodResolver } from '@hookform/resolvers/zod'; +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 { InviteSecurity } from './InviteSecurity'; -import { DangerZone } from './DangerZone'; - -const INITIAL: SettingsValues = { - teamName: 'Acme Inc.', - slug: 'acme', - defaultRole: 'Member', - autoJoin: false, - autoJoinDomain: 'acme.io', - linkExpiration: '7d', - requireApproval: true, -}; +import { SaveBar } from './SaveBar'; +import { TeamIdentity } from './TeamIdentity'; +import { DangerZoneSkeleton } from './skeletons/DangerZone.skeleton'; +import { DefaultSettingsSkeleton } from './skeletons/DefaultSettings.skeleton'; +import { InviteSecuritySkeleton } from './skeletons/InviteSecurity.skeleton'; +import { TeamIdentitySkeleton } from './skeletons/TeamIdentity.skeleton'; export function Settings() { - const [settings, setSettings] = useState(INITIAL); - const [saved, setSaved] = useState(INITIAL); - const dirty = JSON.stringify(settings) !== JSON.stringify(saved); + const teamQuery = useQueryTeam(); + const team = teamQuery.data; + + const form = useForm({ + resolver: zodResolver(TeamSettingsFormSchema), + defaultValues: { + name: '', + slug: '', + //todo tags + description: '', + }, + }); - const set = (k: K, v: SettingsValues[K]) => - setSettings((s) => ({ ...s, [k]: v })); + const { reset } = form; + + useEffect(() => { + if (team) { + reset({ + name: team.name, + slug: team.slug, + //todo tags + description: team.description || '', + }); + } + }, [reset, team]); + + if (teamQuery.isError) { + return ( +

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

+ ); + } return ( <> -
- - - - -
- - setSaved(settings)} - onDiscard={() => setSettings(saved)} - /> + +
+ {team ? : } + {team ? : } + {team ? : } + {team ? : } + + {team ? : null} +
); } diff --git a/src/pages/team/ui/settings/TeamCover.tsx b/src/pages/team/ui/settings/TeamCover.tsx new file mode 100644 index 0000000..da0627b --- /dev/null +++ b/src/pages/team/ui/settings/TeamCover.tsx @@ -0,0 +1,73 @@ +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'; + +interface TeamCoverProps { + mutationOptions?: UseUploadFileOptions; + coverUrl: string; +} + +export function TeamCover({ mutationOptions, coverUrl }: TeamCoverProps) { + const fileInputRef = useRef(null); + + const uploadCoverMutation = useUploadCover(mutationOptions); + + const handleCoverPick = () => { + fileInputRef.current?.click(); + }; + + const handleCoverChange = (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) { + return; + } + + uploadCoverMutation.mutate({ file, context: 'team.banner' }); + event.target.value = ''; + }; + + return ( +
+
+ {coverUrl ? ( + + ) : ( +
+ )} +
+
+ +
+ +
+ ); +} diff --git a/src/pages/team/ui/settings/TeamIdentity.tsx b/src/pages/team/ui/settings/TeamIdentity.tsx new file mode 100644 index 0000000..506cbf0 --- /dev/null +++ b/src/pages/team/ui/settings/TeamIdentity.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { UploadAvatar } from 'features/upload-avatar'; +import { TeamAvatar, TTeam } from 'entities/team'; +import { CardSection, Separator } from 'shared/ui'; +import { TeamCover } from './TeamCover'; +import { TeamIdentityForm } from './TeamIdentityForm'; +import { formatDate } from 'shared/lib/utils'; +import { ComponentProps } from 'react'; + +interface TeamIdentityProps extends Omit< + ComponentProps, + 'children' | 'title' | 'description' +> { + team: TTeam.TeamDetailsResponse; +} + +export function TeamIdentity({ team, ...props }: TeamIdentityProps) { + return ( + +
+ + +
+ + } + /> + +
+ + + +

+ Команда создана: {formatDate(team.createdAt)} +

+
+
+ ); +} diff --git a/src/pages/team/ui/settings/TeamIdentityForm.tsx b/src/pages/team/ui/settings/TeamIdentityForm.tsx new file mode 100644 index 0000000..e877a94 --- /dev/null +++ b/src/pages/team/ui/settings/TeamIdentityForm.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { ComponentProps, useId } from 'react'; +import { Controller, useFormContext, useFormState } from 'react-hook-form'; +import { + Field, + FieldError, + FieldLabel, + Input, + InputGroup, + InputGroupAddon, + InputGroupInput, + Textarea, +} from 'shared/ui'; +import { getTeamPathPrefix } from '../../model/team-identity'; + +export function TeamIdentityForm(props: Omit, 'children'>) { + const idName = useId(); + const idSlug = useId(); + const idDescription = useId(); + const teamPathPrefix = getTeamPathPrefix(); + + const form = useFormContext(); + const { isSubmitting } = useFormState({ control: form.control }); + + return ( +
+
+ ( + + Название команды + + {fieldState.invalid && } + + )} + /> + ( + + URL рабочего пространства + + {teamPathPrefix} + { + const v = e.target.value.toLowerCase(); + field.onChange(v); + }} + onBlur={field.onBlur} + name={field.name} + ref={field.ref} + /> + + {fieldState.invalid && } + + )} + /> +
+ ( + + Описание команды +