From 382db993769e1cea1e592edfbcdce7509858b8bc Mon Sep 17 00:00:00 2001 From: LMS10 Date: Thu, 7 May 2026 00:21:30 +0900 Subject: [PATCH 01/11] =?UTF-8?q?=E2=9C=A8=20Feat:=20auth,=20workspace=20A?= =?UTF-8?q?PI=20=ED=83=80=EC=9E=85=20=EB=B0=8F=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/auth/auth.service.ts | 16 +++++++ src/apis/auth/auth.type.ts | 12 ++++++ src/apis/workspace/workspace.service.ts | 56 +++++++++++++++++++++++++ src/apis/workspace/workspace.type.ts | 48 +++++++++++++++++++++ 4 files changed, 132 insertions(+) create mode 100644 src/apis/auth/auth.service.ts create mode 100644 src/apis/auth/auth.type.ts create mode 100644 src/apis/workspace/workspace.service.ts create mode 100644 src/apis/workspace/workspace.type.ts diff --git a/src/apis/auth/auth.service.ts b/src/apis/auth/auth.service.ts new file mode 100644 index 0000000..e0450dd --- /dev/null +++ b/src/apis/auth/auth.service.ts @@ -0,0 +1,16 @@ +import { MeResponse } from './auth.type'; + +const BASE_URL = process.env.NEXT_PUBLIC_API_URL; + +export const authService = { + getMe: async (accessToken: string): Promise => { + const res = await fetch(`${BASE_URL}/api/v1/auth/me`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!res.ok) throw new Error('내 정보 조회 실패'); + return res.json(); + }, +}; diff --git a/src/apis/auth/auth.type.ts b/src/apis/auth/auth.type.ts new file mode 100644 index 0000000..5a429c2 --- /dev/null +++ b/src/apis/auth/auth.type.ts @@ -0,0 +1,12 @@ +export interface MeResponse { + success: boolean; + data: { + email: string; + name: string; + picture: string | null; + }; + meta: { + timestamp: string; + traceId: string; + }; +} diff --git a/src/apis/workspace/workspace.service.ts b/src/apis/workspace/workspace.service.ts new file mode 100644 index 0000000..cfb517f --- /dev/null +++ b/src/apis/workspace/workspace.service.ts @@ -0,0 +1,56 @@ +import { + WorkspaceListResponse, + WorkspaceDetailResponse, + InviteWorkspaceRequest, + InviteWorkspaceResponse, +} from './workspace.type'; + +const BASE_URL = process.env.NEXT_PUBLIC_API_URL; + +export const workspaceService = { + getMyWorkspaces: async (accessToken: string): Promise => { + const res = await fetch(`${BASE_URL}/api/v1/workspaces/my`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!res.ok) throw new Error('워크스페이스 목록 조회 실패'); + return res.json(); + }, + + getWorkspace: async ( + workspaceId: number, + accessToken: string, + ): Promise => { + const res = await fetch(`${BASE_URL}/api/v1/workspaces/${workspaceId}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (res.status === 404) throw new Error('워크스페이스를 찾을 수 없습니다.'); + if (!res.ok) throw new Error('워크스페이스 조회 실패'); + return res.json(); + }, + + inviteWorkspace: async ( + workspaceId: number, + body: InviteWorkspaceRequest, + accessToken: string, + ): Promise => { + const res = await fetch(`${BASE_URL}/api/v1/workspaces/${workspaceId}/invite`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify(body), + }); + + if (res.status === 404) throw new Error('워크스페이스를 찾을 수 없습니다.'); + if (res.status === 409) throw new Error('이미 참여 중인 워크스페이스입니다.'); + if (!res.ok) throw new Error('초대에 실패했습니다. 이메일을 확인해 주세요.'); + return res.json(); + }, +}; diff --git a/src/apis/workspace/workspace.type.ts b/src/apis/workspace/workspace.type.ts new file mode 100644 index 0000000..6564291 --- /dev/null +++ b/src/apis/workspace/workspace.type.ts @@ -0,0 +1,48 @@ +export type WorkspaceRole = 'ADMIN' | 'MEMBER'; + +export interface WorkspaceItem { + color: string; + membershipId: number; + role: WorkspaceRole; + workspaceId: number; + workspaceName: string; +} + +export interface WorkspaceListResponse { + success: boolean; + totalCount: number; + nextCursor: number | null; + data: WorkspaceItem[]; + meta: { + timestamp: string; + traceId: string; + }; +} + +export interface WorkspaceDetailResponse { + success: boolean; + data: { + workspaceId: number; + workspaceName: string; + color: string; + role: WorkspaceRole; + membershipId: number; + }; + meta: { + timestamp: string; + traceId: string; + }; +} + +export interface InviteWorkspaceRequest { + email: string; +} + +export interface InviteWorkspaceResponse { + success: boolean; + data: null; + meta: { + timestamp: string; + traceId: string; + }; +} From 5c58bc3730815122c9dd7197e36f981614ef0df5 Mon Sep 17 00:00:00 2001 From: LMS10 Date: Thu, 7 May 2026 00:23:43 +0900 Subject: [PATCH 02/11] =?UTF-8?q?=E2=9C=A8=20Feat:=20auth,=20workspace=20T?= =?UTF-8?q?anStack=20Query=20=ED=9B=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 41 ++++++++++++++++++++++ package.json | 2 ++ src/apis/auth/auth.queries.ts | 18 ++++++++++ src/apis/workspace/workspace.queries.ts | 46 +++++++++++++++++++++++++ 4 files changed, 107 insertions(+) create mode 100644 src/apis/auth/auth.queries.ts create mode 100644 src/apis/workspace/workspace.queries.ts diff --git a/package-lock.json b/package-lock.json index 12ee2de..f7e725e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "remate", "version": "0.1.0", "dependencies": { + "@tanstack/react-query": "^5.100.9", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "focus-trap-react": "^12.0.0", @@ -16,6 +17,7 @@ "next-auth": "^4.24.14", "react": "19.2.3", "react-dom": "19.2.3", + "react-toastify": "^11.1.0", "tailwind-merge": "^3.5.0", "zustand": "^5.0.12" }, @@ -1980,6 +1982,32 @@ "tailwindcss": "4.1.18" } }, + "node_modules/@tanstack/query-core": { + "version": "5.100.9", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.9.tgz", + "integrity": "sha512-SJSFw1S8+kQ0+knv/XGfrbocWoAlT7vDKsSImtLx3ZPQmEcR46hkDjLSvynSy25N8Ms4tIEini1FuBd5k7IscQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.100.9", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.9.tgz", + "integrity": "sha512-Oa44XkaI3kCNN6ME0KByU3xT3SEUNOMfZpHxL6+wFoTm+OeUFYHKdeYVe0aOXlRDm/f15sgLwEt2HDorIdW8+A==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.100.9" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -6979,6 +7007,19 @@ "dev": true, "license": "MIT" }, + "node_modules/react-toastify": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.1.0.tgz", + "integrity": "sha512-e9h23x3phN0wbFeB6yovmWp7lobzV4CaCH0LO8nVP6H7Y+3GbcLpIzMm9dJhcp1RXbpyfvjgpfXqO80QAmn7sg==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", diff --git a/package.json b/package.json index 717255d..e3de6c6 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ ] }, "dependencies": { + "@tanstack/react-query": "^5.100.9", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "focus-trap-react": "^12.0.0", @@ -25,6 +26,7 @@ "next-auth": "^4.24.14", "react": "19.2.3", "react-dom": "19.2.3", + "react-toastify": "^11.1.0", "tailwind-merge": "^3.5.0", "zustand": "^5.0.12" }, diff --git a/src/apis/auth/auth.queries.ts b/src/apis/auth/auth.queries.ts new file mode 100644 index 0000000..a02dbd8 --- /dev/null +++ b/src/apis/auth/auth.queries.ts @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query'; +import { useSession } from 'next-auth/react'; +import { authService } from './auth.service'; + +export const authKeys = { + me: ['auth', 'me'] as const, +}; + +export const useGetMe = () => { + const { data: session } = useSession(); + const accessToken = session?.accessToken as string | undefined; + + return useQuery({ + queryKey: authKeys.me, + queryFn: () => authService.getMe(accessToken!), + enabled: !!accessToken, + }); +}; diff --git a/src/apis/workspace/workspace.queries.ts b/src/apis/workspace/workspace.queries.ts new file mode 100644 index 0000000..ec65d19 --- /dev/null +++ b/src/apis/workspace/workspace.queries.ts @@ -0,0 +1,46 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useSession } from 'next-auth/react'; +import { workspaceService } from './workspace.service'; +import { InviteWorkspaceRequest } from './workspace.type'; + +export const workspaceKeys = { + all: ['workspaces'] as const, + my: ['workspaces', 'my'] as const, + detail: (workspaceId: number) => ['workspaces', workspaceId] as const, +}; + +export const useGetMyWorkspaces = () => { + const { data: session } = useSession(); + const accessToken = session?.accessToken as string | undefined; + + return useQuery({ + queryKey: workspaceKeys.my, + queryFn: () => workspaceService.getMyWorkspaces(accessToken!), + enabled: !!accessToken, + }); +}; + +export const useGetWorkspace = (workspaceId: number) => { + const { data: session } = useSession(); + const accessToken = session?.accessToken as string | undefined; + + return useQuery({ + queryKey: workspaceKeys.detail(workspaceId), + queryFn: () => workspaceService.getWorkspace(workspaceId, accessToken!), + enabled: !!accessToken && !!workspaceId, + }); +}; + +export const useInviteWorkspace = (workspaceId: number) => { + const { data: session } = useSession(); + const accessToken = session?.accessToken as string | undefined; + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (body: InviteWorkspaceRequest) => + workspaceService.inviteWorkspace(workspaceId, body, accessToken!), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: workspaceKeys.detail(workspaceId) }); + }, + }); +}; From e4c324f436e70ed27dc24ee6b691ec0f3f827214 Mon Sep 17 00:00:00 2001 From: LMS10 Date: Thu, 7 May 2026 00:25:20 +0900 Subject: [PATCH 03/11] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EC=A0=84=EC=97=AD=20?= =?UTF-8?q?ToastContainer=20=EC=84=A4=EC=A0=95=20=EB=B0=8F=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=EC=BB=A4=EC=8A=A4=ED=84=B0=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EC=A7=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/layout.tsx | 44 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2b03a3c..b04ee94 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,8 @@ +import { Slide, ToastContainer } from 'react-toastify'; import localFont from 'next/font/local'; import './globals.css'; import AuthProvider from '@/components/AuthProvider'; +import QueryProvider from '@/components/header/QueryProvider'; import ModalRoot from '@/components/modal/ModalRoot'; const pretendard = localFont({ @@ -9,16 +11,44 @@ const pretendard = localFont({ variable: '--font-pretendard', }); -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { +export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - {children} - + + + {children} + + + +