diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7fed146..fbb20cf 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @kapitulin24 @perekljuchatel +* @kapitulin24 diff --git a/Dockerfile.prod b/Dockerfile.prod index ad91162..58cd06a 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -1,10 +1,11 @@ -FROM node:20-alpine AS base +FROM node:23-alpine AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" -RUN corepack enable +RUN corepack enable && corepack prepare pnpm@10.30.1 --activate WORKDIR /app FROM base AS deps +ENV CI=true RUN apk add --no-cache libc6-compat COPY pnpm-lock.yaml ./ @@ -35,7 +36,7 @@ COPY . . RUN --mount=type=cache,target=/app/.next/cache pnpm run build -FROM node:20-alpine AS runner +FROM node:23-alpine AS runner WORKDIR /app ENV NODE_ENV=production \ 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/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/app/(protected)/page.tsx b/app/(protected)/page.tsx deleted file mode 100644 index 284e44f..0000000 --- a/app/(protected)/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { MePage as default } from 'pages/profile'; diff --git a/app/(protected)/profile/layout.tsx b/app/(protected)/profile/layout.tsx index 8f89916..5ee9d2d 100644 --- a/app/(protected)/profile/layout.tsx +++ b/app/(protected)/profile/layout.tsx @@ -1,12 +1,12 @@ import { PageLayout } from 'app/layouts/PageLayout'; -import { TabsNav } from 'pages/profile'; +import { profileTabs } from 'pages/profile'; export default function ProfileLayout({ children }: { children: React.ReactNode }) { return ( } + tabs={profileTabs} > {children} diff --git a/app/(protected)/profile/teams/page.tsx b/app/(protected)/profile/teams/page.tsx new file mode 100644 index 0000000..e626153 --- /dev/null +++ b/app/(protected)/profile/teams/page.tsx @@ -0,0 +1 @@ +export { TeamsPage as default } from 'pages/profile'; diff --git a/app/(protected)/team/invitations/page.tsx b/app/(protected)/team/invitations/page.tsx new file mode 100644 index 0000000..9f9f851 --- /dev/null +++ b/app/(protected)/team/invitations/page.tsx @@ -0,0 +1 @@ +export { InvitationsPage as default } from 'pages/team'; diff --git a/app/(protected)/team/invites/page.tsx b/app/(protected)/team/invites/page.tsx deleted file mode 100644 index 560fb30..0000000 --- a/app/(protected)/team/invites/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { InvitesPage as default } from 'pages/team'; diff --git a/app/(protected)/team/layout.tsx b/app/(protected)/team/layout.tsx index a8ee106..a89dc4b 100644 --- a/app/(protected)/team/layout.tsx +++ b/app/(protected)/team/layout.tsx @@ -1,6 +1,6 @@ -import { TabsNav } from 'pages/team'; import { PageLayout } from 'app/layouts/PageLayout'; import { Badge } from 'shared/ui'; +import { teamTabs } from 'pages/team'; export default function TeamLayout({ children }: { children: React.ReactNode }) { return ( @@ -8,7 +8,7 @@ export default function TeamLayout({ children }: { children: React.ReactNode }) title="Управление командой" description="Управляйте участниками рабочего пространства, ожидающими приглашениями, ролями и правами доступа." badge={8 участников} - nav={} + tabs={teamTabs} > {children} 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 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/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/PageLayout.tsx b/src/app/layouts/PageLayout.tsx index 28f5750..77cbece 100644 --- a/src/app/layouts/PageLayout.tsx +++ b/src/app/layouts/PageLayout.tsx @@ -1 +1,15 @@ -export { PageLayout } from 'widgets/page-layout'; +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 new file mode 100644 index 0000000..45ca821 --- /dev/null +++ b/src/app/layouts/SidebarLayout.tsx @@ -0,0 +1,23 @@ +import { ComponentProps } from 'react'; +import { TeamSlugSync } from 'features/teams/active-team'; +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/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); } 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..591c867 --- /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: 'user.avatar' | 'team.avatar' | 'team.banner'; +}; 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/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..2b0dee9 100644 --- a/src/entities/auth/model/schemas.ts +++ b/src/entities/auth/model/schemas.ts @@ -1,6 +1,12 @@ 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 +32,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/entities/file/index.ts b/src/entities/file/index.ts deleted file mode 100644 index 6965323..0000000 --- a/src/entities/file/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * as SFile from './model/schemas'; -export * 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/entities/team/api/http.ts b/src/entities/team/api/http.ts index de37977..704698a 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, @@ -104,13 +104,13 @@ export class TeamHttp { } static updateInvitation(slug: string, code: string, data: TTeam.UpdateInvitationBody) { - return api({ + return api({ url: `/teams/${slug}/invitations/${code}`, method: 'PATCH', data, contracts: { body: STeam.UpdateInvitationBody, - response: STeam.TeamInvitationResponse, + response: STeam.ActionResponse, }, }); } @@ -170,23 +170,6 @@ export class TeamHttp { }); } - static updateBanner(slug: string, file: File) { - const formData = new FormData(); - formData.append('file', file); - - return api({ - url: `/teams/${slug}/banner`, - method: 'PATCH', - data: formData, - headers: { - 'Content-Type': 'multipart/form-data', - }, - contracts: { - response: STeam.FileUploadResponse, - }, - }); - } - static getProjects(slug: string, signal?: AbortSignal) { return api({ url: `/teams/${slug}/projects`, diff --git a/src/entities/team/api/queries.ts b/src/entities/team/api/queries.ts index 2a5b3f8..d2d09e3 100644 --- a/src/entities/team/api/queries.ts +++ b/src/entities/team/api/queries.ts @@ -8,7 +8,6 @@ export class TeamQueries { queryKey: teamFabricKeys.bySlug(slug), queryFn: async ({ signal }) => TeamHttp.getTeam(slug, signal), staleTime: 60_000, - refetchOnMount: false, }); } @@ -16,8 +15,8 @@ export class TeamQueries { return queryOptions({ queryKey: teamFabricKeys.checkSlug(slug), queryFn: async ({ signal }) => TeamHttp.checkSlug(slug, signal), - staleTime: 60_000, - refetchOnMount: false, + gcTime: 5000, + staleTime: 5000, }); } @@ -26,7 +25,6 @@ export class TeamQueries { queryKey: teamFabricKeys.invitation(slug, code), queryFn: async ({ signal }) => TeamHttp.getInvitation(slug, code, signal), staleTime: 60_000, - refetchOnMount: false, }); } @@ -35,7 +33,6 @@ export class TeamQueries { queryKey: teamFabricKeys.invitations(slug), queryFn: async ({ signal }) => TeamHttp.getInvitations(slug, signal), staleTime: 60_000, - refetchOnMount: false, }); } @@ -44,7 +41,6 @@ export class TeamQueries { queryKey: teamFabricKeys.members(slug), queryFn: async ({ signal }) => TeamHttp.getMembers(slug, signal), staleTime: 60_000, - refetchOnMount: false, }); } @@ -53,7 +49,6 @@ export class TeamQueries { queryKey: teamFabricKeys.projects(slug), queryFn: async ({ signal }) => TeamHttp.getProjects(slug, signal), staleTime: 60_000, - refetchOnMount: false, }); } @@ -62,7 +57,6 @@ export class TeamQueries { queryKey: [...teamFabricKeys.project(slug, id), token ?? null], queryFn: async ({ signal }) => TeamHttp.getProject(slug, id, token, signal), staleTime: 60_000, - refetchOnMount: false, }); } } diff --git a/src/entities/team/config/roles.ts b/src/entities/team/config/roles.ts new file mode 100644 index 0000000..8cbde32 --- /dev/null +++ b/src/entities/team/config/roles.ts @@ -0,0 +1,13 @@ +import type { TeamRole } from '../model/types'; + +export const ROLE_LABELS: Record, string> = { + admin: 'Администратор', + lead: 'Лид', + moderator: 'Модератор', + member: 'Участник', + viewer: 'Гость', +} as const; + +export const INVITATION_ROLES = [ + ...new Set(['admin', 'lead', 'moderator', 'member', 'viewer']), +] as const; diff --git a/src/entities/team/config/statuses.ts b/src/entities/team/config/statuses.ts new file mode 100644 index 0000000..710c4fb --- /dev/null +++ b/src/entities/team/config/statuses.ts @@ -0,0 +1,11 @@ +import type { MemberStatus } from '../model/types'; + +export const STATUS_LABELS: Record = { + active: 'Активен', + banned: 'Заблокирован', + inactive: 'Неактивен', +} as const; + +export const MEMBER_STATUSES = [ + ...new Set(['active', 'inactive', 'banned']), +] as const; diff --git a/src/entities/team/index.ts b/src/entities/team/index.ts index ca8b6fa..326e935 100644 --- a/src/entities/team/index.ts +++ b/src/entities/team/index.ts @@ -1,5 +1,12 @@ 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'; +export { MAX_SLUG_LENGTH, MIN_SLUG_LENGTH, teamFabricKeys } from './model/const'; +export { ROLE_LABELS, INVITATION_ROLES } from './config/roles'; +export { STATUS_LABELS, MEMBER_STATUSES } from './config/statuses'; +export { useCheckSlug } from './lib/useCheckSlug'; +export { validateTeamSlugAsync } from './lib/validate-team-slug'; +export { useTeamStore } from './model/store'; +export { TeamAvatar } from './ui/TeamAvatar'; +export { SlugField } from './ui/SlugField'; diff --git a/src/entities/team/lib/useCheckSlug.ts b/src/entities/team/lib/useCheckSlug.ts new file mode 100644 index 0000000..1928b51 --- /dev/null +++ b/src/entities/team/lib/useCheckSlug.ts @@ -0,0 +1,90 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import type { FieldErrors } from 'react-hook-form'; +import { debounce } from 'shared/lib/utils'; +import { TeamQueries } from '../api/queries'; +import { MAX_SLUG_LENGTH, MIN_SLUG_LENGTH, teamFabricKeys } from '../model/const'; + +const DEBOUNCE_MS = 400; +const SLUG_UNAVAILABLE_MESSAGE = 'Этот адрес уже занят'; + +export type CheckSlugErrors = FieldErrors<{ slug: string }>; + +function prepareResponse(available: boolean, message?: string): CheckSlugErrors { + if (available) { + return {}; + } + + return { + slug: { + type: 'validate', + message: message ?? SLUG_UNAVAILABLE_MESSAGE, + }, + }; +} + +export function useCheckSlug(defaultValue: string) { + const currentSlug = useRef(defaultValue); + const pendingResolve = useRef<((errors: CheckSlugErrors) => void) | null>(null); + const queryClient = useQueryClient(); + + const resolvePending = useCallback((errors: CheckSlugErrors) => { + pendingResolve.current?.(errors); + pendingResolve.current = null; + }, []); + + const debouncedCheckSlug = useMemo(() => { + // eslint-disable-next-line react-hooks/refs -- refs read only in async-callback debounce + return debounce(async (value: string) => { + try { + const data = await queryClient.fetchQuery(TeamQueries.checkSlug(value)); + + // when the server response arrives but is already outdated + if (value !== currentSlug.current) { + return; + } + + resolvePending(prepareResponse(data.available, data.message)); + } catch { + if (value === currentSlug.current) { + resolvePending({}); + } + } + }, DEBOUNCE_MS); + }, [queryClient, resolvePending]); + + const cancel = useCallback(() => { + debouncedCheckSlug.cancelDebouncedCallback(); + queryClient.cancelQueries({ queryKey: teamFabricKeys.checkSlug() }); + resolvePending({}); + }, [debouncedCheckSlug, queryClient, resolvePending]); + + useEffect(() => { + return () => { + cancel(); + queryClient.removeQueries({ queryKey: teamFabricKeys.checkSlug() }); + }; + }, [cancel, queryClient]); + + return useCallback( + (value: string): Promise => + new Promise((resolve) => { + const isValid = + defaultValue !== value && + value.length >= MIN_SLUG_LENGTH && + value.length <= MAX_SLUG_LENGTH; + + cancel(); + currentSlug.current = value; + + if (!isValid) { + resolve({}); + return; + } + + pendingResolve.current = resolve; + debouncedCheckSlug.debouncedCallback(value); + }), + [cancel, debouncedCheckSlug, defaultValue] + ); +} diff --git a/src/entities/team/lib/useSlugFieldStatus.ts b/src/entities/team/lib/useSlugFieldStatus.ts new file mode 100644 index 0000000..3b8c139 --- /dev/null +++ b/src/entities/team/lib/useSlugFieldStatus.ts @@ -0,0 +1,41 @@ +import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { TeamQueries } from '../api/queries'; +import { MAX_SLUG_LENGTH, MIN_SLUG_LENGTH } from '../model/const'; + +type SlugFieldStatusValue = 'pending' | 'success' | 'error'; + +interface SlugFieldStatusState { + isDirty: boolean; + slug: string; +} + +export function useSlugFieldStatus({ + isDirty, + slug, +}: SlugFieldStatusState): SlugFieldStatusValue | undefined { + const { data, isPending } = useQuery({ + ...TeamQueries.checkSlug(slug), + enabled: false, + }); + + return useMemo(() => { + if (!isDirty || slug.length < MIN_SLUG_LENGTH || slug.length > MAX_SLUG_LENGTH) { + return undefined; + } + + if (isPending) { + return 'pending'; + } + + if (data?.available === false) { + return 'error'; + } + + if (data?.available === true) { + return 'success'; + } + + return undefined; + }, [data?.available, isDirty, isPending, slug]); +} diff --git a/src/entities/team/lib/validate-team-slug.ts b/src/entities/team/lib/validate-team-slug.ts new file mode 100644 index 0000000..acdd285 --- /dev/null +++ b/src/entities/team/lib/validate-team-slug.ts @@ -0,0 +1,21 @@ +import type { FieldValues, ResolverOptions } from 'react-hook-form'; +import type { CheckSlugErrors } from './useCheckSlug'; + +type SlugFormValues = { slug?: string }; + +function shouldValidateSlugAsync(names: readonly string[] | undefined): boolean { + return !names?.length || names.includes('slug'); +} + +export function validateTeamSlugAsync( + checkSlug: (value: string) => Promise, + values: T, + _context: unknown, + options: ResolverOptions +): Promise { + if (!shouldValidateSlugAsync(options.names as readonly string[] | undefined)) { + return Promise.resolve({}); + } + + return checkSlug(values.slug ?? ''); +} diff --git a/src/entities/team/model/const.ts b/src/entities/team/model/const.ts index d08a8d8..4ce135e 100644 --- a/src/entities/team/model/const.ts +++ b/src/entities/team/model/const.ts @@ -1,8 +1,11 @@ import { createEntityKeys } from 'shared/lib/utils'; +export const MIN_SLUG_LENGTH = 2; +export const MAX_SLUG_LENGTH = 100; + export const teamFabricKeys = createEntityKeys('team', { bySlug: (slug: string) => ['teams', slug], - checkSlug: (slug: string) => ['teams', 'check-slug', slug], + checkSlug: (slug?: string) => ['teams', 'check-slug', slug].filter(Boolean), invitations: (slug: string) => ['teams', slug, 'invitations'], invitation: (slug: string, code: string) => ['teams', slug, 'invitations', code], members: (slug: string) => ['teams', slug, 'members'], diff --git a/src/entities/team/model/schemas.ts b/src/entities/team/model/schemas.ts index f3602e4..27ecc17 100644 --- a/src/entities/team/model/schemas.ts +++ b/src/entities/team/model/schemas.ts @@ -1,5 +1,15 @@ -import { z } from 'zod/v4'; import { GlobalSuccess } from 'shared/api'; +import { z } from 'zod/v4'; +import { MAX_SLUG_LENGTH, MIN_SLUG_LENGTH } from './const'; + +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', @@ -9,6 +19,7 @@ export const TeamRole = z.enum([ 'member', // обычный работяга 'viewer', // просто смотрит ]); + export const MemberStatus = z.enum([ 'active', // Полноценный участник 'banned', // Заблокирован не может вернуться по инвайту @@ -16,9 +27,33 @@ export const MemberStatus = z.enum([ ]); export const CreateTeamBody = z.object({ - name: z.string().min(2).max(100), - description: z.string().min(10).max(500), - slug: z.string().optional(), + name: z + .string() + .min(1, 'Укажите название команды') + .min(2, 'Название должно содержать не менее 2 символов') + .max(100, 'Название не может быть длиннее 100 символов'), + description: z + .string() + .min(1, 'Добавьте описание команды') + .min(10, 'Описание должно содержать не менее 10 символов') + .max(256, 'Описание не может быть длиннее 256 символов'), + slug: z + .string() + .optional() + .transform((val) => (val === '' || val === undefined ? undefined : val)) + .pipe( + z + .string() + .min( + MIN_SLUG_LENGTH, + `Короткий адрес должен содержать не менее ${MIN_SLUG_LENGTH} символов` + ) + .max( + MAX_SLUG_LENGTH, + `Короткий адрес в ссылке не может быть длиннее ${MAX_SLUG_LENGTH} символов` + ) + .optional() + ), tags: z .array(z.string()) .optional() @@ -28,7 +63,7 @@ export const CreateTeamBody = z.object({ if (hasDuplicates) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: 'Теги в списке не должны повторяться (регистр не важен)', + message: 'Теги в списке не должны повторяться', }); } }), @@ -52,7 +87,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 +99,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(), @@ -75,7 +110,7 @@ export const TeamInvitationResponse = z.object({ export const InviteMemberBody = z.object({ email: z.email(), - role: TeamRole.default('member'), + role: TeamRole, }); export const UpdateInvitationBody = z.object({ @@ -86,10 +121,12 @@ export const TeamMemberResponse = z.object({ id: z.string(), role: TeamRole, status: MemberStatus, + email: z.email(), + middleName: z.string().nullable(), fullName: z.string(), firstName: z.string(), lastName: z.string(), - avatarUrl: z.url().nullable(), + avatar: TeamAvatarSchema, initials: z.string().max(2), joinedAt: z.iso.datetime({}), }); @@ -120,12 +157,6 @@ export const SyncTagsBody = z.object({ }), }); -export const FileUploadResponse = z.object({ - success: z.boolean(), - url: z.string(), - message: z.string().optional(), -}); - export const ActionResponse = GlobalSuccess; export const CreateProjectBody = z.object({ diff --git a/src/entities/team/model/store.ts b/src/entities/team/model/store.ts new file mode 100644 index 0000000..05d491d --- /dev/null +++ b/src/entities/team/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/entities/team/model/types.ts b/src/entities/team/model/types.ts index 55cfafe..ddf825d 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; @@ -16,7 +18,6 @@ export type InviteMemberBody = z.infer; export type UpdateInvitationBody = z.infer; export type UpdateMemberBody = z.infer; export type SyncTagsBody = z.infer; -export type FileUploadResponse = z.infer; export type ActionResponse = z.infer; export type CreateProjectBody = z.infer; diff --git a/src/entities/team/ui/SlugField.tsx b/src/entities/team/ui/SlugField.tsx new file mode 100644 index 0000000..f05192a --- /dev/null +++ b/src/entities/team/ui/SlugField.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { useId } from 'react'; +import { Controller, FieldPath, FieldValues, useFormContext } from 'react-hook-form'; +import { cn } from 'shared/lib/utils'; +import { + Field, + FieldError, + FieldLabel, + InputGroup, + InputGroupAddon, + InputGroupInput, +} from 'shared/ui'; +import { SlugFieldStatus } from './SlugFieldStatus'; + +interface SlugFieldProps { + name: FieldPath; + disabled?: boolean; + label?: string; + prefix?: string; + placeholder?: string; + className?: string; +} + +export function SlugField({ + disabled = false, + label = 'Короткий адрес в ссылке (необязательно)', + prefix, + placeholder = 'my-team', + className, + name, +}: SlugFieldProps) { + const id = useId(); + const { trigger, control } = useFormContext(); + + return ( + ( + + {label} + + {prefix ? {prefix} : null} + { + field.onChange(e.target.value.trim().toLowerCase()); + void trigger(name); + }} + id={id} + aria-label={label} + placeholder={placeholder} + aria-invalid={fieldState.invalid} + autoComplete="off" + disabled={disabled} + /> + + + + + {fieldState.invalid && } + + )} + /> + ); +} diff --git a/src/entities/team/ui/SlugFieldStatus.tsx b/src/entities/team/ui/SlugFieldStatus.tsx new file mode 100644 index 0000000..ada8a83 --- /dev/null +++ b/src/entities/team/ui/SlugFieldStatus.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { CheckCircle, XCircle } from 'lucide-react'; +import { Spinner } from 'shared/ui'; +import { useSlugFieldStatus } from '../lib/useSlugFieldStatus'; + +interface SlugFieldStatusProps { + slug: string; + isDirty: boolean; +} + +export function SlugFieldStatus({ slug, isDirty }: SlugFieldStatusProps) { + const status = useSlugFieldStatus({ isDirty, slug }); + + if (!status) { + return null; + } + + return ( + <> + {status === 'pending' ? ( + + ) : status === 'error' ? ( + + ) : ( + + )} + + ); +} diff --git a/src/entities/team/ui/TeamAvatar.tsx b/src/entities/team/ui/TeamAvatar.tsx new file mode 100644 index 0000000..7531c4f --- /dev/null +++ b/src/entities/team/ui/TeamAvatar.tsx @@ -0,0 +1,19 @@ +import { UsersIcon } from 'lucide-react'; +import { ComponentProps } from 'react'; +import { Avatar, AvatarFallback, AvatarImage } from 'shared/ui'; + +interface TeamAvatarProps extends ComponentProps { + wrap?: Omit, 'children'>; + fallback?: Omit, 'children'>; +} + +export function TeamAvatar({ wrap = {}, fallback = {}, ...props }: TeamAvatarProps) { + return ( + + + + {!fallback.firstName && !fallback.lastName ? : null} + + + ); +} diff --git a/src/entities/user/api/http.ts b/src/entities/user/api/http.ts index 4a78460..62e485e 100644 --- a/src/entities/user/api/http.ts +++ b/src/entities/user/api/http.ts @@ -59,12 +59,12 @@ export class UserHttp { }); } - static getMyInvites(signal?: AbortSignal) { - return api({ + static getMyInvitations(signal?: AbortSignal) { + return api({ url: '/users/me/invites', method: 'GET', contracts: { - response: SUser.UserInviteResponse.array(), + response: SUser.UserInvitationResponse.array(), }, signal, }); diff --git a/src/entities/user/api/queries.ts b/src/entities/user/api/queries.ts index 82dd123..c5388b1 100644 --- a/src/entities/user/api/queries.ts +++ b/src/entities/user/api/queries.ts @@ -1,5 +1,5 @@ -import { userFabricKeys } from '../model/const'; import { queryOptions } from '@tanstack/react-query'; +import { userFabricKeys } from '../model/const'; import { UserHttp } from './http'; export class UserQueries { @@ -8,7 +8,6 @@ export class UserQueries { queryKey: userFabricKeys.me(), queryFn: async ({ signal }) => UserHttp.getUser(signal), staleTime: 60_000, - refetchOnMount: false, }); } @@ -17,7 +16,6 @@ export class UserQueries { queryKey: userFabricKeys.meActivity(), queryFn: async ({ signal }) => UserHttp.getUserActivity(signal), staleTime: 60_000, - refetchOnMount: false, }); } @@ -26,16 +24,14 @@ export class UserQueries { queryKey: userFabricKeys.myTeams(), queryFn: async ({ signal }) => UserHttp.getMyTeams(signal), staleTime: 60_000, - refetchOnMount: false, }); } - static getMyInvites() { + static getMyInvitations() { return queryOptions({ - queryKey: userFabricKeys.myInvites(), - queryFn: async ({ signal }) => UserHttp.getMyInvites(signal), + queryKey: userFabricKeys.myInvitations(), + queryFn: async ({ signal }) => UserHttp.getMyInvitations(signal), staleTime: 60_000, - refetchOnMount: false, }); } } diff --git a/src/entities/user/index.ts b/src/entities/user/index.ts index 464f23d..3efd22d 100644 --- a/src/entities/user/index.ts +++ b/src/entities/user/index.ts @@ -1,5 +1,6 @@ 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'; +export { UserAvatar } from './ui/UserAvatar'; diff --git a/src/entities/user/model/const.ts b/src/entities/user/model/const.ts index 79da620..58c26b1 100644 --- a/src/entities/user/model/const.ts +++ b/src/entities/user/model/const.ts @@ -4,5 +4,5 @@ export const userFabricKeys = createEntityKeys('user', { me: () => ['users', 'me'], meActivity: () => ['users', 'me', 'activity'], myTeams: () => ['users', 'me', 'teams'], - myInvites: () => ['users', 'me', 'invites'], + myInvitations: () => ['users', 'me', 'invitations'], }); diff --git a/src/entities/user/model/schemas.ts b/src/entities/user/model/schemas.ts index d4504da..dab0a6b 100644 --- a/src/entities/user/model/schemas.ts +++ b/src/entities/user/model/schemas.ts @@ -79,20 +79,20 @@ 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(), + description: z.string(), + avatar: UserAvatarSchema, role: z.string(), joinedAt: z.iso.datetime({}), permissions: TeamPermissions, }); -export const UserInviteResponse = z.object({ +export const UserInvitationResponse = z.object({ code: z.string(), teamName: z.string(), - teamAvatar: z.string().nullable(), + teamAvatar: UserAvatarSchema, role: z.string(), inviterName: z.string(), expiresAt: z.iso.datetime({}), diff --git a/src/entities/user/model/types.ts b/src/entities/user/model/types.ts index e798783..6becd41 100644 --- a/src/entities/user/model/types.ts +++ b/src/entities/user/model/types.ts @@ -7,4 +7,4 @@ export type NotificationsUpdateResponse = z.infer; export type ProfileUpdateResponse = z.infer; export type UserTeamResponse = z.infer; -export type UserInviteResponse = z.infer; +export type UserInvitationResponse = z.infer; diff --git a/src/entities/user/ui/UserAvatar.tsx b/src/entities/user/ui/UserAvatar.tsx new file mode 100644 index 0000000..23bb02c --- /dev/null +++ b/src/entities/user/ui/UserAvatar.tsx @@ -0,0 +1,19 @@ +import { UserRoundIcon } from 'lucide-react'; +import { ComponentProps } from 'react'; +import { Avatar, AvatarFallback, AvatarImage } from 'shared/ui'; + +interface UserAvatarProps extends ComponentProps { + wrap?: Omit, 'children'>; + fallback?: Omit, 'children'>; +} + +export function UserAvatar({ wrap = {}, fallback = {}, ...props }: UserAvatarProps) { + return ( + + + + {!fallback.firstName && !fallback.lastName ? : null} + + + ); +} diff --git a/src/features/teams/active-team/index.ts b/src/features/teams/active-team/index.ts new file mode 100644 index 0000000..bf5608f --- /dev/null +++ b/src/features/teams/active-team/index.ts @@ -0,0 +1,2 @@ +export { useSwitchTeam } from './model/useSwitchTeam'; +export { TeamSlugSync } from './ui/TeamSlugSync'; diff --git a/src/features/teams/active-team/model/useSwitchTeam.ts b/src/features/teams/active-team/model/useSwitchTeam.ts new file mode 100644 index 0000000..f5dbe7a --- /dev/null +++ b/src/features/teams/active-team/model/useSwitchTeam.ts @@ -0,0 +1,45 @@ +'use client'; + +import { useTeamStore } from 'entities/team'; +import { TUser } from 'entities/user'; +import { useRouter } from 'next/navigation'; +import { useCallback } from 'react'; +import { routes } from 'shared/config'; +import { toast } from 'sonner'; + +interface SwitchTeamOptions { + redirect?: boolean; + showToast?: boolean; +} + +interface UseSwitchTeamProps { + teams?: TUser.UserTeamResponse[]; + defaultOptions?: SwitchTeamOptions; +} + +export function useSwitchTeam({ teams = [], defaultOptions = {} }: UseSwitchTeamProps = {}) { + const router = useRouter(); + const setSlug = useTeamStore.use.setSlug(); + + const switchTeam = useCallback( + (slug: string, options: SwitchTeamOptions = {}) => { + const { redirect = false, showToast = true } = { ...defaultOptions, ...options }; + const team = teams.find((t) => t.slug === slug); + + if (!team) { + if (showToast) toast.error('Команда не найдена!'); + return; + } + + setSlug(slug); + if (showToast) toast.success(`Вы сменили команду на "${team.name}"`); + + if (redirect) { + router.push(routes.team.root()); + } + }, + [setSlug, teams, router, defaultOptions] + ); + + return { switchTeam }; +} diff --git a/src/features/teams/active-team/model/useTeamsQueryWithSlugSync.ts b/src/features/teams/active-team/model/useTeamsQueryWithSlugSync.ts new file mode 100644 index 0000000..3754b68 --- /dev/null +++ b/src/features/teams/active-team/model/useTeamsQueryWithSlugSync.ts @@ -0,0 +1,22 @@ +import { useQuery } from '@tanstack/react-query'; +import { UserQueries } from 'entities/user'; +import { useEffect } from 'react'; +import { useTeamStore } from 'entities/team'; + +export function useTeamsQueryWithSlugSync() { + const query = useQuery(UserQueries.getMyTeams()); + const slug = useTeamStore.use.slug(); + const setCurrentTeamSlug = useTeamStore.use.setSlug(); + + useEffect(() => { + if (!query.data) return; + + const hasTeamSlug = !!slug && query.data.some((d) => d.slug === slug); + + if (hasTeamSlug) return; + + setCurrentTeamSlug(query.data[0]?.slug); + }, [slug, setCurrentTeamSlug, query.data]); + + return { query, slug }; +} diff --git a/src/features/teams/active-team/ui/TeamSlugSync.tsx b/src/features/teams/active-team/ui/TeamSlugSync.tsx new file mode 100644 index 0000000..c972a5b --- /dev/null +++ b/src/features/teams/active-team/ui/TeamSlugSync.tsx @@ -0,0 +1,8 @@ +'use client'; + +import { useTeamsQueryWithSlugSync } from '../model/useTeamsQueryWithSlugSync'; + +export function TeamSlugSync() { + useTeamsQueryWithSlugSync(); + return null; +} diff --git a/src/features/teams/create/index.ts b/src/features/teams/create/index.ts new file mode 100644 index 0000000..779b654 --- /dev/null +++ b/src/features/teams/create/index.ts @@ -0,0 +1,2 @@ +export { CreateTeamForm } from './ui/CreateTeamForm'; +export { CreateTeamDialog } from './ui/CreateTeamDialog'; diff --git a/src/features/teams/create/model/schemas.ts b/src/features/teams/create/model/schemas.ts new file mode 100644 index 0000000..a81aa7b --- /dev/null +++ b/src/features/teams/create/model/schemas.ts @@ -0,0 +1,3 @@ +import { STeam } from 'entities/team'; + +export const CreateTeamFormSchema = STeam.CreateTeamBody; diff --git a/src/features/teams/create/model/types.ts b/src/features/teams/create/model/types.ts new file mode 100644 index 0000000..fa4d1a6 --- /dev/null +++ b/src/features/teams/create/model/types.ts @@ -0,0 +1,4 @@ +import { z } from 'zod/v4'; +import { CreateTeamFormSchema } from './schemas'; + +export type CreateTeamFormValues = z.infer; diff --git a/src/features/teams/create/model/useCreateTeam.ts b/src/features/teams/create/model/useCreateTeam.ts new file mode 100644 index 0000000..ca80016 --- /dev/null +++ b/src/features/teams/create/model/useCreateTeam.ts @@ -0,0 +1,21 @@ +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'; + +export type UseCreateTeamOptions = Omit< + UseMutationOptions, + 'mutationFn' +>; + +export function useCreateTeam({ onSuccess, ...rest }: UseCreateTeamOptions = {}) { + return useMutation({ + ...rest, + mutationFn: TeamHttp.createTeam, + onSuccess: async (res, _v, _r, context) => { + onSuccess?.(res, _v, _r, context); + toast.success(res.message ?? 'Команда создана'); + await context.client.invalidateQueries({ queryKey: userFabricKeys.myTeams() }); + }, + }); +} diff --git a/src/features/teams/create/model/useCreateTeamForm.ts b/src/features/teams/create/model/useCreateTeamForm.ts new file mode 100644 index 0000000..a8f8f13 --- /dev/null +++ b/src/features/teams/create/model/useCreateTeamForm.ts @@ -0,0 +1,51 @@ +import { type TTeam, useCheckSlug, validateTeamSlugAsync } from 'entities/team'; +import { useForm } from 'react-hook-form'; +import { extractValidationIssues } from 'shared/api'; +import { useZodValidationWithAsyncCheck } from 'shared/lib/hooks'; +import { setFormErrors } from 'shared/lib/utils'; +import { CreateTeamFormSchema } from './schemas'; +import type { CreateTeamFormValues } from './types'; +import { useCreateTeam, type UseCreateTeamOptions } from './useCreateTeam'; + +export function useCreateTeamForm(mutateOptions: UseCreateTeamOptions = {}) { + const checkSlug = useCheckSlug(''); + + const form = useForm({ + resolver: useZodValidationWithAsyncCheck(CreateTeamFormSchema, (...args) => + validateTeamSlugAsync(checkSlug, ...args) + ), + defaultValues: { + name: '', + description: '', + slug: '', + }, + }); + + const createTeam = useCreateTeam({ + ...mutateOptions, + meta: { + skipGlobalValidationToast: true, + }, + onError: (err, ...args) => { + mutateOptions.onError?.(err, ...args); + setFormErrors(extractValidationIssues(err), form); + }, + }); + + const onSubmit = (data: CreateTeamFormValues) => { + const body: TTeam.CreateTeamBody = { + name: data.name.trim(), + description: data.description.trim(), + ...(data.slug?.trim() ? { slug: data.slug.trim() } : {}), + tags: [''], //todo + }; + + createTeam.mutate(body); + }; + + return { + form, + isPending: createTeam.isPending, + handleSubmit: form.handleSubmit(onSubmit), + }; +} diff --git a/src/features/teams/create/ui/CreateTeamDialog.tsx b/src/features/teams/create/ui/CreateTeamDialog.tsx new file mode 100644 index 0000000..bba9b99 --- /dev/null +++ b/src/features/teams/create/ui/CreateTeamDialog.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { ComponentProps, useId, useState } from 'react'; +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + Spinner, +} from 'shared/ui'; +import { CreateTeamForm } from './CreateTeamForm'; +import { useControllableState } from 'shared/lib/hooks'; + +interface CreateTeamDialogProps extends ComponentProps { + dialog?: ComponentProps; +} + +export function CreateTeamDialog({ dialog = {}, ...props }: CreateTeamDialogProps) { + const [open, setOpen] = useControllableState({ + defaultValue: dialog.defaultOpen, + value: dialog.open, + onChange: dialog.onOpenChange, + }); + const formId = useId(); + const [pending, setPending] = useState(false); + + return ( + + + + + Новая команда + + Укажите название и описание. Короткий адрес в ссылке можно задать вручную или оставить + пустым. + + + + { + setPending(true); + }, + onSuccess: () => { + setOpen(false); + }, + onSettled: () => { + setPending(false); + }, + }} + /> + + + + + + + + + + ); +} diff --git a/src/features/teams/create/ui/CreateTeamForm.tsx b/src/features/teams/create/ui/CreateTeamForm.tsx new file mode 100644 index 0000000..c6717e0 --- /dev/null +++ b/src/features/teams/create/ui/CreateTeamForm.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { SlugField } from 'entities/team'; +import { ComponentProps } from 'react'; +import { Controller, FormProvider } from 'react-hook-form'; +import { cn } from 'shared/lib/utils'; +import { Field, FieldError, FieldGroup, FieldLabel, Input, Textarea } from 'shared/ui'; +import type { UseCreateTeamOptions } from '../model/useCreateTeam'; +import { useCreateTeamForm } from '../model/useCreateTeamForm'; + +interface CreateTeamFormProps extends Omit, 'children' | 'onSubmit'> { + mutateOptions?: UseCreateTeamOptions; +} + +export function CreateTeamForm({ className, mutateOptions, ...props }: CreateTeamFormProps) { + const { form, isPending, handleSubmit } = useCreateTeamForm(mutateOptions); + + return ( + +
+ + ( + + Название + + {fieldState.invalid && } + + )} + /> + + ( + + Описание +