diff --git a/.gitignore b/.gitignore index a547bf3..438657a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +.env # Editor directories and files .vscode/* diff --git a/src/apis/client/interceptors.tsx b/src/apis/client/interceptors.tsx new file mode 100644 index 0000000..2e49df7 --- /dev/null +++ b/src/apis/client/interceptors.tsx @@ -0,0 +1,13 @@ +import type { InternalAxiosRequestConfig } from "axios"; + +export const requestInterceptor = ( + config: InternalAxiosRequestConfig, +): InternalAxiosRequestConfig => { + const token: string | null = localStorage.getItem("token"); + + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + + return config; +}; diff --git a/src/apis/client/requestor.tsx b/src/apis/client/requestor.tsx new file mode 100644 index 0000000..5bd804c --- /dev/null +++ b/src/apis/client/requestor.tsx @@ -0,0 +1,18 @@ +import axios, { AxiosError, AxiosInstance } from "axios"; + +import { requestInterceptor } from "./interceptors"; + +const requestor: AxiosInstance = axios.create({ + baseURL: (import.meta.env.VITE_API_ENDPOINT as string).trim(), + timeout: 60000, + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, +}); + +requestor.interceptors.request.use(requestInterceptor, (error: AxiosError) => { + return Promise.reject(error); +}); + +export default requestor; diff --git a/src/apis/index.ts b/src/apis/index.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/apis/services/alertService.tsx b/src/apis/services/alertService.tsx new file mode 100644 index 0000000..2ee0237 --- /dev/null +++ b/src/apis/services/alertService.tsx @@ -0,0 +1,23 @@ +import type { AxiosResponse } from "axios"; +import { AlertListResponse, AlertReadListResponse } from "src/types"; + +import requestor from "../client/requestor"; + +/* 유저의 알림 목록 조회 */ +export const getAlerts = ( + userId: string, + offset: number = 0, + limit?: number, +): Promise> => { + return requestor.get(`/users/${userId}/alerts`, { + params: { offset, limit }, + }); +}; + +/* 알림 읽음 처리 */ +export const putAlert = ( + userId: string, + alertId: string, +): Promise> => { + return requestor.put(`/users/${userId}/alerts/${alertId}`); +}; diff --git a/src/apis/services/applicationService.tsx b/src/apis/services/applicationService.tsx new file mode 100644 index 0000000..f1b5e7c --- /dev/null +++ b/src/apis/services/applicationService.tsx @@ -0,0 +1,53 @@ +import type { AxiosResponse } from "axios"; +import { + ApplicationListResponse, + ApplicationResponse, + ApplicationStatus, + UserApplicationListResponse, +} from "src/types"; + +import requestor from "../client/requestor"; + +/* 가게의 특정 공고의 지원 목록 조회 */ +export const getShopApplications = ( + shopId: string, + noticeId: string, + offset?: number, + limit?: number, +): Promise> => { + return requestor.get(`/shops/${shopId}/notices/${noticeId}/applications`, { + params: { offset, limit }, + }); +}; + +/* 가게의 특정 공고 지원 등록 */ +export const postApplication = ( + shopId: string, + noticeId: string, +): Promise> => { + return requestor.post(`/shops/${shopId}/notices/${noticeId}/applications`); +}; + +/* 가게의 특정 공고 지원 승인, 거절 또는 취소소 */ +export const putApplication = ( + shopId: string, + noticeId: string, + applicationId: string, + status: Exclude, +): Promise> => { + return requestor.put( + `/shops/${shopId}/notices/${noticeId}/applications/${applicationId}`, + { status }, + ); +}; + +/* 유저의 지원 목록 조회 */ +export const getUserApplications = ( + userId: string, + offset?: number, + limit?: number, +): Promise> => { + return requestor.get(`/users/${userId}/applications`, { + params: { offset, limit }, + }); +}; diff --git a/src/apis/services/authenticationService.tsx b/src/apis/services/authenticationService.tsx new file mode 100644 index 0000000..1a41b43 --- /dev/null +++ b/src/apis/services/authenticationService.tsx @@ -0,0 +1,44 @@ +import type { AxiosResponse } from "axios"; +import type { ApiWithHref, UserItem } from "src/types"; +import { ApiWrapper } from "src/types"; + +import requestor from "../client/requestor"; + +interface LoginItem { + token: string; + user: ApiWithHref; +} + +export type LoginRequest = { email: string; password: string }; +export type LoginResponse = ApiWrapper; + +/* 로그인 */ +export const postAuthentication = async ( + payload: LoginRequest, +): Promise> => { + const res = await requestor.post("/token", payload); + + const token = res.data.item.token; + const user = res.data.item.user.item; + + localStorage.setItem("token", token); + localStorage.setItem("user", JSON.stringify(user)); + + return res; +}; + +/* 로그아웃 */ +export const logout = () => { + localStorage.removeItem("token"); + localStorage.removeItem("user"); +}; + +/* 토큰 반환 */ +export const getToken = () => { + return localStorage.getItem("token"); +}; + +/* 인증 여부 확인 */ +export const isAuthenticated = () => { + return Boolean(getToken()); +}; diff --git a/src/apis/services/imageService.tsx b/src/apis/services/imageService.tsx new file mode 100644 index 0000000..4f39ef2 --- /dev/null +++ b/src/apis/services/imageService.tsx @@ -0,0 +1,38 @@ +import axios, { AxiosResponse } from "axios"; +import { ApiWrapper } from "src/types"; + +import requestor from "../client/requestor"; + +interface PresignedItem { + url: string; +} +type PresignedResponse = ApiWrapper; + +/* Presigned URL 생성 */ +export const postImage = async (name: string): Promise => { + const res: AxiosResponse = await requestor.post( + "/images", + { name }, + ); + return res.data.item.url; +}; + +/* S3로 이미지 업로드 */ +export const putImage = async ( + presignedURL: string, + file: File | Blob, +): Promise> => { + return axios.put(presignedURL, file, { + headers: { "Content-Type": file.type || "application/octet-stream" }, + }); +}; + +/* Presigned URL 조회 */ +export const getPublicURL = (presignedURL: string) => { + return presignedURL.split("?")[0]; +}; + +/* 이미지 조회 */ +export const getImage = (publicURL: string): Promise> => { + return axios.get(publicURL, { responseType: "blob" }); +}; diff --git a/src/apis/services/noticeService.tsx b/src/apis/services/noticeService.tsx new file mode 100644 index 0000000..5a63f60 --- /dev/null +++ b/src/apis/services/noticeService.tsx @@ -0,0 +1,52 @@ +import type { AxiosResponse } from "axios"; +import { + NoticeResponse, + NoticePayload, + GetNoticesParams, + NoticeListResponseWithoutUserApplication, +} from "src/types"; + +import requestor from "../client/requestor"; + +/* 공고 조회 */ +export const getNotices = ( + params?: GetNoticesParams, +): Promise> => { + return requestor.get("/notices", { params }); +}; + +/* 가게의 공고 목록 조회 */ +export const getShopNotices = ( + shopId: string, + offset?: number, + limit?: number, +): Promise> => { + return requestor.get(`/shops/${shopId}/notices`, { + params: { offset, limit }, + }); +}; + +/* 가게 공고 등록 */ +export const postNotice = ( + shopId: string, + payload: NoticePayload, +): Promise> => { + return requestor.post(`/shops/${shopId}/notices`, payload); +}; + +/* 가게의 특정 공고 조회 */ +export const getNotice = ( + shopId: string, + noticeId: string, +): Promise> => { + return requestor.get(`/shops/${shopId}/notices/${noticeId}`); +}; + +/* 가게의 특정 공고 수정 */ +export const putNotice = ( + shopId: string, + noticeId: string, + payload: NoticePayload, +): Promise> => { + return requestor.put(`/shops/${shopId}/notices/${noticeId}`, payload); +}; diff --git a/src/apis/services/shopService.tsx b/src/apis/services/shopService.tsx new file mode 100644 index 0000000..e5a5179 --- /dev/null +++ b/src/apis/services/shopService.tsx @@ -0,0 +1,26 @@ +import type { AxiosResponse } from "axios"; +import { ShopPayload, ShopResponse } from "src/types"; + +import requestor from "../client/requestor"; + +/* 가게 등록 */ +export const postShop = ( + payload: ShopPayload, +): Promise> => { + return requestor.post("/shops", payload); +}; + +/* 가게 정보 조회 */ +export const getShop = ( + shopId: string, +): Promise> => { + return requestor.get(`/shops/${shopId}`); +}; + +/* 가게 정보 수정 */ +export const putShop = ( + shopId: string, + payload: ShopPayload, +): Promise> => { + return requestor.put(`/shops/${shopId}`, payload); +}; diff --git a/src/apis/services/userService.tsx b/src/apis/services/userService.tsx new file mode 100644 index 0000000..c3b115a --- /dev/null +++ b/src/apis/services/userService.tsx @@ -0,0 +1,31 @@ +import type { AxiosResponse } from "axios"; +import { + SignupPayload, + SignupResponse, + UserResponse, + UpdateUserPayload, +} from "src/types"; + +import requestor from "../client/requestor"; + +/* 회원가입 */ +export const postUser = ( + payload: SignupPayload, +): Promise> => { + return requestor.post("/users", payload); +}; + +/* 내 정보 조회 */ +export const getUser = ( + userId: string, +): Promise> => { + return requestor.get(`/users/${userId}`); +}; + +/* 내 정보 수정 */ +export const putUser = ( + userId: string, + payload: UpdateUserPayload, +): Promise> => { + return requestor.put(`/users/${userId}`, payload); +}; diff --git a/src/types/alert.ts b/src/types/alert.ts new file mode 100644 index 0000000..7acb1d5 --- /dev/null +++ b/src/types/alert.ts @@ -0,0 +1,30 @@ +import type { ApplicationStatus } from "./application"; +import type { ApiWrapper, ApiPaged, ApiWithHref, LinkItem } from "./common"; +import type { NoticeSummary } from "./notice"; +import type { ShopSummary } from "./shop"; + +export type AlertResult = "accepted" | "rejected"; + +export interface ApplicationMini { + id: string; + status: Exclude; +} + +export interface AlertItem { + id: string; + createdAt: string; + result: AlertResult; + read: boolean; + application: ApiWithHref; + shop: ApiWithHref; + notice: ApiWithHref; +} + +export type AlertResponse = ApiWrapper; +export type AlertListResponse = ApiPaged; +export interface AlertReadListResponse { + offset: number; + limit: number; + items: ApiWrapper[]; + links: LinkItem[]; +} diff --git a/src/types/application.ts b/src/types/application.ts new file mode 100644 index 0000000..ee9118f --- /dev/null +++ b/src/types/application.ts @@ -0,0 +1,26 @@ +import type { ApiWrapper, ApiPaged, ApiWithHref } from "./common"; +import type { NoticeSummary } from "./notice"; +import type { ShopSummary } from "./shop"; +import type { UserSummary } from "./user"; + +export type ApplicationStatus = + | "pending" + | "accepted" + | "rejected" + | "canceled"; + +export interface ApplicationItem { + id: string; + status: ApplicationStatus; + createdAt: string; + user: ApiWithHref; + shop: ApiWithHref; + notice: ApiWithHref; +} + +export type UserApplicationList = Omit; + +export type ApplicationResponse = ApiWrapper; +export type ApplicationListResponse = ApiPaged; + +export type UserApplicationListResponse = ApiPaged; diff --git a/src/types/common.ts b/src/types/common.ts new file mode 100644 index 0000000..28d39f5 --- /dev/null +++ b/src/types/common.ts @@ -0,0 +1,66 @@ +export interface LinkItem { + rel: string; + description: string; + method: string; + href: string; +} + +export interface ApiWrapper { + item: T; + links: LinkItem[]; +} + +export interface ApiWithHref { + item: T; + href: string; +} + +export interface ApiPaged { + offset: number; + limit: number; + count: number; + hasNext: boolean; + items: ApiWrapper[]; + links: LinkItem[]; +} + +export const SeoulDistricts = [ + "서울시 종로구", + "서울시 중구", + "서울시 용산구", + "서울시 성동구", + "서울시 광진구", + "서울시 동대문구", + "서울시 중랑구", + "서울시 성북구", + "서울시 강북구", + "서울시 도봉구", + "서울시 노원구", + "서울시 은평구", + "서울시 서대문구", + "서울시 마포구", + "서울시 양천구", + "서울시 강서구", + "서울시 구로구", + "서울시 금천구", + "서울시 영등포구", + "서울시 동작구", + "서울시 관악구", + "서울시 서초구", + "서울시 강남구", + "서울시 송파구", + "서울시 강동구", +] as const; +export type SeoulDistrict = (typeof SeoulDistricts)[number]; + +export const ShopCategories = [ + "한식", + "중식", + "일식", + "양식", + "분식", + "카페", + "편의점", + "기타", +] as const; +export type ShopCategory = (typeof ShopCategories)[number]; diff --git a/src/types/index.ts b/src/types/index.ts index e69de29..d3c1ade 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -0,0 +1,6 @@ +export * from "./common"; +export * from "./user"; +export * from "./shop"; +export * from "./notice"; +export * from "./application"; +export * from "./alert"; diff --git a/src/types/notice.ts b/src/types/notice.ts new file mode 100644 index 0000000..f79100f --- /dev/null +++ b/src/types/notice.ts @@ -0,0 +1,63 @@ +import type { ApiPaged, ApiWithHref, ApiWrapper } from "./common"; +import type { ShopSummary } from "./shop"; + +export type SortKey = + | "time" /* 마감임박순 */ + | "pay" /* 시급많은순 */ + | "hour" /* 시간적은순 */ + | "shop"; /* 가나다순 */ + +export interface NoticeListMeta { + address: string[]; + keyword?: string; +} + +export interface NoticeItem { + id: string; + hourlyPay: number; + startsAt: string; + workhour: number; + description: string; + closed: boolean; + shop?: ApiWithHref; + currentUserApplication?: { + item: { + id: string; + status: "pending" | "accepted" | "rejected" | "canceled"; + createdAt: string; + }; + }; +} + +export interface GetNoticesParams { + offset?: number; + limit?: number; + address?: string; + keyword?: string; + startsAtGte?: string; + hourlyPayGte?: number; + sort?: SortKey; +} + +export interface NoticePayload { + hourlyPay: number; + startsAt: string; + workhour: number; + description: string; +} + +export type NoticeSummary = Pick< + NoticeItem, + "id" | "hourlyPay" | "description" | "startsAt" | "workhour" | "closed" +>; + +export type NoticeResponse = ApiWrapper; +export type NoticeListResponse = ApiPaged; +export type NoticeWithoutUserApplication = Omit< + NoticeItem, + "currentUserApplication" +>; + +export interface NoticeListResponseWithoutUserApplication + extends ApiPaged, + NoticeListMeta {} diff --git a/src/types/shop.ts b/src/types/shop.ts new file mode 100644 index 0000000..9533482 --- /dev/null +++ b/src/types/shop.ts @@ -0,0 +1,32 @@ +import type { + ApiWithHref, + ApiWrapper, + SeoulDistrict, + ShopCategory, +} from "./common"; +import type { UserSummary } from "./user"; + +export interface ShopSummary { + id: string; + name: string; + category: ShopCategory; + address1: SeoulDistrict; + address2: string; + description: string; + imageUrl: string; + originalHourlyPay: number; +} + +export type ShopItem = ShopSummary & { user?: ApiWithHref }; + +export interface ShopPayload { + name: string; + category: ShopCategory; + address1: SeoulDistrict; + address2: string; + description: string; + imageUrl: string; + originalHourlyPay: number; +} + +export type ShopResponse = ApiWrapper; diff --git a/src/types/user.ts b/src/types/user.ts new file mode 100644 index 0000000..688e54a --- /dev/null +++ b/src/types/user.ts @@ -0,0 +1,39 @@ +import type { ApiWrapper } from "./common"; +import type { SeoulDistrict } from "./common"; +import { ShopSummary } from "./shop"; + +export type UserType = "employee" | "employer"; + +export interface UserItem { + id: string; + email: string; + type: UserType; + name?: string; + phone?: string; + address?: SeoulDistrict; + bio?: string; + shop?: ApiWrapper | null; +} + +export type UserSummary = Pick< + UserItem, + "id" | "email" | "type" | "name" | "phone" | "address" | "bio" +>; + +export interface SignupPayload { + email: string; + password: string; + type: UserType; +} + +export interface UpdateUserPayload { + name?: string; + phone?: string; + address?: SeoulDistrict; + bio?: string; +} + +export type UserResponse = ApiWrapper; +export type SignupResponse = ApiWrapper< + Pick +>;