From 09f9561bf4e091b2e2eb7bfc06b226deedb2ab73 Mon Sep 17 00:00:00 2001 From: Yeonu-Kim Date: Sun, 7 Jun 2026 20:13:28 +0900 Subject: [PATCH 1/7] =?UTF-8?q?=F0=9F=9A=9A=20=EA=B3=B5=EB=8F=99=20api=20?= =?UTF-8?q?=EC=9C=A0=ED=8B=B8=20=EC=9C=84=EC=B9=98=EB=A1=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/apis/encode-query-params.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/infrastructure/api/apis/encode-query-params.ts diff --git a/src/infrastructure/api/apis/encode-query-params.ts b/src/infrastructure/api/apis/encode-query-params.ts new file mode 100644 index 0000000..fa34f9f --- /dev/null +++ b/src/infrastructure/api/apis/encode-query-params.ts @@ -0,0 +1,33 @@ +export const encodeQueryParams = ({ + params, +}: { + params: Record< + string, + | string + | number + | boolean + | string[] + | number[] + | boolean[] + | null + | undefined + >; +}) => { + const queryParameters = new URLSearchParams(); + + Object.entries(params).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; // null, undefined 제외 + } + + if (Array.isArray(value)) { + value.forEach((v) => { + queryParameters.append(key, v.toString()); + }); + } else { + queryParameters.append(key, value.toString()); + } + }); + + return queryParameters.toString(); +}; From d110493edb22078a5c01438fa2455a550bec02ea Mon Sep 17 00:00:00 2001 From: Yeonu-Kim Date: Sun, 7 Jun 2026 20:14:46 +0900 Subject: [PATCH 2/7] =?UTF-8?q?=E2=9C=A8=20S3(storage)=EC=99=80=EC=9D=98?= =?UTF-8?q?=20=EC=86=8C=ED=86=B5=EC=9D=84=20=EC=9C=84=ED=95=9C=20storageAp?= =?UTF-8?q?i=20=EB=AA=A8=EB=93=88=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/apis/local-server/apis.ts | 43 ++++++++++++++- .../api/apis/storage-server/apis.ts | 53 +++++++++++++++++++ .../api/apis/storage-server/index.ts | 1 + .../api/apis/storage-server/schemas.ts | 6 +++ src/infrastructure/api/client.ts | 47 ++++++++++++++++ src/infrastructure/api/domain.ts | 14 +++++ src/infrastructure/api/external-call.ts | 43 ++++++++++++++- 7 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 src/infrastructure/api/apis/storage-server/apis.ts create mode 100644 src/infrastructure/api/apis/storage-server/index.ts create mode 100644 src/infrastructure/api/apis/storage-server/schemas.ts diff --git a/src/infrastructure/api/apis/local-server/apis.ts b/src/infrastructure/api/apis/local-server/apis.ts index 4c88f23..c04609a 100644 --- a/src/infrastructure/api/apis/local-server/apis.ts +++ b/src/infrastructure/api/apis/local-server/apis.ts @@ -4,12 +4,17 @@ import type { ResponseNecessary, SuccessResponse, } from '../../domain'; -import { encodeQueryParams } from './encode-query-params'; +import { encodeQueryParams } from '../encode-query-params'; import type { + ApplicationCreateRequest, EmailRequest, EmailValidateRequest, + EventDetailResponse, + S3UploadRequest, + S3UploadResponse, SignInRequest, SignUpRequest, + StoreDetailResponse, StoreEventListResponse, StoreListWithEventsResponse, StoreType, @@ -110,4 +115,40 @@ export const getLocalServerApis = ({ method: 'GET', path: `api/store/${query.storeId}/events`, }), + 'GET /api/store/:storeId': ({ query }: { query: StoreIdQuery }) => + callWithoutToken>({ + method: 'GET', + path: `api/store/${query.storeId}`, + }), + 'GET /api/event/:eventId': ({ query }: { query: { eventId: string } }) => + callWithoutToken>({ + method: 'GET', + path: `api/event/${query.eventId}`, + }), + 'POST /api/s3': ({ + token, + body, + }: { + token: string; + body: S3UploadRequest; + }) => + callWithToken>({ + method: 'POST', + path: 'api/s3', + body, + token, + }), + 'POST /api/applications': ({ + token, + body, + }: { + token: string; + body: ApplicationCreateRequest; + }) => + callWithToken>({ + method: 'POST', + path: 'api/applications', + body, + token, + }), }) satisfies Record; diff --git a/src/infrastructure/api/apis/storage-server/apis.ts b/src/infrastructure/api/apis/storage-server/apis.ts new file mode 100644 index 0000000..67334bf --- /dev/null +++ b/src/infrastructure/api/apis/storage-server/apis.ts @@ -0,0 +1,53 @@ +import type { + ErrorResponse, + InternalFileCallParams, + ResponseNecessary, + SuccessResponse, +} from '../../domain'; +import type { BlobResponse, UploadFileRequest } from './schemas'; + +type GetApisProps = { + callWithFile: ( + p: InternalFileCallParams, + returnFile?: boolean + ) => Promise; +}; + +type Api = (_: { + path: string; + body: never; + token: string; + contentType: never; + params: never; + query: never; + formData?: FormData; +}) => Promise<{ status: number; data: unknown }>; + +export const getStorageServerApis = ({ callWithFile }: GetApisProps) => + ({ + 'PUT upload-file': ({ + path, + body, + contentType, + }: { + path: string; + body: UploadFileRequest; + contentType: string; + }) => + callWithFile>({ + method: 'PUT', + contentType, + path, + body, + }), + 'GET download-file': ({ path }: { path: string }) => { + const returnFile = true; + return callWithFile>( + { + method: 'GET', + path, + }, + returnFile + ); + }, + }) satisfies Record; diff --git a/src/infrastructure/api/apis/storage-server/index.ts b/src/infrastructure/api/apis/storage-server/index.ts new file mode 100644 index 0000000..a8ebed7 --- /dev/null +++ b/src/infrastructure/api/apis/storage-server/index.ts @@ -0,0 +1 @@ +export { getStorageServerApis } from './apis'; diff --git a/src/infrastructure/api/apis/storage-server/schemas.ts b/src/infrastructure/api/apis/storage-server/schemas.ts new file mode 100644 index 0000000..76f6362 --- /dev/null +++ b/src/infrastructure/api/apis/storage-server/schemas.ts @@ -0,0 +1,6 @@ +export type UploadFileRequest = File; + +export type BlobResponse = { + blob: Blob; + type: string; +}; diff --git a/src/infrastructure/api/client.ts b/src/infrastructure/api/client.ts index 3d5f739..e36f393 100644 --- a/src/infrastructure/api/client.ts +++ b/src/infrastructure/api/client.ts @@ -1,8 +1,11 @@ import { getLocalServerApis } from './apis/local-server'; +import { getStorageServerApis } from './apis/storage-server'; import type { ErrorResponse, ExternalCallParams, + ExternalFileCallParams, InternalCallParams, + InternalFileCallParams, ResponseNecessary, } from './domain'; @@ -10,6 +13,13 @@ type ImplApiProps = { externalCall(_: ExternalCallParams): Promise; }; +type ImplStorageApiProps = { + externalStorageCall( + _: ExternalFileCallParams, + returnFile: boolean + ): Promise; +}; + export const implApi = ({ externalCall }: ImplApiProps) => { const internalCall = async (content: { method: string; @@ -52,4 +62,41 @@ export const implApi = ({ externalCall }: ImplApiProps) => { }); }; +export const implStorageApi = ({ + externalStorageCall, +}: ImplStorageApiProps) => { + const internalFileCall = async ( + content: { + method: string; + path: string; + contentType?: string; + body?: Record | File; + }, + returnFile: boolean = false + ) => { + const response = await externalStorageCall( + { + method: content.method, + path: content.path, + body: content.body, + headers: { + ...(content.contentType !== undefined + ? { 'Content-Type': content.contentType } + : {}), + }, + }, + returnFile + ); + + return response as R; + }; + const callWithFile = ( + p: InternalFileCallParams, + returnFile: boolean = false + ) => internalFileCall(p, returnFile); + + return getStorageServerApis({ callWithFile }); +}; + export type Apis = ReturnType; +export type StorageApis = ReturnType; diff --git a/src/infrastructure/api/domain.ts b/src/infrastructure/api/domain.ts index bc152e5..1c9ca6d 100644 --- a/src/infrastructure/api/domain.ts +++ b/src/infrastructure/api/domain.ts @@ -28,9 +28,23 @@ export type ExternalCallParams = { credentials?: string; }; +export type ExternalFileCallParams = { + method: string; + path: string; + body?: Record | File; + headers?: Record; +}; + export type InternalCallParams = { method: string; path: string; body?: Record; token?: string; }; + +export type InternalFileCallParams = { + method: string; + path: string; + contentType?: string; + body?: Record | File; +}; diff --git a/src/infrastructure/api/external-call.ts b/src/infrastructure/api/external-call.ts index ee8fa51..6be691a 100644 --- a/src/infrastructure/api/external-call.ts +++ b/src/infrastructure/api/external-call.ts @@ -1,4 +1,8 @@ -import type { ExternalCallParams, ResponseNecessary } from './domain'; +import type { + ExternalCallParams, + ExternalFileCallParams, + ResponseNecessary, +} from './domain'; export const externalCall = async ( params: ExternalCallParams @@ -12,3 +16,40 @@ export const externalCall = async ( const data = await response.json().catch(() => null); return { status: response.status, data }; }; + +export const externalStorageCall = async ( + content: ExternalFileCallParams, + returnFile: boolean = false +) => { + const getBody = (body: Record | File | undefined) => { + if (body === undefined) { + return undefined; + } + if (body instanceof File) { + return body; + } + return JSON.stringify(body); + }; + + const response = await fetch(content.path, { + method: content.method, + headers: content.headers, + ...(getBody(content.body) !== undefined + ? { body: getBody(content.body) } + : {}), + }); + + if (returnFile) { + const responseBlob = (await response.blob().catch(() => null)) as Blob; + const contentType = response.headers.get('Content-Type'); + return { + status: response.status, + data: { blob: responseBlob, type: contentType }, + }; + } + const responseBody = (await response.json().catch(() => null)) as unknown; + return { + status: response.status, + data: responseBody, + }; +}; From ab7f8fa6f73654c48acf707df60ff0451423a14b Mon Sep 17 00:00:00 2001 From: Yeonu-Kim Date: Sun, 7 Jun 2026 20:15:18 +0900 Subject: [PATCH 3/7] =?UTF-8?q?=E2=9C=A8=20=EC=A7=80=EA=B0=91=20=EC=A3=BC?= =?UTF-8?q?=EC=86=8C=20=EC=9E=85=EB=A0=A5=20=EB=B0=8F=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20=EC=97=85=EB=A1=9C=EB=93=9C=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 11 +- src/entities/file.ts | 1 + .../application/application-query.ts | 87 +++++ .../presenter/application-form-presenter.ts | 30 ++ .../presenter/application-input-presenter.ts | 36 ++ .../usecase/application-usecase.ts | 77 ++++ src/feature/file/usecase/file-usecase.ts | 53 +++ src/feature/shared/context/query-context.ts | 2 + src/feature/shared/routes/path.ts | 2 +- src/feature/shared/routes/router-provider.tsx | 4 + .../shared/routes/use-route-navigation.ts | 6 +- .../apis/local-server/encode-query-params.ts | 33 -- .../api/apis/local-server/schemas.ts | 32 ++ src/mocks/application/handler.ts | 1 + src/mocks/application/resolvers.ts | 62 ++++ src/mocks/application/schemas.ts | 6 + src/mocks/event/handler.ts | 1 + src/mocks/event/resolvers.ts | 13 + src/mocks/s3/handler.ts | 7 +- src/mocks/s3/resolvers.ts | 5 + src/mocks/store/handler.ts | 4 +- src/mocks/store/resolvers.ts | 17 + src/pages/submit.tsx | 7 + src/widgets/application/ui/submit-form.tsx | 344 ++++++++++++++++++ src/widgets/landing/ui/store-search.tsx | 6 +- 25 files changed, 805 insertions(+), 42 deletions(-) create mode 100644 src/entities/file.ts create mode 100644 src/feature/application/application/application-query.ts create mode 100644 src/feature/application/presenter/application-form-presenter.ts create mode 100644 src/feature/application/presenter/application-input-presenter.ts create mode 100644 src/feature/application/usecase/application-usecase.ts create mode 100644 src/feature/file/usecase/file-usecase.ts delete mode 100644 src/infrastructure/api/apis/local-server/encode-query-params.ts create mode 100644 src/pages/submit.tsx create mode 100644 src/widgets/application/ui/submit-form.tsx diff --git a/src/App.tsx b/src/App.tsx index caf1c2d..93f056d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,10 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { useState } from 'react'; +import { useApplicationQuery } from '@/feature/application/application/application-query'; +import { implApplicationUsecase } from '@/feature/application/usecase/application-usecase'; import { useAuthQuery } from '@/feature/auth/application/auth-query'; import { implAuthUsecase } from '@/feature/auth/usecase/auth-usecase'; +import { implFileUsecase } from '@/feature/file/usecase/file-usecase'; import { QueryContext } from '@/feature/shared/context/query-context'; import { ReviewContext } from '@/feature/shared/context/review-context'; import { TokenContext } from '@/feature/shared/context/token-context'; @@ -10,6 +13,8 @@ import { RouterProvider } from '@/feature/shared/routes/router-provider'; import { useStoreQuery } from '@/feature/store/application/store-query'; import { implStoreUsecase } from '@/feature/store/usecase/store-usecase'; import { externalCall, implApi } from '@/infrastructure/api'; +import { implStorageApi } from '@/infrastructure/api/client'; +import { externalStorageCall } from '@/infrastructure/api/external-call'; import { implTokenRepository } from '@/infrastructure/token/token-repository'; const queryClient = new QueryClient({ @@ -26,14 +31,18 @@ const AppContent = () => { const [storeId, setStoreId] = useState(null); const [eventId, setEventId] = useState(null); const api = implApi({ externalCall }); + const storageApi = implStorageApi({ externalStorageCall }); const tokenRepository = implTokenRepository({ setToken }); const authUsecase = implAuthUsecase({ api, tokenRepository }); const authQuery = useAuthQuery({ authUsecase }); const storeUsecase = implStoreUsecase({ api }); const storeQuery = useStoreQuery({ storeUsecase }); + const fileUsecase = implFileUsecase({ api, storageApi }); + const applicationUsecase = implApplicationUsecase({ api, fileUsecase }); + const applicationQuery = useApplicationQuery({ applicationUsecase }); return ( - + ; + +export const useApplicationQuery = ({ + applicationUsecase, +}: { + applicationUsecase: ApplicationUsecase; +}) => { + return { + useGetStore: ({ storeId }: { storeId: string }) => { + const { data, isLoading } = useQuery({ + queryKey: ['store', storeId], + queryFn: async (): Promise => { + const response = await applicationUsecase.getStore({ storeId }); + if (response.type === 'success') { + return response.data; + } + throw new Error(response.message); + }, + }); + return { store: data, isLoading }; + }, + + useGetEvent: ({ eventId }: { eventId: string }) => { + const { data, isLoading } = useQuery({ + queryKey: ['event', eventId], + queryFn: async (): Promise => { + const response = await applicationUsecase.getEvent({ eventId }); + if (response.type === 'success') { + return response.data; + } + throw new Error(response.message); + }, + }); + return { event: data, isLoading }; + }, + + useSubmitApplication: ({ + setResponseMessage, + onSuccess, + }: { + setResponseMessage: (message: string) => void; + onSuccess: () => void; + }) => { + const { mutate: submitApplication, isPending } = useMutation({ + mutationFn: async ({ + token, + eventId, + walletAddress, + imageFile, + }: { + token: string; + eventId: string; + walletAddress: string; + imageFile: File; + }) => { + return await applicationUsecase.submitApplication({ + token, + eventId, + walletAddress, + imageFile, + }); + }, + onSuccess: (response) => { + if (response.type === 'success') { + onSuccess(); + return; + } + setResponseMessage( + createErrorMessage( + response.code, + '제출에 실패했습니다. 다시 시도해 주세요.' + ) + ); + }, + }); + return { submitApplication, isPending }; + }, + }; +}; diff --git a/src/feature/application/presenter/application-form-presenter.ts b/src/feature/application/presenter/application-form-presenter.ts new file mode 100644 index 0000000..4e26d80 --- /dev/null +++ b/src/feature/application/presenter/application-form-presenter.ts @@ -0,0 +1,30 @@ +import type { Input, InputForForm } from '@/entities/input'; +import type { ApplicationInputPresenter } from './application-input-presenter'; + +type ApplicationFormPresenter = { + useValidator({ + applicationInputPresenter, + }: { + applicationInputPresenter: ApplicationInputPresenter; + }): { + inputStates: { + walletAddress: Input; + imageFile: Input; + }; + formStates: { + walletAddress: InputForForm; + imageFile: InputForForm; + }; + }; +}; + +export const applicationFormPresenter: ApplicationFormPresenter = { + useValidator: ({ applicationInputPresenter }) => { + const { walletAddress, imageFile } = + applicationInputPresenter.useValidator(); + return { + inputStates: { walletAddress, imageFile }, + formStates: { walletAddress, imageFile }, + }; + }, +}; diff --git a/src/feature/application/presenter/application-input-presenter.ts b/src/feature/application/presenter/application-input-presenter.ts new file mode 100644 index 0000000..25c6c62 --- /dev/null +++ b/src/feature/application/presenter/application-input-presenter.ts @@ -0,0 +1,36 @@ +import { useState } from 'react'; +import type { Input } from '@/entities/input'; + +const ETHEREUM_ADDRESS_REGEX = /^0x[0-9a-fA-F]{40}$/; +const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp']; +const MAX_FILE_SIZE = 10 * 1024 * 1024; + +export type ApplicationInputPresenter = { + useValidator(): { + walletAddress: Input; + imageFile: Input; + }; +}; + +export const applicationInputPresenter: ApplicationInputPresenter = { + useValidator: () => { + const [walletAddress, setWalletAddress] = useState(''); + const [imageFile, setImageFile] = useState(null); + + return { + walletAddress: { + value: walletAddress, + isError: !ETHEREUM_ADDRESS_REGEX.test(walletAddress), + onChange: setWalletAddress, + }, + imageFile: { + value: imageFile, + isError: + imageFile === null || + !ALLOWED_IMAGE_TYPES.includes(imageFile.type) || + imageFile.size > MAX_FILE_SIZE, + onChange: setImageFile, + }, + }; + }, +}; diff --git a/src/feature/application/usecase/application-usecase.ts b/src/feature/application/usecase/application-usecase.ts new file mode 100644 index 0000000..b6e69d5 --- /dev/null +++ b/src/feature/application/usecase/application-usecase.ts @@ -0,0 +1,77 @@ +import type { UsecaseResponse } from '@/feature/shared/response'; +import type { Apis } from '@/infrastructure/api'; +import type { + EventDetailResponse, + StoreDetailResponse, +} from '@/infrastructure/api/apis/local-server/schemas'; +import type { FileUsecase } from '../../file/usecase/file-usecase'; + +export type ApplicationUsecase = { + getStore(params: { storeId: string }): UsecaseResponse; + getEvent(params: { eventId: string }): UsecaseResponse; + submitApplication(params: { + token: string; + eventId: string; + walletAddress: string; + imageFile: File; + }): UsecaseResponse; +}; + +export const implApplicationUsecase = ({ + api, + fileUsecase, +}: { + api: Apis; + fileUsecase: FileUsecase; +}): ApplicationUsecase => ({ + getStore: async ({ storeId }) => { + const { status, data } = await api['GET /api/store/:storeId']({ + query: { storeId }, + }); + if (status === 200) { + return { type: 'success', data: data as StoreDetailResponse }; + } + const err = data as { code: string; message: string }; + return { type: 'error', code: err.code, message: err.message }; + }, + + getEvent: async ({ eventId }) => { + const { status, data } = await api['GET /api/event/:eventId']({ + query: { eventId }, + }); + if (status === 200) { + return { type: 'success', data: data as EventDetailResponse }; + } + const err = data as { code: string; message: string }; + return { type: 'error', code: err.code, message: err.message }; + }, + + submitApplication: async ({ token, eventId, walletAddress, imageFile }) => { + const presignedResp = await fileUsecase.getUploadPresignedUrl({ + token, + fileName: imageFile.name, + fileType: 'REVIEW', + }); + if (presignedResp.type === 'error') { + return presignedResp; + } + + const uploadResp = await fileUsecase.uploadFile({ + presignedUrl: presignedResp.data.url, + file: imageFile, + }); + if (uploadResp.type === 'error') { + return uploadResp; + } + + const { status, data } = await api['POST /api/applications']({ + token, + body: { eventId, walletAddress, imageKey: presignedResp.data.s3Key }, + }); + if (status === 200) { + return { type: 'success', data: null }; + } + const err = data as { code: string; message: string }; + return { type: 'error', code: err.code, message: err.message }; + }, +}); diff --git a/src/feature/file/usecase/file-usecase.ts b/src/feature/file/usecase/file-usecase.ts new file mode 100644 index 0000000..e3006f9 --- /dev/null +++ b/src/feature/file/usecase/file-usecase.ts @@ -0,0 +1,53 @@ +import type { FileType } from '@/entities/file'; +import type { UsecaseResponse } from '@/feature/shared/response'; +import type { Apis } from '@/infrastructure/api'; +import type { S3UploadResponse } from '@/infrastructure/api/apis/local-server/schemas'; +import type { StorageApis } from '@/infrastructure/api/client'; + +export type FileUsecase = { + getUploadPresignedUrl(params: { + token: string; + fileName: string; + fileType: FileType; + }): UsecaseResponse; + uploadFile(params: { + presignedUrl: string; + file: File; + }): UsecaseResponse; +}; + +export const implFileUsecase = ({ + api, + storageApi, +}: { + api: Apis; + storageApi: StorageApis; +}): FileUsecase => ({ + getUploadPresignedUrl: async ({ token, fileName, fileType }) => { + const { status, data } = await api['POST /api/s3']({ + token, + body: { fileName, fileType }, + }); + if (status === 200) { + return { type: 'success', data: data as S3UploadResponse }; + } + const err = data as { code: string; message: string }; + return { type: 'error', code: err.code, message: err.message }; + }, + + uploadFile: async ({ presignedUrl, file }) => { + const { status } = await storageApi['PUT upload-file']({ + path: presignedUrl, + body: file, + contentType: file.type, + }); + if (status === 200) { + return { type: 'success', data: null }; + } + return { + type: 'error', + code: 'S3_001', + message: '이미지 업로드에 실패했습니다.', + }; + }, +}); diff --git a/src/feature/shared/context/query-context.ts b/src/feature/shared/context/query-context.ts index cac9ca5..777bc61 100644 --- a/src/feature/shared/context/query-context.ts +++ b/src/feature/shared/context/query-context.ts @@ -1,10 +1,12 @@ import { createContext } from 'react'; +import type { ApplicationQuery } from '@/feature/application/application/application-query'; import type { AuthQuery } from '@/feature/auth/application/auth-query'; import type { StoreQuery } from '@/feature/store/application/store-query'; export type QueryContext = { authQuery: AuthQuery; storeQuery: StoreQuery; + applicationQuery: ApplicationQuery; }; export const QueryContext = createContext(null); diff --git a/src/feature/shared/routes/path.ts b/src/feature/shared/routes/path.ts index 2269034..bc29298 100644 --- a/src/feature/shared/routes/path.ts +++ b/src/feature/shared/routes/path.ts @@ -3,5 +3,5 @@ export const PATH = { SIGN_IN: '/sign-in', SIGN_UP: '/sign-up', SIGN_UP_COMPLETE: '/sign-up-complete', - SUBMIT: '/submit', + SUBMIT: '/store/:storeId/event/:eventId', }; diff --git a/src/feature/shared/routes/router-provider.tsx b/src/feature/shared/routes/router-provider.tsx index e04ec6e..c9a076c 100644 --- a/src/feature/shared/routes/router-provider.tsx +++ b/src/feature/shared/routes/router-provider.tsx @@ -6,6 +6,7 @@ import { LandingPage } from '../../../pages/landing'; import { MyPage } from '../../../pages/my-page'; import { SignInPage } from '../../../pages/sign-in'; import { SignUpPage } from '../../../pages/sign-up'; +import { SubmitPage } from '../../../pages/submit'; import { PATH } from './path'; export const RouterProvider = () => { @@ -20,6 +21,9 @@ export const RouterProvider = () => { }> } /> + }> + } /> + diff --git a/src/feature/shared/routes/use-route-navigation.ts b/src/feature/shared/routes/use-route-navigation.ts index 82ba605..07d4eda 100644 --- a/src/feature/shared/routes/use-route-navigation.ts +++ b/src/feature/shared/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, SIGN_UP_COMPLETE, SUBMIT } = PATH; + const { LANDING, SIGN_IN, SIGN_UP, SIGN_UP_COMPLETE } = PATH; return { toMain: () => { @@ -18,8 +18,8 @@ export const useRouteNavigation = () => { toSignUpComplete: () => { void navigate(SIGN_UP_COMPLETE); }, - toSubmit: () => { - void navigate(SUBMIT); + toSubmit: ({ storeId, eventId }: { storeId: string; eventId: string }) => { + void navigate(`/store/${storeId}/event/${eventId}`); }, toBack: () => { void navigate(-1); diff --git a/src/infrastructure/api/apis/local-server/encode-query-params.ts b/src/infrastructure/api/apis/local-server/encode-query-params.ts deleted file mode 100644 index fa34f9f..0000000 --- a/src/infrastructure/api/apis/local-server/encode-query-params.ts +++ /dev/null @@ -1,33 +0,0 @@ -export const encodeQueryParams = ({ - params, -}: { - params: Record< - string, - | string - | number - | boolean - | string[] - | number[] - | boolean[] - | null - | undefined - >; -}) => { - const queryParameters = new URLSearchParams(); - - Object.entries(params).forEach(([key, value]) => { - if (value === undefined || value === null) { - return; // null, undefined 제외 - } - - if (Array.isArray(value)) { - value.forEach((v) => { - queryParameters.append(key, v.toString()); - }); - } else { - queryParameters.append(key, value.toString()); - } - }); - - return queryParameters.toString(); -}; diff --git a/src/infrastructure/api/apis/local-server/schemas.ts b/src/infrastructure/api/apis/local-server/schemas.ts index a38ff95..3f801c6 100644 --- a/src/infrastructure/api/apis/local-server/schemas.ts +++ b/src/infrastructure/api/apis/local-server/schemas.ts @@ -70,3 +70,35 @@ export type VerificationTokenResponse = { export type TokenResponse = { accessToken: string; }; + +export type StoreDetailResponse = { + id: string; + name: string; + address: string; +}; + +export type EventDetailResponse = { + id: string; + title: string; + condition: string; + reward: number; + isActive: boolean; +}; + +export type S3FileType = 'REVIEW' | 'STORE'; + +export type S3UploadRequest = { + fileName: string; + fileType: S3FileType; +}; + +export type S3UploadResponse = { + url: string; + s3Key: string; +}; + +export type ApplicationCreateRequest = { + eventId: string; + walletAddress: string; + imageKey: string; +}; diff --git a/src/mocks/application/handler.ts b/src/mocks/application/handler.ts index dda18c6..525deb6 100644 --- a/src/mocks/application/handler.ts +++ b/src/mocks/application/handler.ts @@ -3,6 +3,7 @@ import { http } from 'msw'; import { applicationResolver } from './resolvers'; export const applicationHandlers = [ + http.post('*/api/applications', applicationResolver.submitApplication), http.get( '*/api/event/:eventId/applications', applicationResolver.getEventApplications diff --git a/src/mocks/application/resolvers.ts b/src/mocks/application/resolvers.ts index 5491c63..7b3f5e2 100644 --- a/src/mocks/application/resolvers.ts +++ b/src/mocks/application/resolvers.ts @@ -7,11 +7,17 @@ import { } from '../utils'; import { mockApplicationSummaries, mockApplications } from './data'; import type { + ApplicationCreateRequest, ApplicationStatusRequest, ApplicationSubmissionRequest, } from './schemas'; type ApplicationResolver = { + submitApplication: HttpResponseResolver< + never, + ApplicationCreateRequest, + never + >; getEventApplications: HttpResponseResolver<{ eventId: string }, never, never>; applyEvent: HttpResponseResolver<{ eventId: string }, never, never>; getMyApplications: HttpResponseResolver; @@ -32,7 +38,63 @@ type ApplicationResolver = { >; }; +const ETHEREUM_ADDRESS_REGEX = /^0x[0-9a-fA-F]{40}$/; + export const applicationResolver: ApplicationResolver = { + // POST /applications - 리뷰어가 이벤트 신청 + 인증 사진 한 번에 제출 + submitApplication: async ({ request }) => { + const role = getRole(request); + if (!role) { + return HttpResponse.json( + { + code: 'AUTH_001', + message: 'Authorization header is missing or malformed', + }, + { status: 401 } + ); + } + if (role !== 'REVIEWER') { + return HttpResponse.json( + { + code: 'AUTH_007', + message: 'You do not have permission to perform this action', + }, + { status: 401 } + ); + } + + const body = (await request.json()) as ApplicationCreateRequest; + if (!body.eventId || !body.walletAddress || !body.imageKey) { + return HttpResponse.json( + { code: 'GEN_004', message: 'Required fields are missing' }, + { status: 400 } + ); + } + + if (!ETHEREUM_ADDRESS_REGEX.test(body.walletAddress)) { + return HttpResponse.json( + { code: 'GEN_005', message: 'Invalid wallet address format' }, + { status: 400 } + ); + } + + const event = mockEvents.find((e) => e.id === body.eventId); + if (!event) { + return HttpResponse.json( + { code: 'EVENT_001', message: 'Event not found' }, + { status: 404 } + ); + } + if (!event.isActive) { + return HttpResponse.json( + { code: 'GEN_003', message: 'Event is already closed' }, + { status: 400 } + ); + } + + return HttpResponse.json(null, { status: 200 }); + }, + // GET /event/:eventId/applications - 사장님이 이벤트별 신청 목록 조회 getEventApplications: ({ request, params }) => { const { eventId } = params; diff --git a/src/mocks/application/schemas.ts b/src/mocks/application/schemas.ts index bb554e5..fb0baef 100644 --- a/src/mocks/application/schemas.ts +++ b/src/mocks/application/schemas.ts @@ -56,3 +56,9 @@ export type ApplicationStatusRequest = { status: 'approved' | 'rejected'; reason?: string; }; + +export type ApplicationCreateRequest = { + eventId: string; + walletAddress: string; + imageKey: string; +}; diff --git a/src/mocks/event/handler.ts b/src/mocks/event/handler.ts index 90acdfa..7f49948 100644 --- a/src/mocks/event/handler.ts +++ b/src/mocks/event/handler.ts @@ -6,6 +6,7 @@ export const eventHandlers = [ http.post('*/api/event', eventResolver.createEvent), // /event/owner를 /event/:eventId보다 먼저 등록 http.get('*/api/event/owner', eventResolver.getOwnerEvents), + http.get('*/api/event/:eventId', eventResolver.getEvent), http.patch('*/api/event/:eventId', eventResolver.patchEvent), http.delete('*/api/event/:eventId', eventResolver.deleteEvent), ]; diff --git a/src/mocks/event/resolvers.ts b/src/mocks/event/resolvers.ts index 0d049c4..a5e2de5 100644 --- a/src/mocks/event/resolvers.ts +++ b/src/mocks/event/resolvers.ts @@ -7,6 +7,7 @@ import type { EventCreateRequest, EventPatchRequest } from './schemas'; type EventResolver = { createEvent: HttpResponseResolver; getOwnerEvents: HttpResponseResolver; + getEvent: HttpResponseResolver<{ eventId: string }, never, never>; patchEvent: HttpResponseResolver< { eventId: string }, EventPatchRequest, @@ -89,6 +90,18 @@ export const eventResolver: EventResolver = { return HttpResponse.json({ events: mockEvents }); }, + getEvent: ({ params }) => { + const { eventId } = params; + const event = mockEvents.find((e) => e.id === eventId); + if (!event) { + return HttpResponse.json( + { code: 'EVENT_001', message: 'Event not found' }, + { status: 404 } + ); + } + return HttpResponse.json(event); + }, + patchEvent: async ({ request, params }) => { const { eventId } = params; const role = getRole(request); diff --git a/src/mocks/s3/handler.ts b/src/mocks/s3/handler.ts index a11652d..7a31676 100644 --- a/src/mocks/s3/handler.ts +++ b/src/mocks/s3/handler.ts @@ -1,5 +1,8 @@ import { http } from 'msw'; - +import { MOCK_S3_BASE_URL } from './data'; import { s3Resolver } from './resolvers'; -export const s3Handlers = [http.post('*/api/s3', s3Resolver.getPresignedUrl)]; +export const s3Handlers = [ + http.post('*/api/s3', s3Resolver.getPresignedUrl), + http.put(`${MOCK_S3_BASE_URL}/*`, s3Resolver.uploadFile), +]; diff --git a/src/mocks/s3/resolvers.ts b/src/mocks/s3/resolvers.ts index b28dcd9..6f73bcc 100644 --- a/src/mocks/s3/resolvers.ts +++ b/src/mocks/s3/resolvers.ts @@ -6,6 +6,7 @@ import type { S3UploadRequest } from './schemas'; type S3Resolver = { getPresignedUrl: HttpResponseResolver; + uploadFile: HttpResponseResolver; }; export const s3Resolver: S3Resolver = { @@ -34,4 +35,8 @@ export const s3Resolver: S3Resolver = { s3Key: `/${body.fileType}/mock/${body.fileName}`, }); }, + + uploadFile: () => { + return new HttpResponse(null, { status: 200 }); + }, }; diff --git a/src/mocks/store/handler.ts b/src/mocks/store/handler.ts index f19d1bd..edf02c9 100644 --- a/src/mocks/store/handler.ts +++ b/src/mocks/store/handler.ts @@ -5,7 +5,9 @@ import { storeResolver } from './resolvers'; export const storeHandlers = [ http.post('*/api/store', storeResolver.createStore), http.get('*/api/store', storeResolver.getStores), + // /store/:storeId/events 를 /store/:storeId 보다 먼저 등록 + http.get('*/api/store/:storeId/events', storeResolver.getStoreEvents), + http.get('*/api/store/:storeId', storeResolver.getStore), http.patch('*/api/store/:storeId', storeResolver.patchStore), http.delete('*/api/store/:storeId', storeResolver.deleteStore), - http.get('*/api/store/:storeId/events', storeResolver.getStoreEvents), ]; diff --git a/src/mocks/store/resolvers.ts b/src/mocks/store/resolvers.ts index 6efb347..5de7295 100644 --- a/src/mocks/store/resolvers.ts +++ b/src/mocks/store/resolvers.ts @@ -16,6 +16,7 @@ const toStoreResponse = (store: (typeof mockStores)[0]): StoreResponse => { type StoreResolver = { createStore: HttpResponseResolver; getStores: HttpResponseResolver; + getStore: HttpResponseResolver<{ storeId: string }, never, never>; patchStore: HttpResponseResolver< { storeId: string }, StorePatchRequest, @@ -82,6 +83,22 @@ export const storeResolver: StoreResolver = { }); }, + getStore: ({ params }) => { + const { storeId } = params; + const store = mockStores.find((s) => s.id === storeId); + if (!store) { + return HttpResponse.json( + { code: 'STORE_001', message: 'Store not found' }, + { status: 404 } + ); + } + return HttpResponse.json({ + id: store.id, + name: store.name, + address: store.address, + }); + }, + patchStore: async ({ request, params }) => { const { storeId } = params; const role = getRole(request); diff --git a/src/pages/submit.tsx b/src/pages/submit.tsx new file mode 100644 index 0000000..a0d94d5 --- /dev/null +++ b/src/pages/submit.tsx @@ -0,0 +1,7 @@ +import { SubmitForm } from '@/widgets/application/ui/submit-form'; + +export const SubmitPage = () => ( +
+ +
+); diff --git a/src/widgets/application/ui/submit-form.tsx b/src/widgets/application/ui/submit-form.tsx new file mode 100644 index 0000000..8761e8a --- /dev/null +++ b/src/widgets/application/ui/submit-form.tsx @@ -0,0 +1,344 @@ +import { CheckCircle, ImagePlus, Upload, Wallet, X } from 'lucide-react'; +import { useRef, useState } from 'react'; +import { useParams } from 'react-router'; +import { applicationFormPresenter } from '@/feature/application/presenter/application-form-presenter'; +import { applicationInputPresenter } from '@/feature/application/presenter/application-input-presenter'; +import { QueryContext } from '@/feature/shared/context/query-context'; +import { TokenContext } from '@/feature/shared/context/token-context'; +import { useGuardContext } from '@/feature/shared/context/use-gaurd-context'; +import { Button } from '@/widgets/common/ui/button'; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@/widgets/common/ui/card'; +import { Input } from '@/widgets/common/ui/input'; +import { Label } from '@/widgets/common/ui/label'; + +declare global { + interface Window { + ethereum?: { + request(args: { method: string }): Promise; + }; + } +} + +const ALLOWED_EXTENSIONS = '.jpg,.jpeg,.png,.webp'; + +export const SubmitForm = () => { + const { storeId, eventId } = useParams<{ + storeId: string; + eventId: string; + }>(); + + const { applicationQuery } = useGuardContext(QueryContext); + const { token } = useGuardContext(TokenContext); + + const { store } = applicationQuery.useGetStore({ storeId: storeId ?? '' }); + const { event } = applicationQuery.useGetEvent({ eventId: eventId ?? '' }); + + const { inputStates, formStates } = applicationFormPresenter.useValidator({ + applicationInputPresenter, + }); + + const [walletMode, setWalletMode] = useState<'manual' | 'metamask'>('manual'); + const [metaMaskError, setMetaMaskError] = useState(''); + const [previewUrl, setPreviewUrl] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const [responseMessage, setResponseMessage] = useState(''); + const [isSubmitted, setIsSubmitted] = useState(false); + + const fileInputRef = useRef(null); + + const { submitApplication, isPending } = + applicationQuery.useSubmitApplication({ + setResponseMessage, + onSuccess: () => { + setIsSubmitted(true); + }, + }); + + const handleMetaMaskConnect = async () => { + setMetaMaskError(''); + if (window.ethereum === undefined) { + setMetaMaskError( + 'MetaMask가 설치되어 있지 않습니다. metamask.io에서 설치해 주세요.' + ); + return; + } + try { + const accounts = await window.ethereum.request({ + method: 'eth_requestAccounts', + }); + const address = accounts[0]; + if (address !== undefined) { + inputStates.walletAddress.onChange(address); + setWalletMode('metamask'); + } + } catch { + setMetaMaskError('MetaMask 연결에 실패했습니다.'); + } + }; + + const handleDisconnectMetaMask = () => { + inputStates.walletAddress.onChange(''); + setWalletMode('manual'); + setMetaMaskError(''); + }; + + const handleFileSelect = (file: File) => { + inputStates.imageFile.onChange(file); + if (previewUrl !== null) { + URL.revokeObjectURL(previewUrl); + } + setPreviewUrl(URL.createObjectURL(file)); + }; + + const handleFileClear = () => { + inputStates.imageFile.onChange(null); + if (previewUrl !== null) { + URL.revokeObjectURL(previewUrl); + setPreviewUrl(null); + } + if (fileInputRef.current !== null) { + fileInputRef.current.value = ''; + } + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const file = e.dataTransfer.files[0]; + if (file !== undefined) { + handleFileSelect(file); + } + }; + + const handleSubmit = () => { + if (token === null || storeId === undefined || eventId === undefined) { + return; + } + if (formStates.walletAddress.isError || formStates.imageFile.isError) { + return; + } + if (inputStates.imageFile.value === null) { + return; + } + + submitApplication({ + token, + eventId, + walletAddress: inputStates.walletAddress.value, + imageFile: inputStates.imageFile.value, + }); + }; + + if (isSubmitted) { + return ( +
+ +
+

제출 완료

+

+ 신청이 접수되었습니다. 사장님이 인증을 검토한 후 리워드가 + 지급됩니다. +

+
+
+ ); + } + + return ( +
+

이벤트 참여 신청

+ + {/* 가게·이벤트 정보 */} + + + 가게 정보 + + + {store !== undefined ? ( + <> +

{store.name}

+

{store.address}

+ + ) : ( +

불러오는 중...

+ )} +
+
+ + + + 이벤트 정보 + + + {event !== undefined ? ( + <> +

{event.title}

+

{event.condition}

+

{event.reward} ETH

+ + ) : ( +

불러오는 중...

+ )} +
+
+ + {/* 지갑 등록 */} +
+ + + {walletMode === 'manual' ? ( + + ) : ( +
+ + + {inputStates.walletAddress.value} + + +
+ )} + + {metaMaskError.length > 0 && ( +

{metaMaskError}

+ )} + + {walletMode === 'manual' && ( +
+ { + inputStates.walletAddress.onChange(e.target.value); + }} + /> + {inputStates.walletAddress.value.length > 0 && + formStates.walletAddress.isError && ( +

+ 올바른 이더리움 주소를 입력해 주세요 (0x 시작, 42자) +

+ )} +
+ )} +
+ + {/* 인증 사진 업로드 */} +
+ + + {previewUrl !== null ? ( +
+ 인증 사진 미리보기 + + {formStates.imageFile.isError && ( +

+ jpg, jpeg, png, webp 파일만 가능하며 크기는 10MB 이하여야 + 합니다. +

+ )} +
+ ) : ( +
{ + fileInputRef.current?.click(); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + fileInputRef.current?.click(); + } + }} + onDragOver={(e) => { + e.preventDefault(); + setIsDragging(true); + }} + onDragLeave={() => { + setIsDragging(false); + }} + onDrop={handleDrop} + role="button" + tabIndex={0} + > + {isDragging ? ( + + ) : ( + + )} +
+

클릭하거나 파일을 끌어다 놓으세요

+

+ JPG, PNG, WEBP · 최대 10MB +

+
+
+ )} + + { + const file = e.target.files?.[0]; + if (file !== undefined) { + handleFileSelect(file); + } + }} + /> +
+ + {/* 에러 메시지 */} + {responseMessage.length > 0 && ( +

+ {responseMessage} +

+ )} + + +
+ ); +}; diff --git a/src/widgets/landing/ui/store-search.tsx b/src/widgets/landing/ui/store-search.tsx index 62a4e2b..1b2bd9d 100644 --- a/src/widgets/landing/ui/store-search.tsx +++ b/src/widgets/landing/ui/store-search.tsx @@ -155,7 +155,11 @@ export const StoreSearch = () => { From 2c594f89a088dddf4a74d5ff8ea402a75209f20b Mon Sep 17 00:00:00 2001 From: Yeonu-Kim Date: Sun, 7 Jun 2026 20:33:43 +0900 Subject: [PATCH 4/7] =?UTF-8?q?=E2=9C=A8=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EC=95=84=EB=8F=84=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=82=AC=EC=A7=84=EC=9D=84=20=EC=98=AC=EB=A6=B4=20?= =?UTF-8?q?=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/application-query.ts | 3 - .../usecase/application-usecase.ts | 5 +- src/feature/file/usecase/file-usecase.ts | 4 +- src/feature/shared/routes/router-provider.tsx | 4 +- .../api/apis/local-server/apis.ts | 22 +---- src/mocks/application/resolvers.ts | 20 ----- src/mocks/s3/resolvers.ts | 12 --- src/widgets/application/ui/submit-form.tsx | 88 +++++++++++-------- 8 files changed, 58 insertions(+), 100 deletions(-) diff --git a/src/feature/application/application/application-query.ts b/src/feature/application/application/application-query.ts index b1038a0..ea7fdac 100644 --- a/src/feature/application/application/application-query.ts +++ b/src/feature/application/application/application-query.ts @@ -51,18 +51,15 @@ export const useApplicationQuery = ({ }) => { const { mutate: submitApplication, isPending } = useMutation({ mutationFn: async ({ - token, eventId, walletAddress, imageFile, }: { - token: string; eventId: string; walletAddress: string; imageFile: File; }) => { return await applicationUsecase.submitApplication({ - token, eventId, walletAddress, imageFile, diff --git a/src/feature/application/usecase/application-usecase.ts b/src/feature/application/usecase/application-usecase.ts index b6e69d5..009f6e0 100644 --- a/src/feature/application/usecase/application-usecase.ts +++ b/src/feature/application/usecase/application-usecase.ts @@ -10,7 +10,6 @@ export type ApplicationUsecase = { getStore(params: { storeId: string }): UsecaseResponse; getEvent(params: { eventId: string }): UsecaseResponse; submitApplication(params: { - token: string; eventId: string; walletAddress: string; imageFile: File; @@ -46,9 +45,8 @@ export const implApplicationUsecase = ({ return { type: 'error', code: err.code, message: err.message }; }, - submitApplication: async ({ token, eventId, walletAddress, imageFile }) => { + submitApplication: async ({ eventId, walletAddress, imageFile }) => { const presignedResp = await fileUsecase.getUploadPresignedUrl({ - token, fileName: imageFile.name, fileType: 'REVIEW', }); @@ -65,7 +63,6 @@ export const implApplicationUsecase = ({ } const { status, data } = await api['POST /api/applications']({ - token, body: { eventId, walletAddress, imageKey: presignedResp.data.s3Key }, }); if (status === 200) { diff --git a/src/feature/file/usecase/file-usecase.ts b/src/feature/file/usecase/file-usecase.ts index e3006f9..0e28103 100644 --- a/src/feature/file/usecase/file-usecase.ts +++ b/src/feature/file/usecase/file-usecase.ts @@ -6,7 +6,6 @@ import type { StorageApis } from '@/infrastructure/api/client'; export type FileUsecase = { getUploadPresignedUrl(params: { - token: string; fileName: string; fileType: FileType; }): UsecaseResponse; @@ -23,9 +22,8 @@ export const implFileUsecase = ({ api: Apis; storageApi: StorageApis; }): FileUsecase => ({ - getUploadPresignedUrl: async ({ token, fileName, fileType }) => { + getUploadPresignedUrl: async ({ fileName, fileType }) => { const { status, data } = await api['POST /api/s3']({ - token, body: { fileName, fileType }, }); if (status === 200) { diff --git a/src/feature/shared/routes/router-provider.tsx b/src/feature/shared/routes/router-provider.tsx index c9a076c..91233cd 100644 --- a/src/feature/shared/routes/router-provider.tsx +++ b/src/feature/shared/routes/router-provider.tsx @@ -16,14 +16,12 @@ export const RouterProvider = () => { } /> } /> + } /> }> } /> }> } /> - }> - } /> - diff --git a/src/infrastructure/api/apis/local-server/apis.ts b/src/infrastructure/api/apis/local-server/apis.ts index c04609a..7c366cc 100644 --- a/src/infrastructure/api/apis/local-server/apis.ts +++ b/src/infrastructure/api/apis/local-server/apis.ts @@ -125,30 +125,16 @@ export const getLocalServerApis = ({ method: 'GET', path: `api/event/${query.eventId}`, }), - 'POST /api/s3': ({ - token, - body, - }: { - token: string; - body: S3UploadRequest; - }) => - callWithToken>({ + 'POST /api/s3': ({ body }: { body: S3UploadRequest }) => + callWithoutToken>({ method: 'POST', path: 'api/s3', body, - token, }), - 'POST /api/applications': ({ - token, - body, - }: { - token: string; - body: ApplicationCreateRequest; - }) => - callWithToken>({ + 'POST /api/applications': ({ body }: { body: ApplicationCreateRequest }) => + callWithoutToken>({ method: 'POST', path: 'api/applications', body, - token, }), }) satisfies Record; diff --git a/src/mocks/application/resolvers.ts b/src/mocks/application/resolvers.ts index 7b3f5e2..dc8c97f 100644 --- a/src/mocks/application/resolvers.ts +++ b/src/mocks/application/resolvers.ts @@ -43,26 +43,6 @@ const ETHEREUM_ADDRESS_REGEX = /^0x[0-9a-fA-F]{40}$/; export const applicationResolver: ApplicationResolver = { // POST /applications - 리뷰어가 이벤트 신청 + 인증 사진 한 번에 제출 submitApplication: async ({ request }) => { - const role = getRole(request); - if (!role) { - return HttpResponse.json( - { - code: 'AUTH_001', - message: 'Authorization header is missing or malformed', - }, - { status: 401 } - ); - } - if (role !== 'REVIEWER') { - return HttpResponse.json( - { - code: 'AUTH_007', - message: 'You do not have permission to perform this action', - }, - { status: 401 } - ); - } - const body = (await request.json()) as ApplicationCreateRequest; if (!body.eventId || !body.walletAddress || !body.imageKey) { return HttpResponse.json( diff --git a/src/mocks/s3/resolvers.ts b/src/mocks/s3/resolvers.ts index 6f73bcc..4226189 100644 --- a/src/mocks/s3/resolvers.ts +++ b/src/mocks/s3/resolvers.ts @@ -1,6 +1,5 @@ import { HttpResponse, type HttpResponseResolver } from 'msw'; -import { getRole } from '../utils'; import { MOCK_S3_BASE_URL } from './data'; import type { S3UploadRequest } from './schemas'; @@ -11,17 +10,6 @@ type S3Resolver = { export const s3Resolver: S3Resolver = { getPresignedUrl: async ({ request }) => { - const role = getRole(request); - if (!role) { - return HttpResponse.json( - { - code: 'AUTH_001', - message: 'Authorization header is missing or malformed', - }, - { status: 401 } - ); - } - const body = (await request.json()) as S3UploadRequest; if (!body.fileName || !body.fileType) { return HttpResponse.json( diff --git a/src/widgets/application/ui/submit-form.tsx b/src/widgets/application/ui/submit-form.tsx index 8761e8a..0f82ee7 100644 --- a/src/widgets/application/ui/submit-form.tsx +++ b/src/widgets/application/ui/submit-form.tsx @@ -4,7 +4,6 @@ import { useParams } from 'react-router'; import { applicationFormPresenter } from '@/feature/application/presenter/application-form-presenter'; import { applicationInputPresenter } from '@/feature/application/presenter/application-input-presenter'; import { QueryContext } from '@/feature/shared/context/query-context'; -import { TokenContext } from '@/feature/shared/context/token-context'; import { useGuardContext } from '@/feature/shared/context/use-gaurd-context'; import { Button } from '@/widgets/common/ui/button'; import { @@ -33,7 +32,6 @@ export const SubmitForm = () => { }>(); const { applicationQuery } = useGuardContext(QueryContext); - const { token } = useGuardContext(TokenContext); const { store } = applicationQuery.useGetStore({ storeId: storeId ?? '' }); const { event } = applicationQuery.useGetEvent({ eventId: eventId ?? '' }); @@ -42,7 +40,7 @@ export const SubmitForm = () => { applicationInputPresenter, }); - const [walletMode, setWalletMode] = useState<'manual' | 'metamask'>('manual'); + const [metaMaskConnected, setMetaMaskConnected] = useState(false); const [metaMaskError, setMetaMaskError] = useState(''); const [previewUrl, setPreviewUrl] = useState(null); const [isDragging, setIsDragging] = useState(false); @@ -74,7 +72,7 @@ export const SubmitForm = () => { const address = accounts[0]; if (address !== undefined) { inputStates.walletAddress.onChange(address); - setWalletMode('metamask'); + setMetaMaskConnected(true); } } catch { setMetaMaskError('MetaMask 연결에 실패했습니다.'); @@ -83,7 +81,15 @@ export const SubmitForm = () => { const handleDisconnectMetaMask = () => { inputStates.walletAddress.onChange(''); - setWalletMode('manual'); + setMetaMaskConnected(false); + setMetaMaskError(''); + }; + + const handleManualAddressChange = (value: string) => { + if (metaMaskConnected) { + setMetaMaskConnected(false); + } + inputStates.walletAddress.onChange(value); setMetaMaskError(''); }; @@ -116,18 +122,18 @@ export const SubmitForm = () => { }; const handleSubmit = () => { - if (token === null || storeId === undefined || eventId === undefined) { + if (storeId === undefined || eventId === undefined) { return; } if (formStates.walletAddress.isError || formStates.imageFile.isError) { return; } if (inputStates.imageFile.value === null) { + setResponseMessage('인증 사진을 업로드해 주세요.'); return; } submitApplication({ - token, eventId, walletAddress: inputStates.walletAddress.value, imageFile: inputStates.imageFile.value, @@ -191,19 +197,8 @@ export const SubmitForm = () => {
- {walletMode === 'manual' ? ( - - ) : ( + {/* MetaMask 연결 */} + {metaMaskConnected ? (
@@ -217,29 +212,48 @@ export const SubmitForm = () => {
+ ) : ( + )} {metaMaskError.length > 0 && (

{metaMaskError}

)} - {walletMode === 'manual' && ( -
- { - inputStates.walletAddress.onChange(e.target.value); - }} - /> - {inputStates.walletAddress.value.length > 0 && - formStates.walletAddress.isError && ( -

- 올바른 이더리움 주소를 입력해 주세요 (0x 시작, 42자) -

- )} -
- )} + {/* 구분선 */} +
+
+ 또는 +
+
+ + {/* 직접 입력 */} +
+ { + handleManualAddressChange(e.target.value); + }} + /> + {!metaMaskConnected && + inputStates.walletAddress.value.length > 0 && + formStates.walletAddress.isError && ( +

+ 올바른 이더리움 주소를 입력해 주세요 (0x 시작, 42자) +

+ )} +
{/* 인증 사진 업로드 */} From 47527307106521e1d8ccf1c98c499b78349cb3db Mon Sep 17 00:00:00 2001 From: Yeonu-Kim Date: Sun, 7 Jun 2026 21:42:20 +0900 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=A7=AA=20submit=20form=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20=EC=84=B1=EA=B3=B5/=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/stories/submit-form.stories.tsx | 320 ++++++++++++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 src/stories/submit-form.stories.tsx diff --git a/src/stories/submit-form.stories.tsx b/src/stories/submit-form.stories.tsx new file mode 100644 index 0000000..65842a7 --- /dev/null +++ b/src/stories/submit-form.stories.tsx @@ -0,0 +1,320 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { HttpResponse, http } from 'msw'; +import { useState } from 'react'; +import { Navigate, Route, Routes } from 'react-router'; +import { expect, fireEvent, userEvent, waitFor, within } from 'storybook/test'; + +import { useApplicationQuery } from '@/feature/application/application/application-query'; +import { implApplicationUsecase } from '@/feature/application/usecase/application-usecase'; +import { useAuthQuery } from '@/feature/auth/application/auth-query'; +import { implAuthUsecase } from '@/feature/auth/usecase/auth-usecase'; +import { implFileUsecase } from '@/feature/file/usecase/file-usecase'; +import { QueryContext } from '@/feature/shared/context/query-context'; +import { useStoreQuery } from '@/feature/store/application/store-query'; +import { implStoreUsecase } from '@/feature/store/usecase/store-usecase'; +import { externalCall, implApi } from '@/infrastructure/api'; +import { implStorageApi } from '@/infrastructure/api/client'; +import { externalStorageCall } from '@/infrastructure/api/external-call'; +import { implTokenRepository } from '@/infrastructure/token/token-repository'; +import { eventHandlers } from '@/mocks/event/handler'; +import { s3Handlers } from '@/mocks/s3/handler'; +import { SubmitForm } from '@/widgets/application/ui/submit-form'; + +const VALID_WALLET = '0xAbCdEf1234567890AbCdEf1234567890AbCdEf12'; +const MOCK_S3_BASE_URL = 'https://mock-s3.example.com/presigned'; + +const SubmitFormWrapper = () => { + const [, setToken] = useState(null); + const api = implApi({ externalCall }); + const storageApi = implStorageApi({ externalStorageCall }); + const tokenRepository = implTokenRepository({ setToken }); + const authUsecase = implAuthUsecase({ api, tokenRepository }); + const authQuery = useAuthQuery({ authUsecase }); + const storeUsecase = implStoreUsecase({ api }); + const storeQuery = useStoreQuery({ storeUsecase }); + const fileUsecase = implFileUsecase({ api, storageApi }); + const applicationUsecase = implApplicationUsecase({ api, fileUsecase }); + const applicationQuery = useApplicationQuery({ applicationUsecase }); + + return ( + + + } /> + } + /> + + + ); +}; + +// userEvent.upload은 display:none 요소에서 pointer events 체크를 통과하지 못하므로 +// DataTransfer + fireEvent.change로 직접 주입한다. +const uploadFileToInput = (canvasElement: HTMLElement, file: File) => { + const fileInput = + canvasElement.querySelector('input[type="file"]')!; + const dt = new DataTransfer(); + dt.items.add(file); + Object.defineProperty(fileInput, 'files', { + value: dt.files, + configurable: true, + }); + fireEvent.change(fileInput); +}; + +const fillForm = async (canvasElement: HTMLElement) => { + const canvas = within(canvasElement); + await userEvent.type( + canvas.getByPlaceholderText('지갑 주소 직접 입력 (0x...)'), + VALID_WALLET + ); + uploadFileToInput( + canvasElement, + new File(['img'], 'review.jpg', { type: 'image/jpeg' }) + ); + // 이미지 상태 반영 후 버튼 활성화 대기 + await waitFor(() => + expect(canvas.getByRole('button', { name: '제출하기' })).not.toBeDisabled() + ); +}; + +const meta: Meta = { + title: 'Application/SubmitForm', + component: SubmitFormWrapper, +}; + +export default meta; +type Story = StoryObj; + +// ─── 초기 상태 ────────────────────────────────────── +export const Default: Story = { + name: '200 폼 초기 렌더 — 가게·이벤트 정보 표시', + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.findByText('기절초풍왕순대') + ).resolves.toBeInTheDocument(); + await expect( + canvas.findByText('신규 메뉴 불닭볶음면 먹고 리뷰 달기') + ).resolves.toBeInTheDocument(); + }, +}; + +// ─── 성공 케이스 ────────────────────────────────────── +export const Success: Story = { + name: '200 제출 성공 — 완료 화면 전환', + play: async ({ canvasElement }) => { + await fillForm(canvasElement); + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole('button', { name: '제출하기' })); + await expect(canvas.findByText('제출 완료')).resolves.toBeInTheDocument(); + }, +}; + +// ─── MetaMask ────────────────────────────────────── +export const MetaMaskNotInstalled: Story = { + name: 'MetaMask 미설치 — 설치 안내 메시지', + play: async ({ canvasElement }) => { + // 브라우저에 MetaMask가 설치돼 있어도 미설치 케이스를 재현하기 위해 임시 제거 + const originalEthereum = window.ethereum; + window.ethereum = undefined; + + const canvas = within(canvasElement); + await userEvent.click( + canvas.getByRole('button', { name: 'MetaMask 연결' }) + ); + await expect( + canvas.findByText( + 'MetaMask가 설치되어 있지 않습니다. metamask.io에서 설치해 주세요.' + ) + ).resolves.toBeInTheDocument(); + + window.ethereum = originalEthereum; + }, +}; + +export const MetaMaskConnectionFailed: Story = { + name: 'MetaMask 연결 거부 — 에러 메시지', + play: async ({ canvasElement }) => { + const originalEthereum = window.ethereum; + window.ethereum = { + request: () => + Promise.reject(new Error('User rejected the request')) as Promise< + string[] + >, + }; + + const canvas = within(canvasElement); + await userEvent.click( + canvas.getByRole('button', { name: 'MetaMask 연결' }) + ); + await expect( + canvas.findByText('MetaMask 연결에 실패했습니다.') + ).resolves.toBeInTheDocument(); + + window.ethereum = originalEthereum; + }, +}; + +// ─── 클라이언트 유효성 ────────────────────────────────────── +export const ClientValidationErrorInvalidWallet: Story = { + name: '400 잘못된 지갑 주소 형식 (클라이언트 유효성)', + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.type( + canvas.getByPlaceholderText('지갑 주소 직접 입력 (0x...)'), + '0xinvalid' + ); + await expect( + canvas.findByText('올바른 이더리움 주소를 입력해 주세요 (0x 시작, 42자)') + ).resolves.toBeInTheDocument(); + }, +}; + +export const ClientValidationErrorInvalidFileType: Story = { + name: '400 허용되지 않는 파일 형식 (클라이언트 유효성)', + play: async ({ canvasElement }) => { + uploadFileToInput( + canvasElement, + new File(['pdf content'], 'document.pdf', { type: 'application/pdf' }) + ); + const canvas = within(canvasElement); + await expect( + canvas.findByText( + 'jpg, jpeg, png, webp 파일만 가능하며 크기는 10MB 이하여야 합니다.' + ) + ).resolves.toBeInTheDocument(); + }, +}; + +// ─── API 에러 ────────────────────────────────────── +// msw-storybook-addon은 parameters.msw.handlers 설정 시 전역 핸들러를 resetHandlers()로 +// 제거하고 스토리 핸들러만 등록한다. 따라서 제출 플로우에서 필요한 S3 핸들러를 함께 포함해야 한다. + +export const ErrorPresignedUrlFail: Story = { + name: '500 presigned URL 발급 실패', + parameters: { + msw: { + handlers: [ + // POST /api/s3를 오버라이드 — 이 시점에서 플로우가 멈추므로 S3 PUT은 불필요 + http.post('*/api/s3', () => + HttpResponse.json( + { code: 'S3_001', message: 'Failed to generate presigned URL' }, + { status: 500 } + ) + ), + ], + }, + }, + play: async ({ canvasElement }) => { + await fillForm(canvasElement); + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole('button', { name: '제출하기' })); + await expect( + canvas.findByText('파일 업로드 URL 생성에 실패했습니다.') + ).resolves.toBeInTheDocument(); + }, +}; + +export const ErrorS3UploadFail: Story = { + name: '500 S3 파일 업로드 실패', + parameters: { + msw: { + handlers: [ + // PUT 오버라이드를 먼저 등록해 s3Handlers의 PUT보다 우선 적용 + http.put( + `${MOCK_S3_BASE_URL}/*`, + () => new HttpResponse(null, { status: 500 }) + ), + // presigned URL 발급(POST /api/s3)은 정상 동작 필요 + ...s3Handlers, + ], + }, + }, + play: async ({ canvasElement }) => { + await fillForm(canvasElement); + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole('button', { name: '제출하기' })); + await expect( + canvas.findByText('파일 업로드 URL 생성에 실패했습니다.') + ).resolves.toBeInTheDocument(); + }, +}; + +export const ErrorEventClosed: Story = { + name: '400 마감된 이벤트 신청', + parameters: { + msw: { + handlers: [ + http.post('*/api/applications', () => + HttpResponse.json( + { code: 'GEN_003', message: 'Event is already closed' }, + { status: 400 } + ) + ), + // fillForm의 업로드 플로우(POST /api/s3 + PUT S3)에 필요 + ...s3Handlers, + ], + }, + }, + play: async ({ canvasElement }) => { + await fillForm(canvasElement); + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole('button', { name: '제출하기' })); + await expect( + canvas.findByText('잘못된 요청입니다.') + ).resolves.toBeInTheDocument(); + }, +}; + +export const ErrorEventNotFound: Story = { + name: '404 존재하지 않는 이벤트', + parameters: { + msw: { + handlers: [ + http.post('*/api/applications', () => + HttpResponse.json( + { code: 'EVENT_001', message: 'Event not found' }, + { status: 404 } + ) + ), + ...s3Handlers, + ], + }, + }, + play: async ({ canvasElement }) => { + await fillForm(canvasElement); + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole('button', { name: '제출하기' })); + await expect( + canvas.findByText('이벤트를 찾을 수 없습니다.') + ).resolves.toBeInTheDocument(); + }, +}; + +export const ErrorStoreLoadFail: Story = { + name: '404 가게 정보 조회 실패 — 로딩 유지', + parameters: { + msw: { + handlers: [ + http.get('*/api/store/:storeId', () => + HttpResponse.json( + { code: 'STORE_001', message: 'Store not found' }, + { status: 404 } + ) + ), + // 이벤트 쿼리는 글로벌 핸들러와 독립적으로 병렬 실행되므로 별도 포함 + ...eventHandlers, + ], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // 이벤트 카드는 성공적으로 로드되고, 가게 카드만 로딩 상태로 남는지 확인 + await expect( + canvas.findByText('신규 메뉴 불닭볶음면 먹고 리뷰 달기') + ).resolves.toBeInTheDocument(); + expect(canvas.getAllByText('불러오는 중...')).toHaveLength(1); + }, +}; From 602e469a5038d5c55b363ab7406301acdfe04a6c Mon Sep 17 00:00:00 2001 From: Yeonu-Kim Date: Sun, 7 Jun 2026 21:42:38 +0900 Subject: [PATCH 6/7] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20apis.ts=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EC=97=90=20Query=20type=EC=9D=84=20=EC=A7=81=EC=A0=91?= =?UTF-8?q?=20=EC=82=BD=EC=9E=85=ED=95=9C=20=EA=B2=BD=EC=9A=B0=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/apis/local-server/apis.ts | 137 ++++++++++++++++-- .../api/apis/local-server/schemas.ts | 117 +++++++++++++++ 2 files changed, 244 insertions(+), 10 deletions(-) diff --git a/src/infrastructure/api/apis/local-server/apis.ts b/src/infrastructure/api/apis/local-server/apis.ts index 7c366cc..f4c5251 100644 --- a/src/infrastructure/api/apis/local-server/apis.ts +++ b/src/infrastructure/api/apis/local-server/apis.ts @@ -7,31 +7,38 @@ import type { import { encodeQueryParams } from '../encode-query-params'; import type { ApplicationCreateRequest, + ApplicationIdQuery, + ApplicationListResponse, + ApplicationSubmissionRequest, + ApplicationSummaryListResponse, + ChangePasswordRequest, + DepositRequest, + DepositResponse, EmailRequest, EmailValidateRequest, + EventApplicationListQuery, + EventCreateRequest, EventDetailResponse, + EventIdQuery, + EventListResponse, + EventResponse, + ResetPasswordRequest, S3UploadRequest, S3UploadResponse, SignInRequest, SignUpRequest, + StoreCreateRequest, + StoreCreateResponse, StoreDetailResponse, StoreEventListResponse, + StoreIdQuery, + StoreListQuery, StoreListWithEventsResponse, - StoreType, TokenResponse, UserWithAccessTokenResponse, VerificationTokenResponse, } from './schemas'; -type StoreListQuery = { - category?: StoreType; - name?: string; - page?: string; - size?: string; -}; - -type StoreIdQuery = { storeId: string }; - type GetApisProps = { callWithToken: ( p: InternalCallParams & { token: string } @@ -137,4 +144,114 @@ export const getLocalServerApis = ({ path: 'api/applications', body, }), + + // Event + 'POST /api/event': ({ body, token }: { body: EventCreateRequest; token: string }) => + callWithToken>({ + method: 'POST', + path: 'api/event', + body, + token, + }), + 'GET /api/event/owner': ({ token }: { token: string }) => + callWithToken>({ + method: 'GET', + path: 'api/event/owner', + token, + }), + 'DELETE /api/event/:eventId': ({ query, token }: { query: EventIdQuery; token: string }) => + callWithToken>({ + method: 'DELETE', + path: `api/event/${query.eventId}`, + token, + }), + 'GET /api/event/:eventId/applications': ({ + query, + token, + }: { + query: EventApplicationListQuery; + token: string; + }) => { + const { eventId, ...rest } = query; + const qs = Object.keys(rest).length > 0 ? encodeQueryParams({ params: rest }) : ''; + return callWithToken>({ + method: 'GET', + path: qs ? `api/event/${eventId}/applications?${qs}` : `api/event/${eventId}/applications`, + token, + }); + }, + + // Store + 'POST /api/store': ({ body, token }: { body: StoreCreateRequest; token: string }) => + callWithToken>({ + method: 'POST', + path: 'api/store', + body, + token, + }), + 'DELETE /api/store/:storeId': ({ query, token }: { query: StoreIdQuery; token: string }) => + callWithToken>({ + method: 'DELETE', + path: `api/store/${query.storeId}`, + token, + }), + + // Application + 'GET /api/application': ({ token }: { token: string }) => + callWithToken>({ + method: 'GET', + path: 'api/application', + token, + }), + 'DELETE /api/application/:applicationId': ({ + query, + token, + }: { + query: ApplicationIdQuery; + token: string; + }) => + callWithToken>({ + method: 'DELETE', + path: `api/application/${query.applicationId}`, + token, + }), + 'POST /api/application/:applicationId/submission': ({ + query, + body, + token, + }: { + query: ApplicationIdQuery; + body: ApplicationSubmissionRequest; + token: string; + }) => + callWithToken>({ + method: 'POST', + path: `api/application/${query.applicationId}/submission`, + body, + token, + }), + + // Auth - password + 'PATCH /api/auth/password': ({ body, token }: { body: ChangePasswordRequest; token: string }) => + callWithToken>({ + method: 'PATCH', + path: 'api/auth/password', + body, + token, + }), + 'POST /api/auth/password': ({ body }: { body: ResetPasswordRequest }) => + callWithoutToken>({ + method: 'POST', + path: 'api/auth/password', + body, + }), + + // Deposit + 'POST /api/deposit': ({ body, token }: { body: DepositRequest; token: string }) => + callWithToken>({ + method: 'POST', + path: 'api/deposit', + body, + token, + }), }) satisfies Record; diff --git a/src/infrastructure/api/apis/local-server/schemas.ts b/src/infrastructure/api/apis/local-server/schemas.ts index 3f801c6..19db198 100644 --- a/src/infrastructure/api/apis/local-server/schemas.ts +++ b/src/infrastructure/api/apis/local-server/schemas.ts @@ -102,3 +102,120 @@ export type ApplicationCreateRequest = { walletAddress: string; imageKey: string; }; + +export type ApplicationStatus = 'PENDING' | 'APPROVED' | 'REJECTED'; + +type ReviewSubmissionDTO = { + id: string; + message: string; + reviewImages: string[]; +}; + +type ApplicationItemResponse = { + id: string; + eventId: string; + status: ApplicationStatus; + reviewSubmission?: ReviewSubmissionDTO; + appliedAt: string; +}; + +export type ApplicationListResponse = { + applications: ApplicationItemResponse[]; + totalCount: number; + currentPage: number; + totalPages: number; + hasNext: boolean; +}; + +type ApplicationSummaryResponse = { + id: string; + reviewerId: string; + reviewerName: string; + status: ApplicationStatus; + appliedAt: string; + hasSubmission: boolean; +}; + +export type ApplicationSummaryListResponse = { + applications: ApplicationSummaryResponse[]; + totalCount: number; + currentPage: number; + totalPages: number; + hasNext: boolean; +}; + +export type ApplicationSubmissionRequest = { + imageList: string[]; + comment: string; +}; + +export type EventCreateRequest = { + title: string; + condition: string; + reward: number; +}; + +export type EventResponse = { + id: string; + title: string; + condition: string; + reward: number; + isActive: boolean; +}; + +export type EventListResponse = { + events: EventResponse[]; +}; + +export type ChangePasswordRequest = { + oldPassword: string; + newPassword: string; +}; + +export type ResetPasswordRequest = { + email: string; +}; + +export type StoreCreateRequest = { + name: string; + address: string; + category: StoreType; + thumbnailUrl?: string; + description?: string; +}; + +export type StoreCreateResponse = { + id: string; + name: string; + address: string; + category: StoreType; + thumbnailKey?: string; + description?: string; +}; + +export type DepositRequest = { + amount: number; +}; + +export type DepositResponse = { + balance: number; + depositedAt: string; +}; + +export type StoreListQuery = { + category?: StoreType; + name?: string; + page?: string; + size?: string; +}; + +export type StoreIdQuery = { storeId: string }; +export type EventIdQuery = { eventId: string }; +export type ApplicationIdQuery = { applicationId: string }; + +export type EventApplicationListQuery = { + eventId: string; + status?: 'pending' | 'approved' | 'rejected'; + page?: string; + size?: string; +}; From 8e6ff659374e7dda4b8ea009886d96fba1871adb Mon Sep 17 00:00:00 2001 From: Yeonu-Kim Date: Sun, 7 Jun 2026 21:45:15 +0900 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=8E=A8=20=ED=8F=AC=EB=A7=A4=ED=8C=85?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/apis/local-server/apis.ts | 55 ++++++++++++++++--- 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/src/infrastructure/api/apis/local-server/apis.ts b/src/infrastructure/api/apis/local-server/apis.ts index f4c5251..28e9cc2 100644 --- a/src/infrastructure/api/apis/local-server/apis.ts +++ b/src/infrastructure/api/apis/local-server/apis.ts @@ -146,7 +146,13 @@ export const getLocalServerApis = ({ }), // Event - 'POST /api/event': ({ body, token }: { body: EventCreateRequest; token: string }) => + 'POST /api/event': ({ + body, + token, + }: { + body: EventCreateRequest; + token: string; + }) => callWithToken>({ method: 'POST', path: 'api/event', @@ -159,7 +165,13 @@ export const getLocalServerApis = ({ path: 'api/event/owner', token, }), - 'DELETE /api/event/:eventId': ({ query, token }: { query: EventIdQuery; token: string }) => + 'DELETE /api/event/:eventId': ({ + query, + token, + }: { + query: EventIdQuery; + token: string; + }) => callWithToken>({ method: 'DELETE', path: `api/event/${query.eventId}`, @@ -173,23 +185,38 @@ export const getLocalServerApis = ({ token: string; }) => { const { eventId, ...rest } = query; - const qs = Object.keys(rest).length > 0 ? encodeQueryParams({ params: rest }) : ''; + const qs = + Object.keys(rest).length > 0 ? encodeQueryParams({ params: rest }) : ''; return callWithToken>({ method: 'GET', - path: qs ? `api/event/${eventId}/applications?${qs}` : `api/event/${eventId}/applications`, + path: qs + ? `api/event/${eventId}/applications?${qs}` + : `api/event/${eventId}/applications`, token, }); }, // Store - 'POST /api/store': ({ body, token }: { body: StoreCreateRequest; token: string }) => + 'POST /api/store': ({ + body, + token, + }: { + body: StoreCreateRequest; + token: string; + }) => callWithToken>({ method: 'POST', path: 'api/store', body, token, }), - 'DELETE /api/store/:storeId': ({ query, token }: { query: StoreIdQuery; token: string }) => + 'DELETE /api/store/:storeId': ({ + query, + token, + }: { + query: StoreIdQuery; + token: string; + }) => callWithToken>({ method: 'DELETE', path: `api/store/${query.storeId}`, @@ -232,7 +259,13 @@ export const getLocalServerApis = ({ }), // Auth - password - 'PATCH /api/auth/password': ({ body, token }: { body: ChangePasswordRequest; token: string }) => + 'PATCH /api/auth/password': ({ + body, + token, + }: { + body: ChangePasswordRequest; + token: string; + }) => callWithToken>({ method: 'PATCH', path: 'api/auth/password', @@ -247,7 +280,13 @@ export const getLocalServerApis = ({ }), // Deposit - 'POST /api/deposit': ({ body, token }: { body: DepositRequest; token: string }) => + 'POST /api/deposit': ({ + body, + token, + }: { + body: DepositRequest; + token: string; + }) => callWithToken>({ method: 'POST', path: 'api/deposit',