From 9d3170e84b5587a4621507547d34cc3f7ebdbb55 Mon Sep 17 00:00:00 2001 From: nas7062 Date: Tue, 9 Jun 2026 22:13:50 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat=20:=20api=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/facades/plant.ts | 12 +- src/api/facades/seasonal-bloom.ts | 9 + src/api/facades/spot-favorite.ts | 68 +++ src/api/facades/user-follow.ts | 70 +++ src/api/generated/peakdaApi.schemas.ts | 575 +++++++++++++++++- src/api/generated/plant/plant.ts | 52 +- .../seasonal-bloom/seasonal-bloom.ts | 398 ++++++++++++ .../generated/spot-favorite/spot-favorite.ts | 400 ++++++++++++ src/api/generated/user-follow/user-follow.ts | 574 +++++++++++++++++ src/api/generated/user/user.ts | 271 ++++----- src/api/mutator/index.ts | 49 +- src/app/followers/page.tsx | 42 +- src/app/following/page.tsx | 41 +- src/app/my/_components/MyRecordSection.tsx | 7 +- src/app/my/page.tsx | 25 +- src/app/my/saved/page.tsx | 73 +-- src/app/record/[id]/edit/page.tsx | 211 +++++++ src/app/record/[id]/page.tsx | 48 ++ src/app/users/[id]/page.tsx | 19 +- src/components/ui/button/FollowButton.tsx | 20 +- src/components/ui/button/HeartBtn.tsx | 17 +- src/components/ui/card/SpotCard.tsx | 21 +- src/components/ui/list/UserRow.tsx | 14 +- src/lib/utils/spotRecordToFeed.ts | 36 +- 24 files changed, 2755 insertions(+), 297 deletions(-) create mode 100644 src/api/facades/seasonal-bloom.ts create mode 100644 src/api/facades/spot-favorite.ts create mode 100644 src/api/facades/user-follow.ts create mode 100644 src/api/generated/seasonal-bloom/seasonal-bloom.ts create mode 100644 src/api/generated/spot-favorite/spot-favorite.ts create mode 100644 src/api/generated/user-follow/user-follow.ts create mode 100644 src/app/record/[id]/edit/page.tsx create mode 100644 src/app/record/[id]/page.tsx diff --git a/src/api/facades/plant.ts b/src/api/facades/plant.ts index eab7e57..fe139c9 100644 --- a/src/api/facades/plant.ts +++ b/src/api/facades/plant.ts @@ -1,10 +1,10 @@ import { useQueryClient } from '@tanstack/react-query' import { - getListQueryKey, - list, + getList1QueryKey, + list1, search, suggest, - useList, + useList1, useSearch, useSuggest as useSuggestGen, } from '@/api/generated/plant/plant' @@ -15,7 +15,7 @@ import type { SuggestPlantRequest } from '@/api/generated/peakdaApi.schemas' // ─── plain async (이벤트 기반 호출) ─────────────────────────────────────────── export async function listPlantsApi() { - const res = await list() + const res = await list1() return res.data.data ?? null } @@ -32,7 +32,7 @@ export async function suggestPlantApi(payload: SuggestPlantRequest) { // ─── React Query hooks (캐싱 / 상태 관리) ──────────────────────────────────── export const usePlants = () => - useList({ query: { select: (res) => res.data.data ?? null } }) + useList1({ query: { select: (res) => res.data.data ?? null } }) export const useSearchPlants = (keyword: string) => useSearch( @@ -46,7 +46,7 @@ export const useSuggestPlant = () => { const queryClient = useQueryClient() return useSuggestGen({ mutation: { - onSuccess: () => queryClient.invalidateQueries({ queryKey: getListQueryKey() }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: getList1QueryKey() }), }, }) } diff --git a/src/api/facades/seasonal-bloom.ts b/src/api/facades/seasonal-bloom.ts new file mode 100644 index 0000000..d482801 --- /dev/null +++ b/src/api/facades/seasonal-bloom.ts @@ -0,0 +1,9 @@ +// AUTO-GENERATED FACADE — 초기 1회만 생성됨. 이후 수동 유지. +// TODO: 아래 import를 실제 생성된 함수명으로 교체하세요 +// import { someFunction } from "@/api/generated/seasonal-bloom/seasonal-bloom"; + +// 언래핑 규칙: res.data (Orval 래퍼) → res.data.data (백엔드 실제 payload) +// export async function exampleApi() { +// const res = await someFunction(); +// return res.data.data ?? null; +// } diff --git a/src/api/facades/spot-favorite.ts b/src/api/facades/spot-favorite.ts new file mode 100644 index 0000000..a819f32 --- /dev/null +++ b/src/api/facades/spot-favorite.ts @@ -0,0 +1,68 @@ +import { useQueryClient } from '@tanstack/react-query' +import { + add, + getListQueryKey, + list, + remove, + updateNotify, + useAdd as useAddGen, + useList, + useRemove as useRemoveGen, + useUpdateNotify as useUpdateNotifyGen, +} from '@/api/generated/spot-favorite/spot-favorite' +import type { UpdateFavoriteNotifyRequest } from '@/api/generated/peakdaApi.schemas' + +// 언래핑 규칙: res.data (Orval 래퍼) → res.data.data (백엔드 실제 payload) + +// 찜 목록 캐시 키 — mutation 성공 시 무효화 대상 +const favoriteListKey = getListQueryKey() + +// ─── plain async (이벤트 기반 호출) ─────────────────────────────────────────── + +export async function addFavoriteApi(spotId: number) { + const res = await add(spotId) + return res.data.data ?? null +} + +export async function removeFavoriteApi(spotId: number) { + await remove(spotId) +} + +export async function updateFavoriteNotifyApi(spotId: number, payload: UpdateFavoriteNotifyRequest) { + const res = await updateNotify(spotId, payload) + return res.data.data ?? null +} + +export async function favoriteListApi() { + const res = await list() + return res.data.data ?? null +} + +// ─── React Query hooks (캐싱 / 상태 관리) ──────────────────────────────────── + +export const useFavoriteList = () => + useList({ query: { select: (res) => res.data.data ?? null } }) + +// mutate({ spotId }) 형태로 호출 — 성공 시 찜 목록 캐시 무효화 + +export const useAddFavorite = () => { + const queryClient = useQueryClient() + return useAddGen({ + mutation: { onSuccess: () => queryClient.invalidateQueries({ queryKey: favoriteListKey }) }, + }) +} + +export const useRemoveFavorite = () => { + const queryClient = useQueryClient() + return useRemoveGen({ + mutation: { onSuccess: () => queryClient.invalidateQueries({ queryKey: favoriteListKey }) }, + }) +} + +// mutate({ spotId, data: { enabled } }) 형태로 호출 +export const useUpdateFavoriteNotify = () => { + const queryClient = useQueryClient() + return useUpdateNotifyGen({ + mutation: { onSuccess: () => queryClient.invalidateQueries({ queryKey: favoriteListKey }) }, + }) +} diff --git a/src/api/facades/user-follow.ts b/src/api/facades/user-follow.ts new file mode 100644 index 0000000..54e9e7f --- /dev/null +++ b/src/api/facades/user-follow.ts @@ -0,0 +1,70 @@ +import { useQueryClient } from '@tanstack/react-query' +import { + follow, + followers, + followings, + summary, + unfollow, + useFollow as useFollowGen, + useFollowers, + useFollowings, + useSummary, + useUnfollow as useUnfollowGen, +} from '@/api/generated/user-follow/user-follow' +import type { FollowersParams, FollowingsParams } from '@/api/generated/peakdaApi.schemas' + +// 언래핑 규칙: res.data (Orval 래퍼) → res.data.data (백엔드 실제 payload) + +// 팔로우 변경 시 무효화 대상 — 여러 userId 의 summary·목록이 동시에 바뀌므로 predicate 로 일괄 처리한다. +const invalidateFollow = (queryClient: ReturnType) => + queryClient.invalidateQueries({ + predicate: (q) => typeof q.queryKey[0] === 'string' && q.queryKey[0].includes('/follow'), + }) + +// ─── plain async (이벤트 기반 호출) ─────────────────────────────────────────── + +export async function followApi(userId: number) { + await follow(userId) +} + +export async function unfollowApi(userId: number) { + await unfollow(userId) +} + +export async function followingsApi(userId: number, params: FollowingsParams) { + const res = await followings(userId, params) + return res.data.data ?? null +} + +export async function followersApi(userId: number, params: FollowersParams) { + const res = await followers(userId, params) + return res.data.data ?? null +} + +export async function followSummaryApi(userId: number) { + const res = await summary(userId) + return res.data.data ?? null +} + +// ─── React Query hooks (캐싱 / 상태 관리) ──────────────────────────────────── + +export const useFollowSummary = (userId: number) => + useSummary(userId, { query: { select: (res) => res.data.data ?? null } }) + +export const useFollowingList = (userId: number, params: FollowingsParams) => + useFollowings(userId, params, { query: { select: (res) => res.data.data ?? null } }) + +export const useFollowerList = (userId: number, params: FollowersParams) => + useFollowers(userId, params, { query: { select: (res) => res.data.data ?? null } }) + +// mutate({ userId }) 형태로 호출 — 성공 시 팔로우 관련 캐시 무효화 + +export const useFollow = () => { + const queryClient = useQueryClient() + return useFollowGen({ mutation: { onSuccess: () => invalidateFollow(queryClient) } }) +} + +export const useUnfollow = () => { + const queryClient = useQueryClient() + return useUnfollowGen({ mutation: { onSuccess: () => invalidateFollow(queryClient) } }) +} diff --git a/src/api/generated/peakdaApi.schemas.ts b/src/api/generated/peakdaApi.schemas.ts index fd01937..6ac48cc 100644 --- a/src/api/generated/peakdaApi.schemas.ts +++ b/src/api/generated/peakdaApi.schemas.ts @@ -5,6 +5,23 @@ * 계절 여행 타이밍 안내 서비스 API 문서 * OpenAPI spec version: v1 */ +/** + * 공통 응답 envelope + */ +export interface ApiResponseUnit { + /** HTTP status code */ + status: number; + /** 성공 시 'SUCCESS', 실패 시 ErrorCode enum name */ + code: string; + /** 사람이 읽는 메시지 */ + message: string; + /** + * 응답 본문 (성공 시 채워지고, 에러 시 생략됨) + * @nullable + */ + data?: null; +} + /** * 프로필 이미지 업로드 multipart form */ @@ -444,6 +461,56 @@ export interface ApiResponseSpotMatchResponse { data?: SpotMatchResponse | null; } +/** + * 스팟 분류 + */ +export type SpotFavoriteResponseType = typeof SpotFavoriteResponseType[keyof typeof SpotFavoriteResponseType]; + + +export const SpotFavoriteResponseType = { + ATTRACTION: 'ATTRACTION', + LOCAL: 'LOCAL', +} as const; + +/** + * 찜한 스팟 + */ +export interface SpotFavoriteResponse { + /** 스팟 PK */ + spotId: number; + /** 스팟 분류 */ + type: SpotFavoriteResponseType; + /** 스팟 표시명 */ + name: string; + /** + * 스팟 주소 + * @nullable + */ + address?: string | null; + /** + * ATTRACTION 일 때 attraction id + * @nullable + */ + attractionId?: number | null; + /** 만개 알림 수신 여부 */ + notifyEnabled: boolean; + /** 찜한 시각 */ + favoritedAt: string; +} + +/** + * 공통 응답 envelope + */ +export interface ApiResponseSpotFavoriteResponse { + /** HTTP status code */ + status: number; + /** 성공 시 'SUCCESS', 실패 시 ErrorCode enum name */ + code: string; + /** 사람이 읽는 메시지 */ + message: string; + data?: SpotFavoriteResponse | null; +} + /** * 검색에서 찾지 못한 식물을 사용자가 추가 제안 */ @@ -530,23 +597,6 @@ export interface SignupCompleteRequest { profileImageUrl?: string | null; } -/** - * 공통 응답 envelope - */ -export interface ApiResponseUnit { - /** HTTP status code */ - status: number; - /** 성공 시 'SUCCESS', 실패 시 ErrorCode enum name */ - code: string; - /** 사람이 읽는 메시지 */ - message: string; - /** - * 응답 본문 (성공 시 채워지고, 에러 시 생략됨) - * @nullable - */ - data?: null; -} - /** * 개화/단풍 상태 교체 * @nullable @@ -598,6 +648,14 @@ export interface UpdateSpotRecordRequest { photoKeys?: string[] | null; } +/** + * 찜한 스팟 만개 알림 설정 변경 요청 + */ +export interface UpdateFavoriteNotifyRequest { + /** 만개 알림 수신 여부 */ + enabled: boolean; +} + /** * 공통 페이지 요청 (0-based) */ @@ -615,6 +673,83 @@ export interface PageRequest { size?: number; } +/** + * 팔로우 관계의 사용자 정보 (팔로워/팔로잉 목록 항목) + */ +export interface FollowUserResponse { + /** 사용자 PK */ + userId: number; + /** 닉네임 */ + nickname: string; + /** + * 프로필 이미지 URL (없으면 null) + * @nullable + */ + profileImageUrl?: string | null; + /** 현재 로그인 사용자가 이 사용자를 팔로우 중인지 여부. false 이면 '팔로우'(맞팔로우) 버튼, true 이면 '팔로잉' 버튼을 노출한다. */ + following: boolean; + /** 팔로우 관계가 생성된 시각 */ + followedAt: string; +} + +/** + * 공통 페이지 응답 (0-based) + */ +export interface PageResponseFollowUserResponse { + /** 현재 페이지의 항목 리스트 */ + content: FollowUserResponse[]; + /** 0-based 현재 페이지 */ + page: number; + /** 페이지 크기 */ + size: number; + /** 전체 항목 수 */ + totalElements: number; + /** 전체 페이지 수 */ + totalPages: number; + /** 다음 페이지 존재 여부 */ + hasNext: boolean; +} + +/** + * 공통 응답 envelope + */ +export interface ApiResponsePageResponseFollowUserResponse { + /** HTTP status code */ + status: number; + /** 성공 시 'SUCCESS', 실패 시 ErrorCode enum name */ + code: string; + /** 사람이 읽는 메시지 */ + message: string; + data?: PageResponseFollowUserResponse | null; +} + +/** + * 사용자 팔로우 통계 요약 (유저 프로필 헤더용) + */ +export interface FollowSummaryResponse { + /** 대상 사용자 PK */ + userId: number; + /** 팔로워 수 (이 사용자를 팔로우하는 사람 수) */ + followerCount: number; + /** 팔로잉 수 (이 사용자가 팔로우하는 사람 수) */ + followingCount: number; + /** 현재 로그인 사용자가 이 사용자를 팔로우 중인지 여부. 본인 프로필이면 항상 false. */ + following: boolean; +} + +/** + * 공통 응답 envelope + */ +export interface ApiResponseFollowSummaryResponse { + /** HTTP status code */ + status: number; + /** 성공 시 'SUCCESS', 실패 시 ErrorCode enum name */ + code: string; + /** 사람이 읽는 메시지 */ + message: string; + data?: FollowSummaryResponse | null; +} + /** * @nullable */ @@ -690,6 +825,306 @@ export interface ApiResponsePageResponseSpotRecordSummaryResponse { data?: PageResponseSpotRecordSummaryResponse | null; } +/** + * 찜한 스팟 목록 + */ +export interface SpotFavoriteListResponse { + /** 찜한 스팟 총 개수 */ + count: number; + /** 찜한 스팟 목록 (최근 찜한 순) */ + favorites: SpotFavoriteResponse[]; +} + +/** + * 공통 응답 envelope + */ +export interface ApiResponseSpotFavoriteListResponse { + /** HTTP status code */ + status: number; + /** 성공 시 'SUCCESS', 실패 시 ErrorCode enum name */ + code: string; + /** 사람이 읽는 메시지 */ + message: string; + data?: SpotFavoriteListResponse | null; +} + +/** + * 꽃 카테고리 + */ +export type BloomSlotCategory = typeof BloomSlotCategory[keyof typeof BloomSlotCategory]; + + +export const BloomSlotCategory = { + PLUM: 'PLUM', + FORSYTHIA: 'FORSYTHIA', + AZALEA_KR: 'AZALEA_KR', + CHERRY: 'CHERRY', + CANOLA: 'CANOLA', + AZALEA: 'AZALEA', + HYDRANGEA: 'HYDRANGEA', + LOTUS: 'LOTUS', + COSMOS: 'COSMOS', + PINK_MUHLY: 'PINK_MUHLY', + SILVERGRASS: 'SILVERGRASS', + MAPLE: 'MAPLE', + CAMELLIA: 'CAMELLIA', +} as const; + +/** + * 현재 상태 (PREPARING/STARTED/PEAK) + */ +export type BloomSlotStatus = typeof BloomSlotStatus[keyof typeof BloomSlotStatus]; + + +export const BloomSlotStatus = { + PREPARING: 'PREPARING', + STARTED: 'STARTED', + PEAK: 'PEAK', + ENDED: 'ENDED', +} as const; + +/** + * 명소×카테고리 현재 상태 슬롯 + */ +export interface BloomSlot { + /** 꽃 카테고리 */ + category: BloomSlotCategory; + /** 카테고리 표시명 */ + displayName: string; + /** 현재 상태 (PREPARING/STARTED/PEAK) */ + status: BloomSlotStatus; + /** 신뢰도 (0~1) */ + confidence: number; +} + +/** + * 명소 1건과 그 꽃 슬롯들 + */ +export interface BloomMapItem { + /** 명소 id */ + attractionId: number; + /** 명소명 */ + title: string; + /** + * 위도 + * @nullable + */ + latitude?: number | null; + /** + * 경도 + * @nullable + */ + longitude?: number | null; + /** 이 명소의 꽃 슬롯들 */ + blooms: BloomSlot[]; +} + +/** + * 지도 영역 내 명소별 현재 개화 상태 (핀 3단계, ENDED 제외) + */ +export interface BloomMapResponse { + /** + * 상태 산출 기준일 (없으면 null) + * @nullable + */ + baseDate?: string | null; + /** 명소 수 */ + count: number; + /** 명소 목록 */ + attractions: BloomMapItem[]; +} + +/** + * 공통 응답 envelope + */ +export interface ApiResponseBloomMapResponse { + /** HTTP status code */ + status: number; + /** 성공 시 'SUCCESS', 실패 시 ErrorCode enum name */ + code: string; + /** 사람이 읽는 메시지 */ + message: string; + data?: BloomMapResponse | null; +} + +/** + * 꽃 카테고리 + */ +export type BloomPeakItemCategory = typeof BloomPeakItemCategory[keyof typeof BloomPeakItemCategory]; + + +export const BloomPeakItemCategory = { + PLUM: 'PLUM', + FORSYTHIA: 'FORSYTHIA', + AZALEA_KR: 'AZALEA_KR', + CHERRY: 'CHERRY', + CANOLA: 'CANOLA', + AZALEA: 'AZALEA', + HYDRANGEA: 'HYDRANGEA', + LOTUS: 'LOTUS', + COSMOS: 'COSMOS', + PINK_MUHLY: 'PINK_MUHLY', + SILVERGRASS: 'SILVERGRASS', + MAPLE: 'MAPLE', + CAMELLIA: 'CAMELLIA', +} as const; + +/** + * 절정 명소 1건 + */ +export interface BloomPeakItem { + /** 명소 id */ + attractionId: number; + /** 명소명 */ + title: string; + /** + * 위도 + * @nullable + */ + latitude?: number | null; + /** + * 경도 + * @nullable + */ + longitude?: number | null; + /** 꽃 카테고리 */ + category: BloomPeakItemCategory; + /** 카테고리 표시명 */ + displayName: string; + /** 신뢰도 (0~1) */ + confidence: number; + /** + * 절정 시작일 + * @nullable + */ + peakStartDate?: string | null; + /** + * 절정 종료일 + * @nullable + */ + peakEndDate?: string | null; + /** + * 절정 지속일 (양 끝 포함) + * @nullable + */ + peakDurationDays?: number | null; +} + +/** + * 지금이 절정인 명소 목록 (status=PEAK) + */ +export interface BloomPeakListResponse { + /** + * 상태 산출 기준일 (없으면 null) + * @nullable + */ + baseDate?: string | null; + /** 절정 명소 수 */ + count: number; + /** 절정 명소 목록 */ + items: BloomPeakItem[]; +} + +/** + * 공통 응답 envelope + */ +export interface ApiResponseBloomPeakListResponse { + /** HTTP status code */ + status: number; + /** 성공 시 'SUCCESS', 실패 시 ErrorCode enum name */ + code: string; + /** 사람이 읽는 메시지 */ + message: string; + data?: BloomPeakListResponse | null; +} + +/** + * 꽃 카테고리 + */ +export type BloomCalendarResponseCategory = typeof BloomCalendarResponseCategory[keyof typeof BloomCalendarResponseCategory]; + + +export const BloomCalendarResponseCategory = { + PLUM: 'PLUM', + FORSYTHIA: 'FORSYTHIA', + AZALEA_KR: 'AZALEA_KR', + CHERRY: 'CHERRY', + CANOLA: 'CANOLA', + AZALEA: 'AZALEA', + HYDRANGEA: 'HYDRANGEA', + LOTUS: 'LOTUS', + COSMOS: 'COSMOS', + PINK_MUHLY: 'PINK_MUHLY', + SILVERGRASS: 'SILVERGRASS', + MAPLE: 'MAPLE', + CAMELLIA: 'CAMELLIA', +} as const; + +/** + * 예상 상태 + */ +export type BloomCalendarDayStatus = typeof BloomCalendarDayStatus[keyof typeof BloomCalendarDayStatus]; + + +export const BloomCalendarDayStatus = { + PREPARING: 'PREPARING', + STARTED: 'STARTED', + PEAK: 'PEAK', + ENDED: 'ENDED', +} as const; + +/** + * 특정 일자의 예상 상태 + */ +export interface BloomCalendarDay { + /** 일자 */ + date: string; + /** 예상 상태 */ + status: BloomCalendarDayStatus; +} + +/** + * 단일 명소×카테고리의 향후 예상 만개 캘린더 (온디맨드 시뮬레이션) + */ +export interface BloomCalendarResponse { + /** 명소 id */ + attractionId: number; + /** 꽃 카테고리 */ + category: BloomCalendarResponseCategory; + /** 카테고리 표시명 */ + displayName: string; + /** + * 올해(인근 시즌) 절정 시작일 + * @nullable + */ + peakStartDate?: string | null; + /** + * 절정 종료일 + * @nullable + */ + peakEndDate?: string | null; + /** + * 절정 지속일 (양 끝 포함) + * @nullable + */ + peakDurationDays?: number | null; + /** 오늘부터의 일별 상태 타임라인 */ + days: BloomCalendarDay[]; +} + +/** + * 공통 응답 envelope + */ +export interface ApiResponseBloomCalendarResponse { + /** HTTP status code */ + status: number; + /** 성공 시 'SUCCESS', 실패 시 ErrorCode enum name */ + code: string; + /** 사람이 읽는 메시지 */ + message: string; + data?: BloomCalendarResponse | null; +} + /** * 공통 응답 envelope */ @@ -782,6 +1217,14 @@ spotId: number; pageRequest: PageRequest; }; +export type FollowingsParams = { +pageRequest: PageRequest; +}; + +export type FollowersParams = { +pageRequest: PageRequest; +}; + export type ListMineParams = { status: ListMineStatus; pageRequest: PageRequest; @@ -795,6 +1238,104 @@ export const ListMineStatus = { PUBLISHED: 'PUBLISHED', } as const; +export type MapParams = { +/** + * 남서 위도 + */ +minLat: number; +/** + * 북동 위도 + */ +maxLat: number; +/** + * 남서 경도 + */ +minLng: number; +/** + * 북동 경도 + */ +maxLng: number; +/** + * 꽃 카테고리 필터 (생략 시 전체) + */ +category?: MapCategory; +}; + +export type MapCategory = typeof MapCategory[keyof typeof MapCategory]; + + +export const MapCategory = { + PLUM: 'PLUM', + FORSYTHIA: 'FORSYTHIA', + AZALEA_KR: 'AZALEA_KR', + CHERRY: 'CHERRY', + CANOLA: 'CANOLA', + AZALEA: 'AZALEA', + HYDRANGEA: 'HYDRANGEA', + LOTUS: 'LOTUS', + COSMOS: 'COSMOS', + PINK_MUHLY: 'PINK_MUHLY', + SILVERGRASS: 'SILVERGRASS', + MAPLE: 'MAPLE', + CAMELLIA: 'CAMELLIA', +} as const; + +export type PeakParams = { +/** + * 꽃 카테고리 필터 (생략 시 전체) + */ +category?: PeakCategory; +}; + +export type PeakCategory = typeof PeakCategory[keyof typeof PeakCategory]; + + +export const PeakCategory = { + PLUM: 'PLUM', + FORSYTHIA: 'FORSYTHIA', + AZALEA_KR: 'AZALEA_KR', + CHERRY: 'CHERRY', + CANOLA: 'CANOLA', + AZALEA: 'AZALEA', + HYDRANGEA: 'HYDRANGEA', + LOTUS: 'LOTUS', + COSMOS: 'COSMOS', + PINK_MUHLY: 'PINK_MUHLY', + SILVERGRASS: 'SILVERGRASS', + MAPLE: 'MAPLE', + CAMELLIA: 'CAMELLIA', +} as const; + +export type CalendarParams = { +/** + * 명소 id + */ +attractionId: number; +/** + * 꽃 카테고리 + */ +category: CalendarCategory; +}; + +export type CalendarCategory = typeof CalendarCategory[keyof typeof CalendarCategory]; + + +export const CalendarCategory = { + PLUM: 'PLUM', + FORSYTHIA: 'FORSYTHIA', + AZALEA_KR: 'AZALEA_KR', + CHERRY: 'CHERRY', + CANOLA: 'CANOLA', + AZALEA: 'AZALEA', + HYDRANGEA: 'HYDRANGEA', + LOTUS: 'LOTUS', + COSMOS: 'COSMOS', + PINK_MUHLY: 'PINK_MUHLY', + SILVERGRASS: 'SILVERGRASS', + MAPLE: 'MAPLE', + CAMELLIA: 'CAMELLIA', +} as const; + export type SearchParams = { keyword: string; }; diff --git a/src/api/generated/plant/plant.ts b/src/api/generated/plant/plant.ts index ca73b79..599ffbf 100644 --- a/src/api/generated/plant/plant.ts +++ b/src/api/generated/plant/plant.ts @@ -120,19 +120,19 @@ export const useSuggest = => { return useMutation(getSuggestMutationOptions(options), queryClient); } - export type listResponse200 = { + export type list1Response200 = { data: ApiResponseListPlantResponse status: 200 } -export type listResponseSuccess = (listResponse200) & { +export type list1ResponseSuccess = (list1Response200) & { headers: Headers; }; ; -export type listResponse = (listResponseSuccess) +export type list1Response = (list1ResponseSuccess) -export const getListUrl = () => { +export const getList1Url = () => { @@ -144,9 +144,9 @@ export const getListUrl = () => { * ACTIVE 상태의 식물을 sortOrder 오름차순으로 반환한다. (Step2 식물 칩용) * @summary 활성 식물 마스터 리스트 */ -export const list = async ( options?: RequestInit): Promise => { +export const list1 = async ( options?: RequestInit): Promise => { - return customInstance(getListUrl(), + return customInstance(getList1Url(), { ...options, method: 'GET' @@ -159,69 +159,69 @@ export const list = async ( options?: RequestInit): Promise => { -export const getListQueryKey = () => { +export const getList1QueryKey = () => { return [ `/api/plants` ] as const; } -export const getListQueryOptions = >, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +export const getList1QueryOptions = >, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; - const queryKey = queryOptions?.queryKey ?? getListQueryKey(); + const queryKey = queryOptions?.queryKey ?? getList1QueryKey(); - const queryFn: QueryFunction>> = ({ signal }) => list({ signal, ...requestOptions }); + const queryFn: QueryFunction>> = ({ signal }) => list1({ signal, ...requestOptions }); - return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } -export type ListQueryResult = NonNullable>> -export type ListQueryError = unknown +export type List1QueryResult = NonNullable>> +export type List1QueryError = unknown -export function useList>, TError = unknown>( - options: { query:Partial>, TError, TData>> & Pick< +export function useList1>, TError = unknown>( + options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< - Awaited>, + Awaited>, TError, - Awaited> + Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } -export function useList>, TError = unknown>( - options?: { query?:Partial>, TError, TData>> & Pick< +export function useList1>, TError = unknown>( + options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< - Awaited>, + Awaited>, TError, - Awaited> + Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } -export function useList>, TError = unknown>( - options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +export function useList1>, TError = unknown>( + options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary 활성 식물 마스터 리스트 */ -export function useList>, TError = unknown>( - options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +export function useList1>, TError = unknown>( + options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { - const queryOptions = getListQueryOptions(options) + const queryOptions = getList1QueryOptions(options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; diff --git a/src/api/generated/seasonal-bloom/seasonal-bloom.ts b/src/api/generated/seasonal-bloom/seasonal-bloom.ts new file mode 100644 index 0000000..b9ad64f --- /dev/null +++ b/src/api/generated/seasonal-bloom/seasonal-bloom.ts @@ -0,0 +1,398 @@ +/** + * Generated by orval v8.10.0 🍺 + * Do not edit manually. + * PEAKDA API + * 계절 여행 타이밍 안내 서비스 API 문서 + * OpenAPI spec version: v1 + */ +import { + useQuery +} from '@tanstack/react-query'; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseQueryOptions, + UseQueryResult +} from '@tanstack/react-query'; + +import type { + ApiResponseBloomCalendarResponse, + ApiResponseBloomMapResponse, + ApiResponseBloomPeakListResponse, + CalendarParams, + MapParams, + PeakParams +} from '../peakdaApi.schemas'; + +import { customInstance } from '../../mutator/index'; + + +type SecondParameter unknown> = Parameters[1]; + + + +export type mapResponse200 = { + data: ApiResponseBloomMapResponse + status: 200 +} + +export type mapResponseSuccess = (mapResponse200) & { + headers: Headers; +}; +; + +export type mapResponse = (mapResponseSuccess) + +export const getMapUrl = (params: MapParams,) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + + if (value !== undefined) { + normalizedParams.append(key, value === null ? 'null' : value.toString()) + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 ? `/api/seasonal/blooms?${stringifiedParams}` : `/api/seasonal/blooms` +} + +/** + * 지도 영역(bbox) 내 visible 명소별 현재 개화 상태를 조회한다. 핀 3단계(PREPARING/STARTED/PEAK)만 노출하며 ENDED 는 제외된다. category 로 특정 꽃만 필터할 수 있다. + * @summary 지도 영역 개화 현황 + */ +export const map = async (params: MapParams, options?: RequestInit): Promise => { + + return customInstance(getMapUrl(params), + { + ...options, + method: 'GET' + + + } +);} + + + + + +export const getMapQueryKey = (params?: MapParams,) => { + return [ + `/api/seasonal/blooms`, ...(params ? [params] : []) + ] as const; + } + + +export const getMapQueryOptions = >, TError = unknown>(params: MapParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getMapQueryKey(params); + + + + const queryFn: QueryFunction>> = ({ signal }) => map(params, { signal, ...requestOptions }); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type MapQueryResult = NonNullable>> +export type MapQueryError = unknown + + +export function useMap>, TError = unknown>( + params: MapParams, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useMap>, TError = unknown>( + params: MapParams, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useMap>, TError = unknown>( + params: MapParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 지도 영역 개화 현황 + */ + +export function useMap>, TError = unknown>( + params: MapParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getMapQueryOptions(params,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + + + + + + +export type peakResponse200 = { + data: ApiResponseBloomPeakListResponse + status: 200 +} + +export type peakResponseSuccess = (peakResponse200) & { + headers: Headers; +}; +; + +export type peakResponse = (peakResponseSuccess) + +export const getPeakUrl = (params?: PeakParams,) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + + if (value !== undefined) { + normalizedParams.append(key, value === null ? 'null' : value.toString()) + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 ? `/api/seasonal/blooms/peak?${stringifiedParams}` : `/api/seasonal/blooms/peak` +} + +/** + * 최신 산출일 기준 status=PEAK 명소를 조회한다. category 로 특정 꽃만 필터할 수 있다. + * @summary 지금이 절정인 명소 리스트 + */ +export const peak = async (params?: PeakParams, options?: RequestInit): Promise => { + + return customInstance(getPeakUrl(params), + { + ...options, + method: 'GET' + + + } +);} + + + + + +export const getPeakQueryKey = (params?: PeakParams,) => { + return [ + `/api/seasonal/blooms/peak`, ...(params ? [params] : []) + ] as const; + } + + +export const getPeakQueryOptions = >, TError = unknown>(params?: PeakParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getPeakQueryKey(params); + + + + const queryFn: QueryFunction>> = ({ signal }) => peak(params, { signal, ...requestOptions }); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type PeakQueryResult = NonNullable>> +export type PeakQueryError = unknown + + +export function usePeak>, TError = unknown>( + params: undefined | PeakParams, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function usePeak>, TError = unknown>( + params?: PeakParams, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function usePeak>, TError = unknown>( + params?: PeakParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 지금이 절정인 명소 리스트 + */ + +export function usePeak>, TError = unknown>( + params?: PeakParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getPeakQueryOptions(params,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + + + + + + +export type calendarResponse200 = { + data: ApiResponseBloomCalendarResponse + status: 200 +} + +export type calendarResponseSuccess = (calendarResponse200) & { + headers: Headers; +}; +; + +export type calendarResponse = (calendarResponseSuccess) + +export const getCalendarUrl = (params: CalendarParams,) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + + if (value !== undefined) { + normalizedParams.append(key, value === null ? 'null' : value.toString()) + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 ? `/api/seasonal/blooms/calendar?${stringifiedParams}` : `/api/seasonal/blooms/calendar` +} + +/** + * 단일 명소×카테고리의 향후 일별 예상 상태 타임라인과 대표 절정 구간(올해 만개 시기/지속일)을 온디맨드로 계산한다. + * @summary 예상 만개 캘린더 + */ +export const calendar = async (params: CalendarParams, options?: RequestInit): Promise => { + + return customInstance(getCalendarUrl(params), + { + ...options, + method: 'GET' + + + } +);} + + + + + +export const getCalendarQueryKey = (params?: CalendarParams,) => { + return [ + `/api/seasonal/blooms/calendar`, ...(params ? [params] : []) + ] as const; + } + + +export const getCalendarQueryOptions = >, TError = unknown>(params: CalendarParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getCalendarQueryKey(params); + + + + const queryFn: QueryFunction>> = ({ signal }) => calendar(params, { signal, ...requestOptions }); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type CalendarQueryResult = NonNullable>> +export type CalendarQueryError = unknown + + +export function useCalendar>, TError = unknown>( + params: CalendarParams, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useCalendar>, TError = unknown>( + params: CalendarParams, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useCalendar>, TError = unknown>( + params: CalendarParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 예상 만개 캘린더 + */ + +export function useCalendar>, TError = unknown>( + params: CalendarParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getCalendarQueryOptions(params,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + + + + + + diff --git a/src/api/generated/spot-favorite/spot-favorite.ts b/src/api/generated/spot-favorite/spot-favorite.ts new file mode 100644 index 0000000..872a86a --- /dev/null +++ b/src/api/generated/spot-favorite/spot-favorite.ts @@ -0,0 +1,400 @@ +/** + * Generated by orval v8.10.0 🍺 + * Do not edit manually. + * PEAKDA API + * 계절 여행 타이밍 안내 서비스 API 문서 + * OpenAPI spec version: v1 + */ +import { + useMutation, + useQuery +} from '@tanstack/react-query'; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + MutationFunction, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseMutationOptions, + UseMutationResult, + UseQueryOptions, + UseQueryResult +} from '@tanstack/react-query'; + +import type { + ApiResponseSpotFavoriteListResponse, + ApiResponseSpotFavoriteResponse, + ApiResponseUnit, + UpdateFavoriteNotifyRequest +} from '../peakdaApi.schemas'; + +import { customInstance } from '../../mutator/index'; + + +type SecondParameter unknown> = Parameters[1]; + + + +export type addResponse200 = { + data: ApiResponseSpotFavoriteResponse + status: 200 +} + +export type addResponseSuccess = (addResponse200) & { + headers: Headers; +}; +; + +export type addResponse = (addResponseSuccess) + +export const getAddUrl = (spotId: number,) => { + + + + + return `/api/spots/favorites/${spotId}` +} + +/** + * 스팟을 찜한다. 이미 찜한 스팟이면 기존 찜을 그대로 반환한다 (멱등). 찜 시 만개 알림이 기본 활성화된다. + * @summary 스팟 찜 추가 + */ +export const add = async (spotId: number, options?: RequestInit): Promise => { + + return customInstance(getAddUrl(spotId), + { + ...options, + method: 'POST' + + + } +);} + + + + +export const getAddMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{spotId: number}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{spotId: number}, TContext> => { + +const mutationKey = ['add']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, {spotId: number}> = (props) => { + const {spotId} = props ?? {}; + + return add(spotId,requestOptions) + } + + + + + + + return { mutationFn, ...mutationOptions }} + + export type AddMutationResult = NonNullable>> + + export type AddMutationError = unknown + + /** + * @summary 스팟 찜 추가 + */ +export const useAdd = (options?: { mutation?:UseMutationOptions>, TError,{spotId: number}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {spotId: number}, + TContext + > => { + return useMutation(getAddMutationOptions(options), queryClient); + } + export type removeResponse200 = { + data: ApiResponseUnit + status: 200 +} + +export type removeResponseSuccess = (removeResponse200) & { + headers: Headers; +}; +; + +export type removeResponse = (removeResponseSuccess) + +export const getRemoveUrl = (spotId: number,) => { + + + + + return `/api/spots/favorites/${spotId}` +} + +/** + * 찜을 해제한다. 찜하지 않은 스팟이어도 성공으로 응답한다 (멱등). + * @summary 스팟 찜 취소 + */ +export const remove = async (spotId: number, options?: RequestInit): Promise => { + + return customInstance(getRemoveUrl(spotId), + { + ...options, + method: 'DELETE' + + + } +);} + + + + +export const getRemoveMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{spotId: number}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{spotId: number}, TContext> => { + +const mutationKey = ['remove']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, {spotId: number}> = (props) => { + const {spotId} = props ?? {}; + + return remove(spotId,requestOptions) + } + + + + + + + return { mutationFn, ...mutationOptions }} + + export type RemoveMutationResult = NonNullable>> + + export type RemoveMutationError = unknown + + /** + * @summary 스팟 찜 취소 + */ +export const useRemove = (options?: { mutation?:UseMutationOptions>, TError,{spotId: number}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {spotId: number}, + TContext + > => { + return useMutation(getRemoveMutationOptions(options), queryClient); + } + export type updateNotifyResponse200 = { + data: ApiResponseSpotFavoriteResponse + status: 200 +} + +export type updateNotifyResponseSuccess = (updateNotifyResponse200) & { + headers: Headers; +}; +; + +export type updateNotifyResponse = (updateNotifyResponseSuccess) + +export const getUpdateNotifyUrl = (spotId: number,) => { + + + + + return `/api/spots/favorites/${spotId}/notify` +} + +/** + * 찜한 스팟의 만개 알림 수신 여부를 변경한다. 찜하지 않은 스팟이면 404. + * @summary 찜한 스팟 만개 알림 설정 변경 + */ +export const updateNotify = async (spotId: number, + updateFavoriteNotifyRequest: UpdateFavoriteNotifyRequest, options?: RequestInit): Promise => { + + return customInstance(getUpdateNotifyUrl(spotId), + { + ...options, + method: 'PATCH', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(updateFavoriteNotifyRequest) + } +);} + + + + +export const getUpdateNotifyMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{spotId: number;data: UpdateFavoriteNotifyRequest}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{spotId: number;data: UpdateFavoriteNotifyRequest}, TContext> => { + +const mutationKey = ['updateNotify']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, {spotId: number;data: UpdateFavoriteNotifyRequest}> = (props) => { + const {spotId,data} = props ?? {}; + + return updateNotify(spotId,data,requestOptions) + } + + + + + + + return { mutationFn, ...mutationOptions }} + + export type UpdateNotifyMutationResult = NonNullable>> + export type UpdateNotifyMutationBody = UpdateFavoriteNotifyRequest + export type UpdateNotifyMutationError = unknown + + /** + * @summary 찜한 스팟 만개 알림 설정 변경 + */ +export const useUpdateNotify = (options?: { mutation?:UseMutationOptions>, TError,{spotId: number;data: UpdateFavoriteNotifyRequest}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {spotId: number;data: UpdateFavoriteNotifyRequest}, + TContext + > => { + return useMutation(getUpdateNotifyMutationOptions(options), queryClient); + } + export type listResponse200 = { + data: ApiResponseSpotFavoriteListResponse + status: 200 +} + +export type listResponseSuccess = (listResponse200) & { + headers: Headers; +}; +; + +export type listResponse = (listResponseSuccess) + +export const getListUrl = () => { + + + + + return `/api/spots/favorites` +} + +/** + * 본인이 찜한 스팟을 최근 찜한 순으로 조회한다. + * @summary 찜한 스팟 목록 + */ +export const list = async ( options?: RequestInit): Promise => { + + return customInstance(getListUrl(), + { + ...options, + method: 'GET' + + + } +);} + + + + + +export const getListQueryKey = () => { + return [ + `/api/spots/favorites` + ] as const; + } + + +export const getListQueryOptions = >, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListQueryKey(); + + + + const queryFn: QueryFunction>> = ({ signal }) => list({ signal, ...requestOptions }); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type ListQueryResult = NonNullable>> +export type ListQueryError = unknown + + +export function useList>, TError = unknown>( + options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useList>, TError = unknown>( + options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useList>, TError = unknown>( + options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 찜한 스팟 목록 + */ + +export function useList>, TError = unknown>( + options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getListQueryOptions(options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + + + + + + diff --git a/src/api/generated/user-follow/user-follow.ts b/src/api/generated/user-follow/user-follow.ts new file mode 100644 index 0000000..68121cb --- /dev/null +++ b/src/api/generated/user-follow/user-follow.ts @@ -0,0 +1,574 @@ +/** + * Generated by orval v8.10.0 🍺 + * Do not edit manually. + * PEAKDA API + * 계절 여행 타이밍 안내 서비스 API 문서 + * OpenAPI spec version: v1 + */ +import { + useMutation, + useQuery +} from '@tanstack/react-query'; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + MutationFunction, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseMutationOptions, + UseMutationResult, + UseQueryOptions, + UseQueryResult +} from '@tanstack/react-query'; + +import type { + ApiResponseFollowSummaryResponse, + ApiResponsePageResponseFollowUserResponse, + ApiResponseUnit, + FollowersParams, + FollowingsParams +} from '../peakdaApi.schemas'; + +import { customInstance } from '../../mutator/index'; + + +type SecondParameter unknown> = Parameters[1]; + + + +export type followResponse200 = { + data: ApiResponseUnit + status: 200 +} + +export type followResponseSuccess = (followResponse200) & { + headers: Headers; +}; +; + +export type followResponse = (followResponseSuccess) + +export const getFollowUrl = (userId: number,) => { + + + + + return `/api/users/${userId}/follow` +} + +/** + * 대상 사용자를 팔로우한다. 이미 팔로우 중이면 그대로 성공으로 응답한다 (멱등). 자기 자신은 팔로우할 수 없다. + * @summary 사용자 팔로우 + */ +export const follow = async (userId: number, options?: RequestInit): Promise => { + + return customInstance(getFollowUrl(userId), + { + ...options, + method: 'POST' + + + } +);} + + + + +export const getFollowMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{userId: number}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{userId: number}, TContext> => { + +const mutationKey = ['follow']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, {userId: number}> = (props) => { + const {userId} = props ?? {}; + + return follow(userId,requestOptions) + } + + + + + + + return { mutationFn, ...mutationOptions }} + + export type FollowMutationResult = NonNullable>> + + export type FollowMutationError = unknown + + /** + * @summary 사용자 팔로우 + */ +export const useFollow = (options?: { mutation?:UseMutationOptions>, TError,{userId: number}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {userId: number}, + TContext + > => { + return useMutation(getFollowMutationOptions(options), queryClient); + } + export type unfollowResponse200 = { + data: ApiResponseUnit + status: 200 +} + +export type unfollowResponseSuccess = (unfollowResponse200) & { + headers: Headers; +}; +; + +export type unfollowResponse = (unfollowResponseSuccess) + +export const getUnfollowUrl = (userId: number,) => { + + + + + return `/api/users/${userId}/follow` +} + +/** + * 대상 사용자 팔로우를 해제한다. 팔로우하지 않은 사용자여도 성공으로 응답한다 (멱등). + * @summary 사용자 언팔로우 + */ +export const unfollow = async (userId: number, options?: RequestInit): Promise => { + + return customInstance(getUnfollowUrl(userId), + { + ...options, + method: 'DELETE' + + + } +);} + + + + +export const getUnfollowMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{userId: number}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{userId: number}, TContext> => { + +const mutationKey = ['unfollow']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, {userId: number}> = (props) => { + const {userId} = props ?? {}; + + return unfollow(userId,requestOptions) + } + + + + + + + return { mutationFn, ...mutationOptions }} + + export type UnfollowMutationResult = NonNullable>> + + export type UnfollowMutationError = unknown + + /** + * @summary 사용자 언팔로우 + */ +export const useUnfollow = (options?: { mutation?:UseMutationOptions>, TError,{userId: number}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {userId: number}, + TContext + > => { + return useMutation(getUnfollowMutationOptions(options), queryClient); + } + export type followingsResponse200 = { + data: ApiResponsePageResponseFollowUserResponse + status: 200 +} + +export type followingsResponseSuccess = (followingsResponse200) & { + headers: Headers; +}; +; + +export type followingsResponse = (followingsResponseSuccess) + +export const getFollowingsUrl = (userId: number, + params: FollowingsParams,) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + + if (value !== undefined) { + normalizedParams.append(key, value === null ? 'null' : value.toString()) + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 ? `/api/users/${userId}/followings?${stringifiedParams}` : `/api/users/${userId}/followings` +} + +/** + * 대상 사용자가 팔로우하는 사람들을 최근 팔로우한 순으로 페이징 조회한다. 각 항목의 following 필드는 현재 로그인 사용자 기준의 팔로우 여부다. + * @summary 팔로잉 목록 조회 + */ +export const followings = async (userId: number, + params: FollowingsParams, options?: RequestInit): Promise => { + + return customInstance(getFollowingsUrl(userId,params), + { + ...options, + method: 'GET' + + + } +);} + + + + + +export const getFollowingsQueryKey = (userId: number, + params?: FollowingsParams,) => { + return [ + `/api/users/${userId}/followings`, ...(params ? [params] : []) + ] as const; + } + + +export const getFollowingsQueryOptions = >, TError = unknown>(userId: number, + params: FollowingsParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getFollowingsQueryKey(userId,params); + + + + const queryFn: QueryFunction>> = ({ signal }) => followings(userId,params, { signal, ...requestOptions }); + + + + + + return { queryKey, queryFn, enabled: userId !== null && userId !== undefined, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type FollowingsQueryResult = NonNullable>> +export type FollowingsQueryError = unknown + + +export function useFollowings>, TError = unknown>( + userId: number, + params: FollowingsParams, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useFollowings>, TError = unknown>( + userId: number, + params: FollowingsParams, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useFollowings>, TError = unknown>( + userId: number, + params: FollowingsParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 팔로잉 목록 조회 + */ + +export function useFollowings>, TError = unknown>( + userId: number, + params: FollowingsParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getFollowingsQueryOptions(userId,params,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + + + + + + +export type followersResponse200 = { + data: ApiResponsePageResponseFollowUserResponse + status: 200 +} + +export type followersResponseSuccess = (followersResponse200) & { + headers: Headers; +}; +; + +export type followersResponse = (followersResponseSuccess) + +export const getFollowersUrl = (userId: number, + params: FollowersParams,) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + + if (value !== undefined) { + normalizedParams.append(key, value === null ? 'null' : value.toString()) + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 ? `/api/users/${userId}/followers?${stringifiedParams}` : `/api/users/${userId}/followers` +} + +/** + * 대상 사용자를 팔로우하는 사람들을 최근 팔로우한 순으로 페이징 조회한다. 각 항목의 following 필드는 현재 로그인 사용자 기준의 팔로우 여부다. + * @summary 팔로워 목록 조회 + */ +export const followers = async (userId: number, + params: FollowersParams, options?: RequestInit): Promise => { + + return customInstance(getFollowersUrl(userId,params), + { + ...options, + method: 'GET' + + + } +);} + + + + + +export const getFollowersQueryKey = (userId: number, + params?: FollowersParams,) => { + return [ + `/api/users/${userId}/followers`, ...(params ? [params] : []) + ] as const; + } + + +export const getFollowersQueryOptions = >, TError = unknown>(userId: number, + params: FollowersParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getFollowersQueryKey(userId,params); + + + + const queryFn: QueryFunction>> = ({ signal }) => followers(userId,params, { signal, ...requestOptions }); + + + + + + return { queryKey, queryFn, enabled: userId !== null && userId !== undefined, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type FollowersQueryResult = NonNullable>> +export type FollowersQueryError = unknown + + +export function useFollowers>, TError = unknown>( + userId: number, + params: FollowersParams, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useFollowers>, TError = unknown>( + userId: number, + params: FollowersParams, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useFollowers>, TError = unknown>( + userId: number, + params: FollowersParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 팔로워 목록 조회 + */ + +export function useFollowers>, TError = unknown>( + userId: number, + params: FollowersParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getFollowersQueryOptions(userId,params,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + + + + + + +export type summaryResponse200 = { + data: ApiResponseFollowSummaryResponse + status: 200 +} + +export type summaryResponseSuccess = (summaryResponse200) & { + headers: Headers; +}; +; + +export type summaryResponse = (summaryResponseSuccess) + +export const getSummaryUrl = (userId: number,) => { + + + + + return `/api/users/${userId}/follow-summary` +} + +/** + * 대상 사용자의 팔로워 수·팔로잉 수와, 현재 로그인 사용자의 팔로우 여부를 조회한다. 유저 프로필 헤더 표시에 사용한다. + * @summary 팔로우 통계 요약 + */ +export const summary = async (userId: number, options?: RequestInit): Promise => { + + return customInstance(getSummaryUrl(userId), + { + ...options, + method: 'GET' + + + } +);} + + + + + +export const getSummaryQueryKey = (userId: number,) => { + return [ + `/api/users/${userId}/follow-summary` + ] as const; + } + + +export const getSummaryQueryOptions = >, TError = unknown>(userId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getSummaryQueryKey(userId); + + + + const queryFn: QueryFunction>> = ({ signal }) => summary(userId, { signal, ...requestOptions }); + + + + + + return { queryKey, queryFn, enabled: userId !== null && userId !== undefined, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type SummaryQueryResult = NonNullable>> +export type SummaryQueryError = unknown + + +export function useSummary>, TError = unknown>( + userId: number, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useSummary>, TError = unknown>( + userId: number, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useSummary>, TError = unknown>( + userId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 팔로우 통계 요약 + */ + +export function useSummary>, TError = unknown>( + userId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getSummaryQueryOptions(userId,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + + + + + + diff --git a/src/api/generated/user/user.ts b/src/api/generated/user/user.ts index 9010297..728cbb3 100644 --- a/src/api/generated/user/user.ts +++ b/src/api/generated/user/user.ts @@ -5,35 +5,46 @@ * 계절 여행 타이밍 안내 서비스 API 문서 * OpenAPI spec version: v1 */ -import { useMutation } from '@tanstack/react-query' +import { + useMutation +} from '@tanstack/react-query'; import type { MutationFunction, QueryClient, UseMutationOptions, - UseMutationResult, -} from '@tanstack/react-query' + UseMutationResult +} from '@tanstack/react-query'; import type { ApiResponseProfileImageResponse, ApiResponseUnit, - ProfileImageUploadForm, -} from '../peakdaApi.schemas' + ProfileImageUploadForm +} from '../peakdaApi.schemas'; + +import { customInstance } from '../../mutator/index'; + + +type SecondParameter unknown> = Parameters[1]; -import { customInstance } from '../../mutator/index' -type SecondParameter unknown> = Parameters[1] export type uploadProfileImageResponse200 = { data: ApiResponseProfileImageResponse status: 200 } -export type uploadProfileImageResponseSuccess = uploadProfileImageResponse200 & { - headers: Headers -} -export type uploadProfileImageResponse = uploadProfileImageResponseSuccess +export type uploadProfileImageResponseSuccess = (uploadProfileImageResponse200) & { + headers: Headers; +}; +; + +export type uploadProfileImageResponse = (uploadProfileImageResponseSuccess) export const getUploadProfileImageUrl = () => { + + + + return `/api/users/me/profile-image` } @@ -41,95 +52,83 @@ export const getUploadProfileImageUrl = () => { * 현재 로그인한 사용자의 프로필 이미지를 업로드한다. 서버에서 thumbnail(128x128), main(512x512) 사이즈로 리사이즈 후 스토리지에 저장한다. * @summary 프로필 이미지 업로드 */ -export const uploadProfileImage = async ( - profileImageUploadForm: ProfileImageUploadForm, - options?: RequestInit -): Promise => { - const formData = new FormData() - formData.append(`image`, profileImageUploadForm.image) - - return customInstance(getUploadProfileImageUrl(), { - ...options, - method: 'POST', - body: formData, - }) -} +export const uploadProfileImage = async (profileImageUploadForm: ProfileImageUploadForm, options?: RequestInit): Promise => { + const formData = new FormData(); +formData.append(`image`, profileImageUploadForm.image); -export const getUploadProfileImageMutationOptions = < - TError = unknown, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { data: ProfileImageUploadForm }, - TContext - > - request?: SecondParameter -}): UseMutationOptions< - Awaited>, - TError, - { data: ProfileImageUploadForm }, - TContext -> => { - const mutationKey = ['uploadProfileImage'] - const { mutation: mutationOptions, request: requestOptions } = options - ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey - ? options - : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey }, request: undefined } - - const mutationFn: MutationFunction< - Awaited>, - { data: ProfileImageUploadForm } - > = (props) => { - const { data } = props ?? {} - - return uploadProfileImage(data, requestOptions) + return customInstance(getUploadProfileImageUrl(), + { + ...options, + method: 'POST' + , + body: formData } +);} - return { mutationFn, ...mutationOptions } -} -export type UploadProfileImageMutationResult = NonNullable< - Awaited> -> -export type UploadProfileImageMutationBody = ProfileImageUploadForm -export type UploadProfileImageMutationError = unknown -/** + +export const getUploadProfileImageMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: ProfileImageUploadForm}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{data: ProfileImageUploadForm}, TContext> => { + +const mutationKey = ['uploadProfileImage']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, {data: ProfileImageUploadForm}> = (props) => { + const {data} = props ?? {}; + + return uploadProfileImage(data,requestOptions) + } + + + + + + + return { mutationFn, ...mutationOptions }} + + export type UploadProfileImageMutationResult = NonNullable>> + export type UploadProfileImageMutationBody = ProfileImageUploadForm + export type UploadProfileImageMutationError = unknown + + /** * @summary 프로필 이미지 업로드 */ -export const useUploadProfileImage = ( - options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { data: ProfileImageUploadForm }, - TContext - > - request?: SecondParameter - }, - queryClient?: QueryClient -): UseMutationResult< - Awaited>, - TError, - { data: ProfileImageUploadForm }, - TContext -> => { - return useMutation(getUploadProfileImageMutationOptions(options), queryClient) -} -export type deleteProfileImageResponse200 = { +export const useUploadProfileImage = (options?: { mutation?:UseMutationOptions>, TError,{data: ProfileImageUploadForm}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {data: ProfileImageUploadForm}, + TContext + > => { + return useMutation(getUploadProfileImageMutationOptions(options), queryClient); + } + export type deleteProfileImageResponse200 = { data: ApiResponseUnit status: 200 } -export type deleteProfileImageResponseSuccess = deleteProfileImageResponse200 & { - headers: Headers -} -export type deleteProfileImageResponse = deleteProfileImageResponseSuccess +export type deleteProfileImageResponseSuccess = (deleteProfileImageResponse200) & { + headers: Headers; +}; +; + +export type deleteProfileImageResponse = (deleteProfileImageResponseSuccess) export const getDeleteProfileImageUrl = () => { + + + + return `/api/users/me/profile-image` } @@ -137,61 +136,61 @@ export const getDeleteProfileImageUrl = () => { * 현재 로그인한 사용자의 프로필 이미지를 삭제한다. * @summary 프로필 이미지 삭제 */ -export const deleteProfileImage = async ( - options?: RequestInit -): Promise => { - return customInstance(getDeleteProfileImageUrl(), { +export const deleteProfileImage = async ( options?: RequestInit): Promise => { + + return customInstance(getDeleteProfileImageUrl(), + { ...options, - method: 'DELETE', - }) -} + method: 'DELETE' + -export const getDeleteProfileImageMutationOptions = < - TError = unknown, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - void, - TContext - > - request?: SecondParameter -}): UseMutationOptions>, TError, void, TContext> => { - const mutationKey = ['deleteProfileImage'] - const { mutation: mutationOptions, request: requestOptions } = options - ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey - ? options - : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey }, request: undefined } - - const mutationFn: MutationFunction>, void> = () => { - return deleteProfileImage(requestOptions) } +);} - return { mutationFn, ...mutationOptions } -} -export type DeleteProfileImageMutationResult = NonNullable< - Awaited> -> -export type DeleteProfileImageMutationError = unknown -/** +export const getDeleteProfileImageMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,void, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,void, TContext> => { + +const mutationKey = ['deleteProfileImage']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, void> = () => { + + + return deleteProfileImage(requestOptions) + } + + + + + + + return { mutationFn, ...mutationOptions }} + + export type DeleteProfileImageMutationResult = NonNullable>> + + export type DeleteProfileImageMutationError = unknown + + /** * @summary 프로필 이미지 삭제 */ -export const useDeleteProfileImage = ( - options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - void, - TContext - > - request?: SecondParameter - }, - queryClient?: QueryClient -): UseMutationResult>, TError, void, TContext> => { - return useMutation(getDeleteProfileImageMutationOptions(options), queryClient) -} +export const useDeleteProfileImage = (options?: { mutation?:UseMutationOptions>, TError,void, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + void, + TContext + > => { + return useMutation(getDeleteProfileImageMutationOptions(options), queryClient); + } diff --git a/src/api/mutator/index.ts b/src/api/mutator/index.ts index 13afba3..2eecdcd 100644 --- a/src/api/mutator/index.ts +++ b/src/api/mutator/index.ts @@ -1,14 +1,45 @@ +// 동시 401 요청이 refresh 를 중복 호출하지 않도록 진행 중인 refresh 를 공유한다. +let refreshPromise: Promise | null = null + +async function runRefresh(): Promise { + if (!refreshPromise) { + // 생성 코드(facades)에 의존하면 orval 부트스트랩이 깨지므로 refresh 엔드포인트를 직접 호출한다. + const baseUrl = process.env.NEXT_PUBLIC_API_URL + refreshPromise = fetch(`${baseUrl}/api/auth/refresh`, { + method: 'POST', + credentials: 'include', + }) + .then((res) => { + if (!res.ok) throw new Error('refresh failed') + }) + .finally(() => { + refreshPromise = null + }) + } + return refreshPromise +} + export const customInstance = async (url: string, options?: RequestInit): Promise => { - const baseUrl = process.env.NEXT_PUBLIC_API_URL; - const res = await fetch(`${baseUrl}${url}`, { - credentials: "include", - ...options, - }); + const baseUrl = process.env.NEXT_PUBLIC_API_URL + const request = () => fetch(`${baseUrl}${url}`, { credentials: 'include', ...options }) + + let res = await request() + + // 액세스 토큰 만료(401) → refresh 1회 후 원요청 재시도. refresh 엔드포인트 자체는 제외(무한 루프 방지). + if (res.status === 401 && !url.includes('/api/auth/refresh')) { + try { + await runRefresh() + res = await request() + } catch { + if (typeof window !== 'undefined') window.location.href = '/login' + throw { response: { status: 401, data: null } } + } + } if (!res.ok) { - throw { response: { status: res.status, data: await res.json() } }; + throw { response: { status: res.status, data: await res.json() } } } - const data = await res.json(); - return { data, status: res.status, headers: res.headers } as T; -}; + const data = await res.json() + return { data, status: res.status, headers: res.headers } as T +} diff --git a/src/app/followers/page.tsx b/src/app/followers/page.tsx index d6edaa2..57ae4d0 100644 --- a/src/app/followers/page.tsx +++ b/src/app/followers/page.tsx @@ -1,17 +1,37 @@ +'use client' + import Header from '@/components/ui/layout/Header' import LeftArrow from '@/components/ui/button/LeftArrow' import { UserRow } from '@/components/ui/list/UserRow' +import { useCurrentUser } from '@/api/facades/auth' +import { useFollowerList } from '@/api/facades/user-follow' + +function FollowerList({ userId }: { userId: number }) { + const { data } = useFollowerList(userId, { pageRequest: { page: 0, size: 50 } }) + const users = data?.content ?? [] -const FOLLOWERS = [ - { name: '닉네임', initialFollowing: false }, - { name: '닉네임', initialFollowing: true }, - { name: '닉네임', initialFollowing: false }, - { name: '닉네임', initialFollowing: false }, - { name: '닉네임', initialFollowing: true }, - { name: '닉네임', initialFollowing: false }, -] + if (users.length === 0) { + return

팔로워가 없습니다.

+ } + + return ( +
    + {users.map((user) => ( + + ))} +
+ ) +} export default function FollowersPage() { + const { data: me } = useCurrentUser() + return (
@@ -21,11 +41,7 @@ export default function FollowersPage() { />
-
    - {FOLLOWERS.map((user, idx) => ( - - ))} -
+ {me?.id != null && }
) } diff --git a/src/app/following/page.tsx b/src/app/following/page.tsx index c6c68a4..d3a337c 100644 --- a/src/app/following/page.tsx +++ b/src/app/following/page.tsx @@ -1,16 +1,37 @@ +'use client' + import Header from '@/components/ui/layout/Header' import LeftArrow from '@/components/ui/button/LeftArrow' import { UserRow } from '@/components/ui/list/UserRow' +import { useCurrentUser } from '@/api/facades/auth' +import { useFollowingList } from '@/api/facades/user-follow' + +function FollowingList({ userId }: { userId: number }) { + const { data } = useFollowingList(userId, { pageRequest: { page: 0, size: 50 } }) + const users = data?.content ?? [] -const FOLLOWING = [ - { name: '닉네임', initialFollowing: true }, - { name: '닉네임', initialFollowing: true }, - { name: '닉네임', initialFollowing: true }, - { name: '닉네임', initialFollowing: true }, - { name: '닉네임', initialFollowing: true }, -] + if (users.length === 0) { + return

팔로잉이 없습니다.

+ } + + return ( +
    + {users.map((user) => ( + + ))} +
+ ) +} export default function FollowingPage() { + const { data: me } = useCurrentUser() + return (
@@ -20,11 +41,7 @@ export default function FollowingPage() { />
-
    - {FOLLOWING.map((user, idx) => ( - - ))} -
+ {me?.id != null && }
) } diff --git a/src/app/my/_components/MyRecordSection.tsx b/src/app/my/_components/MyRecordSection.tsx index 744aba0..caee4a5 100644 --- a/src/app/my/_components/MyRecordSection.tsx +++ b/src/app/my/_components/MyRecordSection.tsx @@ -4,7 +4,7 @@ import IconBtn from '@/components/ui/button/IconBtn' import { SectionHeader } from '@/app/my/_components/SectionHeader' import { MyFeed } from '@/app/my/_components/MyFeed' -interface MyRecord { +export interface MyRecord { image: string date: string isPopular?: boolean @@ -12,13 +12,14 @@ interface MyRecord { interface Props { records: MyRecord[] + count?: number canRecord?: boolean } -export function MyRecordSection({ records, canRecord = true }: Props) { +export function MyRecordSection({ records, count, canRecord = true }: Props) { return (
- + {records.length === 0 ? (
diff --git a/src/app/my/page.tsx b/src/app/my/page.tsx index 1f4e117..0268f3a 100644 --- a/src/app/my/page.tsx +++ b/src/app/my/page.tsx @@ -12,18 +12,11 @@ import { MyRecordSection } from '@/app/my/_components/MyRecordSection' import { SavedSpotSection } from '@/app/my/_components/SavedSpotSection' import IconBtn from '@/components/ui/button/IconBtn' import { useRouter } from 'next/navigation' +import { useMySpotRecords } from '@/api/facades/spot-record' +import { toMyRecordThumb } from '@/lib/utils/spotRecordToFeed' const INTEREST_FLOWERS = ['동백꽃', '매화', '개나리', '벚꽃', '철쭉'] -const MY_FEEDS = [ - { image: '/images/explore.png', date: 'yy.mm.dd', isPopular: true }, - { image: '/images/explore.png', date: 'yy.mm.dd', isPopular: true }, - { image: '/images/explore.png', date: 'yy.mm.dd', isPopular: true }, - { image: '/images/explore.png', date: 'yy.mm.dd', isPopular: true }, - { image: '/images/explore.png', date: 'yy.mm.dd', isPopular: true }, - { image: '/images/explore.png', date: 'yy.mm.dd', isPopular: true }, -] - const SAVED_SPOTS: SPOTProps[] = [ { id: 1, @@ -50,6 +43,12 @@ const SAVED_SPOTS: SPOTProps[] = [ export default function MyPage() { const router = useRouter() + const { data: myRecords } = useMySpotRecords({ + status: 'PUBLISHED', + pageRequest: { page: 0, size: 6 }, + }) + const records = (myRecords?.content ?? []).map(toMyRecordThumb) + return (
@@ -86,13 +85,17 @@ export default function MyPage() {
{/* 통계 */} - + {/* 관심 식물 */} {/* 내 기록 */} - + {/* 저장한 스팟 */} diff --git a/src/app/my/saved/page.tsx b/src/app/my/saved/page.tsx index 3d25924..a999f5e 100644 --- a/src/app/my/saved/page.tsx +++ b/src/app/my/saved/page.tsx @@ -1,36 +1,27 @@ -import { Bell } from 'lucide-react' +'use client' + import Header from '@/components/ui/layout/Header' import LeftArrow from '@/components/ui/button/LeftArrow' import SpotCard from '@/components/ui/card/SpotCard' import { SPOTProps } from '@/app/search/_components/SpotPanel' import { SavedSpotEmpty } from '@/app/my/_components/SavedSpotEmpty' +import { useFavoriteList } from '@/api/facades/spot-favorite' +import type { SpotFavoriteResponse } from '@/api/generated/peakdaApi.schemas' -const SAVED_SPOTS: SPOTProps[] = [ - { - id: 1, - name: '장소', - location: '경상남도 창원시 진해구', - status: '이제 막요', - nameList: ['벚꽃'], - }, - { - id: 2, - name: '장소', - location: '경상남도 창원시 진해구', - status: '이제 막요', - nameList: ['벚꽃'], - }, - { - id: 3, - name: '장소', - location: '경상남도 창원시 진해구', - status: '이제 막요', - nameList: ['벚꽃'], - }, -] +// 찜 응답에는 name·address 만 있고 개화상태/꽃 태그(seasonal-bloom 소관)는 없어 비워 둔다. +function toSpotProps(fav: SpotFavoriteResponse): SPOTProps { + return { + id: fav.spotId, + name: fav.name, + location: fav.address ?? '', + status: '', + nameList: [], + } +} export default function SavedSpotsPage() { - const spots = SAVED_SPOTS + const { data, isLoading } = useFavoriteList() + const favorites = data?.favorites ?? [] return (
@@ -38,30 +29,28 @@ export default function SavedSpotsPage() {
} center={ -
찜한 스팟({spots.length})
+
+ 찜한 스팟({data?.count ?? 0}) +
} />
- {spots.length === 0 ? ( - - ) : ( - <> - {/* 만개 임박 안내 배너 */} -
-
- - 진해 군항제가 곧 1주일전이에요 · 3.28(토) ~ 4.5(일) -
-
- + {!isLoading && + (favorites.length === 0 ? ( + + ) : (
    - {spots.map((spot) => ( - + {favorites.map((fav) => ( + ))}
- - )} + ))}
) } diff --git a/src/app/record/[id]/edit/page.tsx b/src/app/record/[id]/edit/page.tsx new file mode 100644 index 0000000..3ae814c --- /dev/null +++ b/src/app/record/[id]/edit/page.tsx @@ -0,0 +1,211 @@ +'use client' + +import { useState } from 'react' +import { Plus } from 'lucide-react' +import { useParams, useRouter } from 'next/navigation' +import { cn } from '@/lib/utils/cn' +import Header from '@/components/ui/layout/Header' +import LeftArrow from '@/components/ui/button/LeftArrow' +import Button from '@/components/ui/button/Button' +import DateSelect from '@/components/ui/form/DateSelect' +import Textarea from '@/components/ui/form/Textarea' +import { Badge } from '@/components/ui/display/Badge' +import { PlantSelectDrawer } from '@/app/record/_components/PlantSelectDrawer' +import { usePlants } from '@/api/facades/plant' +import { useSpotRecord, useUpdateSpotRecord } from '@/api/facades/spot-record' +import type { + SpotRecordResponse, + UpdateSpotRecordRequest, +} from '@/api/generated/peakdaApi.schemas' + +const PLANTS_DEFAULT_COUNT = 8 + +const STATUS_OPTIONS = [ + { label: '이르다', value: 'EARLY' }, + { label: '피기 시작', value: 'STARTING' }, + { label: '절정', value: 'PEAK' }, + { label: '늦었다', value: 'LATE' }, +] as const + +type BloomStage = (typeof STATUS_OPTIONS)[number]['value'] + +export default function RecordEditPage() { + const { id } = useParams<{ id: string }>() + const { data: record, isLoading } = useSpotRecord(Number(id)) + + if (isLoading) { + return ( +
+
+
} center={기록 수정} /> +
+

불러오는 중...

+
+ ) + } + + if (!record) { + return ( +
+
+
} center={기록 수정} /> +
+

기록을 찾을 수 없어요

+
+ ) + } + + // 데이터 로드 후 마운트되어 record 값으로 폼 상태를 초기화한다. + return +} + +function RecordEditForm({ record }: { record: SpotRecordResponse }) { + const router = useRouter() + const { data: plants } = usePlants() + const updateRecord = useUpdateSpotRecord() + + const [date, setDate] = useState(record.visitedDate ? record.visitedDate.replaceAll('-', '.') : '') + const [selectedStatus, setSelectedStatus] = useState(record.bloomStage ?? '') + const [memo, setMemo] = useState(record.memo ?? '') + const [selectedPlantIds, setSelectedPlantIds] = useState(record.plants.map((p) => p.id)) + const [plantDrawerOpen, setPlantDrawerOpen] = useState(false) + + const togglePlant = (plantId: number) => + setSelectedPlantIds((prev) => + prev.includes(plantId) ? prev.filter((p) => p !== plantId) : [...prev, plantId] + ) + + // 기본 노출 식물(앞 8개) + 목록 밖에서 선택된 식물(드로어에서 선택)을 함께 노출 + const basePlants = (plants ?? []).slice(0, PLANTS_DEFAULT_COUNT) + const extraSelected = (plants ?? []).filter( + (p) => selectedPlantIds.includes(p.id) && !basePlants.some((b) => b.id === p.id) + ) + const visiblePlants = [...basePlants, ...extraSelected] + + const isValid = selectedPlantIds.length > 0 && selectedStatus !== '' + + // 사진은 수정 UI 범위 밖이라 photoKeys 를 보내지 않아 기존 사진을 유지한다. + const handleSave = () => { + if (!isValid) return + const payload: UpdateSpotRecordRequest = { + visitedDate: date ? date.replaceAll('.', '-') : null, + bloomStage: selectedStatus, + memo, + plantIds: selectedPlantIds, + } + updateRecord.mutate( + { id: record.id, data: payload }, + { onSuccess: () => router.push(`/record/${record.id}`) } + ) + } + + return ( +
+
+
} center={기록 수정} /> +
+ +
+
+

{record.spot.name}

+

식물 종류와 개화 상태, 메모를 수정할 수 있어요.

+
+ + {/* 식물 */} +
+

+ 식물 * + 복수선택 가능 +

+
+ {visiblePlants.map((plant) => { + const isSelected = selectedPlantIds.includes(plant.id) + return ( + togglePlant(plant.id)} + /> + ) + })} + } + variant="ghost" + color="gray" + className="cursor-pointer rounded-xl px-3.5 py-2" + onClick={() => setPlantDrawerOpen(true)} + /> +
+
+ + {/* 상태 */} +
+

+ 상태 * +

+
+ {STATUS_OPTIONS.map((status) => ( + + ))} +
+
+ + {/* 촬영일자 */} +
+

촬영일자

+ +
+ + {/* 메모 */} +
+

+ 메모 (선택) +

+