Skip to content

Commit 84f05d2

Browse files
authored
✨ 지갑 주소 입력 및 검증 파일 업로드 페이지 추가 (#40)
* 🚚 공동 api 유틸 위치로 이동 * ✨ S3(storage)와의 소통을 위한 storageApi 모듈 생성 * ✨ 지갑 주소 입력 및 검증 파일 업로드 페이지 추가 * ✨ 로그인하지 않아도 인증 사진을 올릴 수 있도록 설정 * 🧪 submit form을 위한 성공/실패 테스트 추가 * ♻️ apis.ts 파일에 Query type을 직접 삽입한 경우 정리 * 🎨 포매팅 적용
1 parent ac5a722 commit 84f05d2

33 files changed

Lines changed: 1583 additions & 33 deletions

src/App.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
22
import { useState } from 'react';
3+
import { useApplicationQuery } from '@/feature/application/application/application-query';
4+
import { implApplicationUsecase } from '@/feature/application/usecase/application-usecase';
35
import { useAuthQuery } from '@/feature/auth/application/auth-query';
46
import { implAuthUsecase } from '@/feature/auth/usecase/auth-usecase';
7+
import { implFileUsecase } from '@/feature/file/usecase/file-usecase';
58
import { QueryContext } from '@/feature/shared/context/query-context';
69
import { ReviewContext } from '@/feature/shared/context/review-context';
710
import { TokenContext } from '@/feature/shared/context/token-context';
@@ -10,6 +13,8 @@ import { RouterProvider } from '@/feature/shared/routes/router-provider';
1013
import { useStoreQuery } from '@/feature/store/application/store-query';
1114
import { implStoreUsecase } from '@/feature/store/usecase/store-usecase';
1215
import { externalCall, implApi } from '@/infrastructure/api';
16+
import { implStorageApi } from '@/infrastructure/api/client';
17+
import { externalStorageCall } from '@/infrastructure/api/external-call';
1318
import { implTokenRepository } from '@/infrastructure/token/token-repository';
1419

1520
const queryClient = new QueryClient({
@@ -26,14 +31,18 @@ const AppContent = () => {
2631
const [storeId, setStoreId] = useState<string | null>(null);
2732
const [eventId, setEventId] = useState<string | null>(null);
2833
const api = implApi({ externalCall });
34+
const storageApi = implStorageApi({ externalStorageCall });
2935
const tokenRepository = implTokenRepository({ setToken });
3036
const authUsecase = implAuthUsecase({ api, tokenRepository });
3137
const authQuery = useAuthQuery({ authUsecase });
3238
const storeUsecase = implStoreUsecase({ api });
3339
const storeQuery = useStoreQuery({ storeUsecase });
40+
const fileUsecase = implFileUsecase({ api, storageApi });
41+
const applicationUsecase = implApplicationUsecase({ api, fileUsecase });
42+
const applicationQuery = useApplicationQuery({ applicationUsecase });
3443

3544
return (
36-
<QueryContext.Provider value={{ authQuery, storeQuery }}>
45+
<QueryContext.Provider value={{ authQuery, storeQuery, applicationQuery }}>
3746
<TokenContext.Provider value={{ token }}>
3847
<UsecaseContext.Provider value={{ authUsecase }}>
3948
<ReviewContext.Provider

src/entities/file.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export type FileType = 'REVIEW' | 'STORE';
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { useMutation, useQuery } from '@tanstack/react-query';
2+
import { createErrorMessage } from '@/feature/shared/error/create-error-message';
3+
import type {
4+
EventDetailResponse,
5+
StoreDetailResponse,
6+
} from '@/infrastructure/api/apis/local-server/schemas';
7+
import type { ApplicationUsecase } from '../usecase/application-usecase';
8+
9+
export type ApplicationQuery = ReturnType<typeof useApplicationQuery>;
10+
11+
export const useApplicationQuery = ({
12+
applicationUsecase,
13+
}: {
14+
applicationUsecase: ApplicationUsecase;
15+
}) => {
16+
return {
17+
useGetStore: ({ storeId }: { storeId: string }) => {
18+
const { data, isLoading } = useQuery({
19+
queryKey: ['store', storeId],
20+
queryFn: async (): Promise<StoreDetailResponse> => {
21+
const response = await applicationUsecase.getStore({ storeId });
22+
if (response.type === 'success') {
23+
return response.data;
24+
}
25+
throw new Error(response.message);
26+
},
27+
});
28+
return { store: data, isLoading };
29+
},
30+
31+
useGetEvent: ({ eventId }: { eventId: string }) => {
32+
const { data, isLoading } = useQuery({
33+
queryKey: ['event', eventId],
34+
queryFn: async (): Promise<EventDetailResponse> => {
35+
const response = await applicationUsecase.getEvent({ eventId });
36+
if (response.type === 'success') {
37+
return response.data;
38+
}
39+
throw new Error(response.message);
40+
},
41+
});
42+
return { event: data, isLoading };
43+
},
44+
45+
useSubmitApplication: ({
46+
setResponseMessage,
47+
onSuccess,
48+
}: {
49+
setResponseMessage: (message: string) => void;
50+
onSuccess: () => void;
51+
}) => {
52+
const { mutate: submitApplication, isPending } = useMutation({
53+
mutationFn: async ({
54+
eventId,
55+
walletAddress,
56+
imageFile,
57+
}: {
58+
eventId: string;
59+
walletAddress: string;
60+
imageFile: File;
61+
}) => {
62+
return await applicationUsecase.submitApplication({
63+
eventId,
64+
walletAddress,
65+
imageFile,
66+
});
67+
},
68+
onSuccess: (response) => {
69+
if (response.type === 'success') {
70+
onSuccess();
71+
return;
72+
}
73+
setResponseMessage(
74+
createErrorMessage(
75+
response.code,
76+
'제출에 실패했습니다. 다시 시도해 주세요.'
77+
)
78+
);
79+
},
80+
});
81+
return { submitApplication, isPending };
82+
},
83+
};
84+
};
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { Input, InputForForm } from '@/entities/input';
2+
import type { ApplicationInputPresenter } from './application-input-presenter';
3+
4+
type ApplicationFormPresenter = {
5+
useValidator({
6+
applicationInputPresenter,
7+
}: {
8+
applicationInputPresenter: ApplicationInputPresenter;
9+
}): {
10+
inputStates: {
11+
walletAddress: Input<string>;
12+
imageFile: Input<File | null>;
13+
};
14+
formStates: {
15+
walletAddress: InputForForm<string>;
16+
imageFile: InputForForm<File | null>;
17+
};
18+
};
19+
};
20+
21+
export const applicationFormPresenter: ApplicationFormPresenter = {
22+
useValidator: ({ applicationInputPresenter }) => {
23+
const { walletAddress, imageFile } =
24+
applicationInputPresenter.useValidator();
25+
return {
26+
inputStates: { walletAddress, imageFile },
27+
formStates: { walletAddress, imageFile },
28+
};
29+
},
30+
};
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { useState } from 'react';
2+
import type { Input } from '@/entities/input';
3+
4+
const ETHEREUM_ADDRESS_REGEX = /^0x[0-9a-fA-F]{40}$/;
5+
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
6+
const MAX_FILE_SIZE = 10 * 1024 * 1024;
7+
8+
export type ApplicationInputPresenter = {
9+
useValidator(): {
10+
walletAddress: Input<string>;
11+
imageFile: Input<File | null>;
12+
};
13+
};
14+
15+
export const applicationInputPresenter: ApplicationInputPresenter = {
16+
useValidator: () => {
17+
const [walletAddress, setWalletAddress] = useState('');
18+
const [imageFile, setImageFile] = useState<File | null>(null);
19+
20+
return {
21+
walletAddress: {
22+
value: walletAddress,
23+
isError: !ETHEREUM_ADDRESS_REGEX.test(walletAddress),
24+
onChange: setWalletAddress,
25+
},
26+
imageFile: {
27+
value: imageFile,
28+
isError:
29+
imageFile === null ||
30+
!ALLOWED_IMAGE_TYPES.includes(imageFile.type) ||
31+
imageFile.size > MAX_FILE_SIZE,
32+
onChange: setImageFile,
33+
},
34+
};
35+
},
36+
};
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type { UsecaseResponse } from '@/feature/shared/response';
2+
import type { Apis } from '@/infrastructure/api';
3+
import type {
4+
EventDetailResponse,
5+
StoreDetailResponse,
6+
} from '@/infrastructure/api/apis/local-server/schemas';
7+
import type { FileUsecase } from '../../file/usecase/file-usecase';
8+
9+
export type ApplicationUsecase = {
10+
getStore(params: { storeId: string }): UsecaseResponse<StoreDetailResponse>;
11+
getEvent(params: { eventId: string }): UsecaseResponse<EventDetailResponse>;
12+
submitApplication(params: {
13+
eventId: string;
14+
walletAddress: string;
15+
imageFile: File;
16+
}): UsecaseResponse<null>;
17+
};
18+
19+
export const implApplicationUsecase = ({
20+
api,
21+
fileUsecase,
22+
}: {
23+
api: Apis;
24+
fileUsecase: FileUsecase;
25+
}): ApplicationUsecase => ({
26+
getStore: async ({ storeId }) => {
27+
const { status, data } = await api['GET /api/store/:storeId']({
28+
query: { storeId },
29+
});
30+
if (status === 200) {
31+
return { type: 'success', data: data as StoreDetailResponse };
32+
}
33+
const err = data as { code: string; message: string };
34+
return { type: 'error', code: err.code, message: err.message };
35+
},
36+
37+
getEvent: async ({ eventId }) => {
38+
const { status, data } = await api['GET /api/event/:eventId']({
39+
query: { eventId },
40+
});
41+
if (status === 200) {
42+
return { type: 'success', data: data as EventDetailResponse };
43+
}
44+
const err = data as { code: string; message: string };
45+
return { type: 'error', code: err.code, message: err.message };
46+
},
47+
48+
submitApplication: async ({ eventId, walletAddress, imageFile }) => {
49+
const presignedResp = await fileUsecase.getUploadPresignedUrl({
50+
fileName: imageFile.name,
51+
fileType: 'REVIEW',
52+
});
53+
if (presignedResp.type === 'error') {
54+
return presignedResp;
55+
}
56+
57+
const uploadResp = await fileUsecase.uploadFile({
58+
presignedUrl: presignedResp.data.url,
59+
file: imageFile,
60+
});
61+
if (uploadResp.type === 'error') {
62+
return uploadResp;
63+
}
64+
65+
const { status, data } = await api['POST /api/applications']({
66+
body: { eventId, walletAddress, imageKey: presignedResp.data.s3Key },
67+
});
68+
if (status === 200) {
69+
return { type: 'success', data: null };
70+
}
71+
const err = data as { code: string; message: string };
72+
return { type: 'error', code: err.code, message: err.message };
73+
},
74+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { FileType } from '@/entities/file';
2+
import type { UsecaseResponse } from '@/feature/shared/response';
3+
import type { Apis } from '@/infrastructure/api';
4+
import type { S3UploadResponse } from '@/infrastructure/api/apis/local-server/schemas';
5+
import type { StorageApis } from '@/infrastructure/api/client';
6+
7+
export type FileUsecase = {
8+
getUploadPresignedUrl(params: {
9+
fileName: string;
10+
fileType: FileType;
11+
}): UsecaseResponse<S3UploadResponse>;
12+
uploadFile(params: {
13+
presignedUrl: string;
14+
file: File;
15+
}): UsecaseResponse<null>;
16+
};
17+
18+
export const implFileUsecase = ({
19+
api,
20+
storageApi,
21+
}: {
22+
api: Apis;
23+
storageApi: StorageApis;
24+
}): FileUsecase => ({
25+
getUploadPresignedUrl: async ({ fileName, fileType }) => {
26+
const { status, data } = await api['POST /api/s3']({
27+
body: { fileName, fileType },
28+
});
29+
if (status === 200) {
30+
return { type: 'success', data: data as S3UploadResponse };
31+
}
32+
const err = data as { code: string; message: string };
33+
return { type: 'error', code: err.code, message: err.message };
34+
},
35+
36+
uploadFile: async ({ presignedUrl, file }) => {
37+
const { status } = await storageApi['PUT upload-file']({
38+
path: presignedUrl,
39+
body: file,
40+
contentType: file.type,
41+
});
42+
if (status === 200) {
43+
return { type: 'success', data: null };
44+
}
45+
return {
46+
type: 'error',
47+
code: 'S3_001',
48+
message: '이미지 업로드에 실패했습니다.',
49+
};
50+
},
51+
});
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { createContext } from 'react';
2+
import type { ApplicationQuery } from '@/feature/application/application/application-query';
23
import type { AuthQuery } from '@/feature/auth/application/auth-query';
34
import type { StoreQuery } from '@/feature/store/application/store-query';
45

56
export type QueryContext = {
67
authQuery: AuthQuery;
78
storeQuery: StoreQuery;
9+
applicationQuery: ApplicationQuery;
810
};
911

1012
export const QueryContext = createContext<QueryContext | null>(null);

src/feature/shared/routes/path.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@ export const PATH = {
33
SIGN_IN: '/sign-in',
44
SIGN_UP: '/sign-up',
55
SIGN_UP_COMPLETE: '/sign-up-complete',
6-
SUBMIT: '/submit',
6+
SUBMIT: '/store/:storeId/event/:eventId',
77
};

src/feature/shared/routes/router-provider.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { LandingPage } from '../../../pages/landing';
66
import { MyPage } from '../../../pages/my-page';
77
import { SignInPage } from '../../../pages/sign-in';
88
import { SignUpPage } from '../../../pages/sign-up';
9+
import { SubmitPage } from '../../../pages/submit';
910
import { PATH } from './path';
1011

1112
export const RouterProvider = () => {
@@ -15,6 +16,7 @@ export const RouterProvider = () => {
1516
<Routes>
1617
<Route path={PATH.SIGN_IN} element={<SignInPage />} />
1718
<Route path={PATH.SIGN_UP} element={<SignUpPage />} />
19+
<Route path={PATH.SUBMIT} element={<SubmitPage />} />
1820
<Route element={<ReissueToken />}>
1921
<Route path={PATH.LANDING} element={<LandingPage />} />
2022
<Route element={<ProtectedRoute role="SIGN_IN" />}>

0 commit comments

Comments
 (0)