diff --git a/package.json b/package.json index 40f51af..64283ea 100644 --- a/package.json +++ b/package.json @@ -39,5 +39,10 @@ "msw": "^2.14.6", "typescript": "~6.0.2", "vite": "^8.0.12" + }, + "msw": { + "workerDirectory": [ + "public" + ] } } diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js new file mode 100644 index 0000000..3df3a1a --- /dev/null +++ b/public/mockServiceWorker.js @@ -0,0 +1,349 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.14.6'; +const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'; +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse'); +const activeClientIds = new Set(); + +addEventListener('install', () => { + self.skipWaiting(); +}); + +addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); +}); + +addEventListener('message', async (event) => { + const clientId = Reflect.get(event.source || {}, 'id'); + + if (!clientId || !self.clients) { + return; + } + + const client = await self.clients.get(clientId); + + if (!client) { + return; + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }); + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }); + break; + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }); + break; + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId); + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }); + break; + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId); + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId; + }); + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister(); + } + + break; + } + } +}); + +addEventListener('fetch', (event) => { + const requestInterceptedAt = Date.now(); + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return; + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { + return; + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return; + } + + const requestId = crypto.randomUUID(); + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)); +}); + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event); + const requestCloneForEvents = event.request.clone(); + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt + ); + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents); + + // Clone the response so both the client and the library could consume it. + const responseClone = response.clone(); + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + headers: Object.fromEntries(responseClone.headers.entries()), + body: responseClone.body, + }, + }, + }, + responseClone.body ? [serializedRequest.body, responseClone.body] : [] + ); + } + + return response; +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId); + + if (activeClientIds.has(event.clientId)) { + return client; + } + + if (client?.frameType === 'top-level') { + return client; + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }); + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible'; + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id); + }); +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone(); + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers); + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept'); + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()); + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough' + ); + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')); + } else { + headers.delete('accept'); + } + } + + return fetch(requestClone, { headers }); + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough(); + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough(); + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request); + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body] + ); + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data); + } + + case 'PASSTHROUGH': { + return passthrough(); + } + } + + return passthrough(); +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel(); + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error); + } + + resolve(event.data); + }; + + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]); + }); +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error(); + } + + const mockedResponse = new Response(response.body, response); + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }); + + return mockedResponse; +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + }; +} diff --git a/src/App.tsx b/src/App.tsx index 51617ab..ea4c7bd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,27 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { implAuthUsecase } from '@/feature/auth/usecase/auth-usecase'; +import { UsecaseContext } from '@/feature/shared/context/usecase-context'; +import { externalCall, implApi } from '@/infrastructure/api'; import { RouterProvider } from './routes/router-provider'; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: false, + }, + }, +}); + export const App = () => { - return ; + const api = implApi({ externalCall }); + const authUsecase = implAuthUsecase({ api }); + + return ( + + + + + + ); }; diff --git a/src/feature/auth/application/auth-query.ts b/src/feature/auth/application/auth-query.ts new file mode 100644 index 0000000..c5f1f1c --- /dev/null +++ b/src/feature/auth/application/auth-query.ts @@ -0,0 +1,81 @@ +import { useMutation } from '@tanstack/react-query'; +import { useGuardContext } from '@/feature/shared/context/use-gaurd-context'; +import { UsecaseContext } from '@/feature/shared/context/usecase-context'; +import { useRouteNavigation } from '@/routes/use-route-navigation'; +import type { UserRole } from '../domain/user-role'; + +export const useSignIn = ({ + setResponseMessage, +}: { + setResponseMessage: (message: string) => void; +}) => { + const { authUsecase } = useGuardContext(UsecaseContext); + const { toMain } = useRouteNavigation(); + const { mutate: signIn, isPending } = useMutation({ + mutationFn: async ({ + role, + mail, + password, + }: { + role: UserRole; + mail: string; + password: string; + }) => { + return await authUsecase.signIn({ role, mail, password }); + }, + onSuccess: (response) => { + if (response.type === 'success') { + toMain(); + } else { + if (response.code === 'AUTH_002' || response.code === 'GEN_004') { + setResponseMessage('아이디 또는 비밀번호가 일치하지 않습니다.'); + return; + } + } + }, + }); + + return { signIn, isPending }; +}; + +export const useSignUp = ({ + setResponseMessage, +}: { + setResponseMessage: (message: string) => void; +}) => { + const { authUsecase } = useGuardContext(UsecaseContext); + const { toMain } = useRouteNavigation(); + const { mutate: signUp, isPending } = useMutation({ + mutationFn: async ({ + role, + username, + email, + password, + }: { + role: 'OWNER' | 'REVIEWER'; + username: string; + email: string; + password: string; + }) => { + return await authUsecase.signUp({ + role, + username, + email, + password, + }); + }, + onSuccess: (response) => { + if (response.type === 'success') { + toMain(); + } else { + if (response.code === 'USER_003') { + setResponseMessage('이미 존재하는 이메일입니다.'); + return; + } + setResponseMessage('회원가입에 실패했습니다. 다시 시도해주세요.'); + } + }, + }); + + return { signUp, isPending }; +}; diff --git a/src/feature/auth/domain/schema.ts b/src/feature/auth/domain/schema.ts new file mode 100644 index 0000000..5124d52 --- /dev/null +++ b/src/feature/auth/domain/schema.ts @@ -0,0 +1,9 @@ +type UserBriefDTO = { + id: string; + userRole: 'OWNER' | 'REVIEWER'; +}; + +export type UserWithAccessTokenResponse = { + user: UserBriefDTO; + token: string; +}; diff --git a/src/feature/auth/domain/user-role.ts b/src/feature/auth/domain/user-role.ts new file mode 100644 index 0000000..288d0b4 --- /dev/null +++ b/src/feature/auth/domain/user-role.ts @@ -0,0 +1 @@ +export type UserRole = 'OWNER' | 'REVIEWER'; diff --git a/src/feature/auth/presenter/authFormPresentation.ts b/src/feature/auth/presenter/auth-form-presenter.ts similarity index 86% rename from src/feature/auth/presenter/authFormPresentation.ts rename to src/feature/auth/presenter/auth-form-presenter.ts index 77c18bd..a61eeeb 100644 --- a/src/feature/auth/presenter/authFormPresentation.ts +++ b/src/feature/auth/presenter/auth-form-presenter.ts @@ -3,7 +3,7 @@ import type { InputForForm, InputWithDetailedError, } from '@/entities/input'; -import type { AuthInputPresentation } from '@/feature/auth/presenter/authInputPresentation'; +import type { AuthInputPresenter } from '@/feature/auth/presenter/auth-input-presenter'; type InitialFormState = { email?: string; @@ -13,13 +13,13 @@ type InitialFormState = { code?: string; }; -type AuthFormPresentation = { +type AuthFormPresenter = { useValidator({ initialState, - authInputPresentation, + authInputPresenter, }: { initialState?: InitialFormState; - authInputPresentation: AuthInputPresentation; + authInputPresenter: AuthInputPresenter; }): { inputStates: { mail: Input; @@ -60,8 +60,8 @@ type AuthFormPresentation = { }; }; -export const authFormPresentation: AuthFormPresentation = { - useValidator: ({ initialState, authInputPresentation }) => { +export const authFormPresenter: AuthFormPresenter = { + useValidator: ({ initialState, authInputPresenter }) => { const initialStateForInput = { mail: initialState?.mail, username: initialState?.username, @@ -78,7 +78,7 @@ export const authFormPresentation: AuthFormPresentation = { newPasswordConfirm, code, emailVerifySuccessCode, - } = authInputPresentation.useValidator({ + } = authInputPresenter.useValidator({ initialState: initialStateForInput, }); diff --git a/src/feature/auth/presenter/authInputPresentation.ts b/src/feature/auth/presenter/auth-input-presenter.ts similarity index 97% rename from src/feature/auth/presenter/authInputPresentation.ts rename to src/feature/auth/presenter/auth-input-presenter.ts index 4a49243..49cd95f 100644 --- a/src/feature/auth/presenter/authInputPresentation.ts +++ b/src/feature/auth/presenter/auth-input-presenter.ts @@ -9,7 +9,7 @@ type InitialInputState = { code?: string; }; -export type AuthInputPresentation = { +export type AuthInputPresenter = { useValidator({ initialState }: { initialState?: InitialInputState }): { mail: Input; username: Input; @@ -53,7 +53,7 @@ const PASSWORD_DETAIL_REGEX = { const CODE_REGEX = /^\d{6}$/; const USERNAME_REGEX = /^([가-힣]{2,6}|[A-Za-z]{2,20})$/; -export const authInputPresentation: AuthInputPresentation = { +export const authInputPresenter: AuthInputPresenter = { useValidator: ({ initialState = {} }) => { const [mail, setMail] = useState( initialState.mail !== undefined ? initialState.mail : '' diff --git a/src/feature/auth/usecase/auth-usecase.ts b/src/feature/auth/usecase/auth-usecase.ts new file mode 100644 index 0000000..13b00e6 --- /dev/null +++ b/src/feature/auth/usecase/auth-usecase.ts @@ -0,0 +1,55 @@ +import type { UserRole } from '@/feature/auth/domain/user-role'; +import type { UsecaseResponse } from '@/feature/shared/response'; +import type { Apis } from '@/infrastructure/api'; +import type { UserWithAccessTokenResponse } from '@/mocks/auth/schemas'; + +export type AuthUsecase = { + signUp: ({ + role, + username, + email, + password, + }: { + role: UserRole; + username: string; + email: string; + password: string; + }) => UsecaseResponse; + signIn: ({ + role, + mail, + password, + }: { + role: UserRole; + mail: string; + password: string; + }) => UsecaseResponse; +}; + +export const implAuthUsecase = ({ api }: { api: Apis }): AuthUsecase => ({ + signUp: async ({ role, username, email, password }) => { + const { status, data } = await api['POST /api/auth/user']({ + body: { role, username, email, password }, + }); + + if (status === 200) { + return { type: 'success', data: data }; + } + + return { type: 'error', code: data.code, message: data.message }; + }, + + signIn: async ({ role, mail, password }) => { + const { status, data } = await api['POST /api/auth/user/session']({ + body: { role, mail, password }, + }); + + if (status === 200) { + return { + type: 'success', + data, + }; + } + return { type: 'error', code: data.code, message: data.message }; + }, +}); diff --git a/src/feature/shared/context/use-gaurd-context.ts b/src/feature/shared/context/use-gaurd-context.ts new file mode 100644 index 0000000..25a5585 --- /dev/null +++ b/src/feature/shared/context/use-gaurd-context.ts @@ -0,0 +1,14 @@ +import type { Context } from 'react'; +import { useContext } from 'react'; + +export const useGuardContext = >( + context: Context +): T => { + const contextValue = useContext(context); + if (contextValue === null) { + throw new Error( + `컨텍스트 값이 존재하지 않습니다: ${context.displayName ?? ''}` + ); + } + return contextValue; +}; diff --git a/src/feature/shared/context/usecase-context.ts b/src/feature/shared/context/usecase-context.ts new file mode 100644 index 0000000..93a717b --- /dev/null +++ b/src/feature/shared/context/usecase-context.ts @@ -0,0 +1,8 @@ +import { createContext } from 'react'; +import type { AuthUsecase } from '@/feature/auth/usecase/auth-usecase'; + +export type UsecaseContext = { + authUsecase: AuthUsecase; +}; + +export const UsecaseContext = createContext(null); diff --git a/src/feature/shared/response.ts b/src/feature/shared/response.ts new file mode 100644 index 0000000..497032b --- /dev/null +++ b/src/feature/shared/response.ts @@ -0,0 +1,10 @@ +export type UsecaseResponse = T extends undefined + ? Promise<{ type: 'success' }> + : Promise< + | { type: 'success'; data: T } + | { + type: 'error'; + code: string; + message: string; + } + >; diff --git a/src/infrastructure/api/apis/local-server/apis.ts b/src/infrastructure/api/apis/local-server/apis.ts index 68dd90a..f6c4e34 100644 --- a/src/infrastructure/api/apis/local-server/apis.ts +++ b/src/infrastructure/api/apis/local-server/apis.ts @@ -4,7 +4,11 @@ import type { ResponseNecessary, SuccessResponse, } from '../../domain'; -import type { TestRequest, TestResponse } from './schemas'; +import type { + SignInRequest, + SignUpRequest, + UserWithAccessTokenResponse, +} from './schemas'; type GetApisProps = { callWithToken: ( @@ -27,10 +31,16 @@ type Api = (_: { export const getLocalServerApis = ({ callWithoutToken }: GetApisProps) => ({ - 'GET /test': ({ body }: { body: TestRequest }) => - callWithoutToken>({ - method: 'GET', - path: 'test', + 'POST /api/auth/user': ({ body }: { body: SignUpRequest }) => + callWithoutToken>({ + method: 'POST', + path: 'api/auth/user', + body, + }), + 'POST /api/auth/user/session': ({ body }: { body: SignInRequest }) => + callWithoutToken>({ + method: 'POST', + path: 'api/auth/user/session', body, }), }) satisfies Record; diff --git a/src/infrastructure/api/apis/local-server/schemas.ts b/src/infrastructure/api/apis/local-server/schemas.ts index f17b97b..457386f 100644 --- a/src/infrastructure/api/apis/local-server/schemas.ts +++ b/src/infrastructure/api/apis/local-server/schemas.ts @@ -1,7 +1,22 @@ -export type TestRequest = { - test: number; +type UserBriefDTO = { + id: string; + userRole: 'OWNER' | 'REVIEWER'; }; -export type TestResponse = { - message: string; +export type SignUpRequest = { + role: 'OWNER' | 'REVIEWER'; + username: string; + email: string; + password: string; +}; + +export type SignInRequest = { + role: 'OWNER' | 'REVIEWER'; + mail: string; + password: string; +}; + +export type UserWithAccessTokenResponse = { + user: UserBriefDTO; + token: string; }; diff --git a/src/infrastructure/api/external-call.ts b/src/infrastructure/api/external-call.ts new file mode 100644 index 0000000..ee8fa51 --- /dev/null +++ b/src/infrastructure/api/external-call.ts @@ -0,0 +1,14 @@ +import type { ExternalCallParams, ResponseNecessary } from './domain'; + +export const externalCall = async ( + params: ExternalCallParams +): Promise => { + const response = await fetch(`/${params.path}`, { + method: params.method, + headers: params.headers as HeadersInit, + body: params.body !== undefined ? JSON.stringify(params.body) : undefined, + credentials: params.credentials as RequestCredentials, + }); + const data = await response.json().catch(() => null); + return { status: response.status, data }; +}; diff --git a/src/infrastructure/api/index.ts b/src/infrastructure/api/index.ts index 933b46b..2b6f9f1 100644 --- a/src/infrastructure/api/index.ts +++ b/src/infrastructure/api/index.ts @@ -1,2 +1,3 @@ export { type Apis, implApi } from './client'; export type { ExternalCallParams } from './domain'; +export { externalCall } from './external-call'; diff --git a/src/main.tsx b/src/main.tsx index c45f8cf..98227e9 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -8,10 +8,19 @@ if (rootElement === null) { throw new Error('Root element not found'); } -createRoot(rootElement).render( - - - - - -); +async function enableMocking() { + if (import.meta.env.DEV) { + const { worker } = await import('./mocks'); + return worker.start({ onUnhandledRequest: 'bypass' }); + } +} + +enableMocking().then(() => { + createRoot(rootElement!).render( + + + + + + ); +}); diff --git a/src/mocks/README.md b/src/mocks/README.md index 1e9e630..6590795 100644 --- a/src/mocks/README.md +++ b/src/mocks/README.md @@ -16,7 +16,7 @@ | 케이스 | 조건 | |---|---| -| `201` 성공 | 정상 body | +| `200` 성공 | 정상 body | | `400` 필드 누락 | `role`, `username`, `email`, `password` 중 하나 누락 | | `409` 중복 이메일 | `email: "duplicate@example.com"` | | `400` 인증코드 만료 (리뷰어) | `role: "REVIEWER"` + `email`에 `+fail` 포함 (예: `test+fail@snu.ac.kr`) | @@ -41,7 +41,7 @@ | 엔드포인트 | 케이스 | 조건 | |---|---|---| -| `POST /api/store` | `201` | owner 토큰 | +| `POST /api/store` | `200` | owner 토큰 | | | `401` | 토큰 없음 | | | `403` | reviewer 토큰 | | `GET /api/store` | `200` | 항상 (`?category=CAFE` 등 필터 가능) | @@ -58,7 +58,7 @@ | 엔드포인트 | 케이스 | 조건 | |---|---|---| -| `POST /api/event` | `201` | owner 토큰, 정상 body | +| `POST /api/event` | `200` | owner 토큰, 정상 body | | | `400` 예치금 부족 | `reward >= 999999999` | | `GET /api/event/owner` | `200` | owner 토큰 | | `PATCH /api/event/:eventId` | `200` | `eventId: "event-001" ~ "event-003"`, owner 토큰 | @@ -97,7 +97,7 @@ |---|---|---| | `GET /api/deposit` | `200` | owner 토큰 | | | `404` 예치금 없음 | `Authorization: Bearer mock-no-deposit-owner-token` | -| `POST /api/deposit` | `201` | owner 토큰, `amount > 0` | +| `POST /api/deposit` | `200` | owner 토큰, `amount > 0` | | | `400` | `amount <= 0` 또는 누락 | --- diff --git a/src/mocks/auth/resolvers.ts b/src/mocks/auth/resolvers.ts index 4554acd..a1bf214 100644 --- a/src/mocks/auth/resolvers.ts +++ b/src/mocks/auth/resolvers.ts @@ -43,13 +43,13 @@ export const authResolver: AuthResolver = { } const response = body.role === 'OWNER' ? mockOwnerUser : mockReviewerUser; - return HttpResponse.json(response, { status: 201 }); + return HttpResponse.json(response, { status: 200 }); }, signIn: async ({ request }) => { const body = (await request.json()) as SignInRequest; - if (!body.mail || !body.password) { + if (!body.role || !body.mail || !body.password) { return HttpResponse.json( { message: 'Required field missing' }, { status: 400 } @@ -67,10 +67,7 @@ export const authResolver: AuthResolver = { ); } - // mail에 'owner' 포함 시 사장님 계정 반환, 그 외 리뷰어 - const response = body.mail.includes('owner') - ? mockOwnerUser - : mockReviewerUser; + const response = body.role === 'OWNER' ? mockOwnerUser : mockReviewerUser; return HttpResponse.json(response, { status: 200 }); }, }; diff --git a/src/mocks/auth/schemas.ts b/src/mocks/auth/schemas.ts index 1850eea..457386f 100644 --- a/src/mocks/auth/schemas.ts +++ b/src/mocks/auth/schemas.ts @@ -11,6 +11,7 @@ export type SignUpRequest = { }; export type SignInRequest = { + role: 'OWNER' | 'REVIEWER'; mail: string; password: string; }; diff --git a/src/mocks/deposit/resolvers.ts b/src/mocks/deposit/resolvers.ts index b24d331..34ca9aa 100644 --- a/src/mocks/deposit/resolvers.ts +++ b/src/mocks/deposit/resolvers.ts @@ -49,7 +49,7 @@ export const depositResolver: DepositResolver = { balance: mockDeposit.balance + body.amount, depositedAt: new Date().toISOString(), }, - { status: 201 } + { status: 200 } ); }, }; diff --git a/src/mocks/event/resolvers.ts b/src/mocks/event/resolvers.ts index 075ed8d..ae068e6 100644 --- a/src/mocks/event/resolvers.ts +++ b/src/mocks/event/resolvers.ts @@ -49,7 +49,7 @@ export const eventResolver: EventResolver = { reward: body.reward, isActive: true, }, - { status: 201 } + { status: 200 } ); }, diff --git a/src/mocks/store/resolvers.ts b/src/mocks/store/resolvers.ts index 7bccca7..9f9b367 100644 --- a/src/mocks/store/resolvers.ts +++ b/src/mocks/store/resolvers.ts @@ -52,7 +52,7 @@ export const storeResolver: StoreResolver = { thumbnailKey: body.thumbnailUrl, description: body.description, }, - { status: 201 } + { status: 200 } ); }, diff --git a/src/routes/path.ts b/src/routes/path.ts index 2c9640a..bcd4900 100644 --- a/src/routes/path.ts +++ b/src/routes/path.ts @@ -2,4 +2,5 @@ export const PATH = { LANDING: '/', SIGN_IN: '/sign-in', SIGN_UP: '/sign-up', + SIGN_UP_COMPLETE: '/sign-up-complete', }; diff --git a/src/routes/use-route-navigation.ts b/src/routes/use-route-navigation.ts index 03adb5c..4e57aad 100644 --- a/src/routes/use-route-navigation.ts +++ b/src/routes/use-route-navigation.ts @@ -3,7 +3,7 @@ import { PATH } from './path'; export const useRouteNavigation = () => { const navigate = useNavigate(); - const { LANDING, SIGN_IN, SIGN_UP } = PATH; + const { LANDING, SIGN_IN, SIGN_UP, SIGN_UP_COMPLETE } = PATH; return { toMain: () => { @@ -15,6 +15,9 @@ export const useRouteNavigation = () => { toSignUp: () => { void navigate(SIGN_UP); }, + toSignUpComplete: () => { + void navigate(SIGN_UP_COMPLETE); + }, toBack: () => { void navigate(-1); }, diff --git a/src/widgets/auth/ui/sign-in-form.tsx b/src/widgets/auth/ui/sign-in-form.tsx index 7f53074..00f5326 100644 --- a/src/widgets/auth/ui/sign-in-form.tsx +++ b/src/widgets/auth/ui/sign-in-form.tsx @@ -1,8 +1,8 @@ import { Eye, EyeOff } from 'lucide-react'; import { useState } from 'react'; - -import { authFormPresentation } from '@/feature/auth/presenter/authFormPresentation'; -import { authInputPresentation } from '@/feature/auth/presenter/authInputPresentation'; +import { useSignIn } from '@/feature/auth/application/auth-query'; +import { authFormPresenter } from '@/feature/auth/presenter/auth-form-presenter'; +import { authInputPresenter } from '@/feature/auth/presenter/auth-input-presenter'; import { useRouteNavigation } from '@/routes/use-route-navigation'; import { Button } from '@/widgets/common/ui/button'; import { @@ -18,29 +18,27 @@ import { Tabs, TabsList, TabsTrigger } from '@/widgets/common/ui/tabs'; type Role = 'OWNER' | 'REVIEWER'; export function SignInForm() { - const { toSignUp } = useRouteNavigation(); const [role, setRole] = useState('REVIEWER'); const [showPassword, setShowPassword] = useState(false); const [submitted, setSubmitted] = useState(false); - const [serverError, setServerError] = useState(''); - const [isLoading, setIsLoading] = useState(false); + const [responseMessage, setResponseMessage] = useState(''); - const { inputStates, formStates } = authFormPresentation.useValidator({ - authInputPresentation, - }); + const { toSignUp } = useRouteNavigation(); + const { inputStates, formStates } = authFormPresenter.useValidator({ + authInputPresenter, + }); const { mail, password } = inputStates; - const handleSubmit = (e: { preventDefault: () => void }) => { + const { signIn, isPending } = useSignIn({ setResponseMessage }); + + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); setSubmitted(true); - setServerError(''); if (formStates.mail.isError || password.isError) { return; } - setIsLoading(true); - // TODO: call signIn usecase - setIsLoading(false); + signIn({ role, mail: mail.value, password: password.value }); }; return ( @@ -119,14 +117,14 @@ export function SignInForm() { )} - {serverError && ( + {responseMessage && (

- {serverError} + {responseMessage}

)} - diff --git a/src/widgets/auth/ui/sign-up-form.tsx b/src/widgets/auth/ui/sign-up-form.tsx index f505803..5850b29 100644 --- a/src/widgets/auth/ui/sign-up-form.tsx +++ b/src/widgets/auth/ui/sign-up-form.tsx @@ -1,8 +1,9 @@ import { Check, Eye, EyeOff, X } from 'lucide-react'; import { useState } from 'react'; - -import { authFormPresentation } from '@/feature/auth/presenter/authFormPresentation'; -import { authInputPresentation } from '@/feature/auth/presenter/authInputPresentation'; +import { useSignUp } from '@/feature/auth/application/auth-query'; +import type { UserRole } from '@/feature/auth/domain/user-role'; +import { authFormPresenter } from '@/feature/auth/presenter/auth-form-presenter'; +import { authInputPresenter } from '@/feature/auth/presenter/auth-input-presenter'; import { useRouteNavigation } from '@/routes/use-route-navigation'; import { Button } from '@/widgets/common/ui/button'; import { @@ -15,22 +16,18 @@ import { Input } from '@/widgets/common/ui/input'; import { Label } from '@/widgets/common/ui/label'; import { Tabs, TabsList, TabsTrigger } from '@/widgets/common/ui/tabs'; -type Role = 'OWNER' | 'REVIEWER'; - export function SignUpForm() { const { toSignIn } = useRouteNavigation(); - const [role, setRole] = useState('REVIEWER'); + const [role, setRole] = useState('REVIEWER'); const [showPassword, setShowPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [codeSent, setCodeSent] = useState(false); const [submitted, setSubmitted] = useState(false); - const [serverError, setServerError] = useState(''); - const [isLoading, setIsLoading] = useState(false); + const [responseMessage, setResponseMessage] = useState(''); - const { inputStates, formStates } = authFormPresentation.useValidator({ - authInputPresentation, + const { inputStates, formStates } = authFormPresenter.useValidator({ + authInputPresenter, }); - const { mail, username, @@ -39,9 +36,10 @@ export function SignUpForm() { code, emailVerifySuccessCode, } = inputStates; - const isEmailVerified = !formStates.emailVerifySuccessCode.isError; + const { signUp, isPending } = useSignUp({ setResponseMessage }); + const handleSendCode = () => { if (formStates.mail.isError) { setSubmitted(true); @@ -58,24 +56,23 @@ export function SignUpForm() { emailVerifySuccessCode.onChange(code.value); }; - const handleSubmit = (e: { preventDefault: () => void }) => { + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); setSubmitted(true); - setServerError(''); - - const isValid = - !username.isError && - !formStates.mail.isError && - isEmailVerified && - !password.isError && - !passwordConfirm.isError; - - if (!isValid) { + if ( + username.isError || + mail.isError || + password.isError || + passwordConfirm.isError + ) { return; } - setIsLoading(true); - // TODO: call signUp usecase - setIsLoading(false); + signUp({ + role, + username: username.value, + email: mail.value, + password: password.value, + }); }; const { detailedError } = password; @@ -90,7 +87,7 @@ export function SignUpForm() {
- setRole(v as Role)}> + setRole(v as UserRole)}> 손님 @@ -301,14 +298,14 @@ export function SignUpForm() { )}
- {serverError && ( + {responseMessage && (

- {serverError} + {responseMessage}

)} -