Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 45 additions & 34 deletions features/mypage/MyTab/TabWrapper/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import PageTabs from "@/components/ui/PageTabs";
import { useQueryParams } from "@/hooks/useQueryParams";
import JoinedMeetingListWrapper from "../../JoinedMeetingList";
Expand All @@ -17,17 +17,14 @@ const TAB_STICKY_OFFSET = {
sm: 48,
md: 88,
} as const;

// STICKY 이후 스크롤 시 스타일 적용 임계값
const THRESHOLD = {
lg: 20,
md: 320,
sm: 220,
} as const;
const SCROLL_CORRECTION = 20;

const STYLE = {
tabWrapper: "sticky top-12 z-10 bg-gray-50 md:top-22 lg:static",
scroll:
"h-4 shadow-[0_13px_16px_rgba(0,0,0,0.08)] overflow-hidden absolute bottom-px left-0 z-0 block w-full",
"h-4 shadow-[0_13px_16px_rgba(0,0,0,0.15)] overflow-hidden absolute bottom-px left-0 z-0 block w-full",
};

const TAB_ITEMS = [
Expand All @@ -49,17 +46,55 @@ export default function TabWrapper() {
const isLg = useMediaQuery(MEDIA_QUERY_LG);
const isMd = useMediaQuery(MEDIA_QUERY_MD);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [scrollThreshold, setScrollThreshold] = useState(0);
const [activeTab, setActiveTab] = useState<TabId>(isTabId(tabQuery) ? tabQuery : TAB_ITEMS[0].id);
const tabAnchorRef = useRef<HTMLDivElement>(null);
const tabRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const threshold = isLg ? THRESHOLD.lg : isMd ? THRESHOLD.md : THRESHOLD.sm;

const isVisible = useScrollVisibility({
threshold,
threshold: scrollThreshold,
targetRef: isLg ? contentRef : undefined,
});

const stickyOffset = isMd ? TAB_STICKY_OFFSET.md : TAB_STICKY_OFFSET.sm;

function getTabStickyScrollTop() {
const anchorTop =
(tabAnchorRef.current?.getBoundingClientRect().top ?? 0) + window.scrollY - stickyOffset;

return Math.max(anchorTop, 0);
}

function scrollToTabContentTop() {
if (isLg) {
contentRef.current?.scrollTo({ top: 0, behavior: "auto" });
return;
}
const tabTop = tabRef.current?.getBoundingClientRect().top ?? 0;
if (tabTop <= stickyOffset + 1) {
window.scrollTo({ top: getTabStickyScrollTop(), behavior: "smooth" });
}
}

function handleTabChange({ id }: { id: string }) {
if (!isTabId(id)) return;

setIsDropdownOpen(false);
scrollToTabContentTop();
setActiveTab(id);
set({ tab: id });
}

// 반응형에 따른 스크롤 이벤트
useLayoutEffect(() => {
if (isLg) {
setScrollThreshold(SCROLL_CORRECTION);
return;
}
setScrollThreshold(getTabStickyScrollTop() + SCROLL_CORRECTION);
}, [isLg, isMd]);

// 잘못된 URL 수정
useEffect(() => {
if (tabQuery !== null && !isTabId(tabQuery)) {
Expand All @@ -82,37 +117,13 @@ export default function TabWrapper() {
WrittenReviewList: <WrittenReviewListWrapper onDropdownOpenChange={setIsDropdownOpen} />,
};

function scrollToTabContentTop() {
if (isLg) {
contentRef.current?.scrollTo({ top: 0, behavior: "auto" });
return;
}

const stickyOffset = isMd ? TAB_STICKY_OFFSET.md : TAB_STICKY_OFFSET.sm;
const anchorTop =
(tabAnchorRef.current?.getBoundingClientRect().top ?? 0) + window.scrollY - stickyOffset;

window.scrollTo({
top: Math.max(anchorTop, 0),
behavior: "smooth",
});
}
return (
<div className="min-w-0 grow">
<div ref={tabAnchorRef} />
<div className={STYLE.tabWrapper} ref={tabRef}>
<div className={cn("relative z-1")}>
<div className={isVisible ? STYLE.scroll : ""} />
<PageTabs
key={activeTab}
defaultId={activeTab}
onChange={({ id }) => {
const nextTab = id as TabId;
setIsDropdownOpen(false);
scrollToTabContentTop();
setActiveTab(nextTab);
set({ tab: id });
}}>
<PageTabs key={activeTab} defaultId={activeTab} onChange={handleTabChange}>
{TAB_ITEMS.map((tabItem) => (
<PageTabs.Item
key={tabItem.id}
Expand Down
74 changes: 17 additions & 57 deletions features/mypage/apis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import { clientFetch } from "@/libs/clientFetch";
import { mapJoinedMeeting, mapMeReviews, mapUsersMeMeetings } from "./mapper";
import { throwApiError } from "@/utils/api";
import { MYPAGE_MESSAGES } from "./message";

export interface BaseListParams {
sortBy?: string;
Expand Down Expand Up @@ -68,9 +69,7 @@ async function mypageFetch<ApiItem, MappedItem, TParams extends object>(

const res = await clientFetch(url);

if (!res.ok) {
throw new Error(`목록 조회 실패: ${res.status}`);
}
await throwApiError(res, MYPAGE_MESSAGES.fetchListError);

const json = await res.json();

Expand Down Expand Up @@ -123,7 +122,11 @@ export async function patchMeetingsStatus({
body: JSON.stringify({ status }),
});

await throwApiError(res, "모임 상태 변경에 실패했습니다.");
await throwApiError(res, MYPAGE_MESSAGES.patchMeetingStatusError, {
statusMessages: {
404: MYPAGE_MESSAGES.deleteMeetingNotFoundError,
},
});
}

// 모임 삭제 하기
Expand All @@ -132,7 +135,11 @@ export async function deleteMeetings({ meetingId }: { meetingId: number }): Prom
method: "DELETE",
});

await throwApiError(res, "모임 삭제에 실패했습니다.");
await throwApiError(res, MYPAGE_MESSAGES.deleteMeetingError, {
statusMessages: {
404: MYPAGE_MESSAGES.deleteMeetingNotFoundError,
},
});
}

// 모임 참여 취소 하기
Expand All @@ -141,7 +148,7 @@ export async function deleteMeetingsJoin({ meetingId }: { meetingId: number }):
method: "DELETE",
});

await throwApiError(res, "모임 참여 취소에 실패했습니다.");
await throwApiError(res, MYPAGE_MESSAGES.deleteMeetingJoinError);
}

// 리뷰 작성 하기
Expand All @@ -153,7 +160,7 @@ export async function postMeetingsReviews({
method: "POST",
body: JSON.stringify(reviewFormValues),
});
await throwApiError(res, "리뷰 작성에 실패했습니다.");
await throwApiError(res, MYPAGE_MESSAGES.createReviewError);
}

// 리뷰 수정 하기
Expand All @@ -165,62 +172,15 @@ export async function patchReviews({
method: "PATCH",
body: JSON.stringify(reviewFormValues),
});
await throwApiError(res, "리뷰 수정에 실패했습니다.");
await throwApiError(res, MYPAGE_MESSAGES.updateReviewError);
}

// 리뷰 삭제 하기
export async function deleteReviews({ reviewId }: { reviewId: number }): Promise<void> {
const res = await clientFetch(`/reviews/${reviewId}`, {
method: "DELETE",
});
await throwApiError(res, "리뷰 삭제에 실패했습니다.");
}

// 찜 추가
export async function postMeetingsFavorites(meetingId: number): Promise<void> {
const res = await clientFetch(`/meetings/${meetingId}/favorites`, {
method: "POST",
});
await throwApiError(res, "찜 추가에 실패했습니다.");
}

// 찜 해제
export async function deleteMeetingsFavorites(meetingId: number): Promise<void> {
const res = await clientFetch(`/meetings/${meetingId}/favorites`, {
method: "DELETE",
});
await throwApiError(res, "찜 해제에 실패했습니다.");
}

// image 업로드
export async function uploadProfileImage(file: File): Promise<string> {
// presigned URL
const presignedResponse = await clientFetch("/images/presigned", {
method: "POST",
body: JSON.stringify({
fileName: file.name,
contentType: file.type,
}),
});

await throwApiError(presignedResponse, "이미지 업로드 URL 발급에 실패했습니다.");

// public URL
const { presignedUrl, publicUrl } = await presignedResponse.json();

const uploadResponse = await fetch(presignedUrl, {
method: "PUT",
headers: {
"Content-Type": file.type,
},
body: file,
});

if (!uploadResponse.ok) {
throw new Error("이미지 업로드에 실패했습니다.");
}

return publicUrl;
await throwApiError(res, MYPAGE_MESSAGES.deleteReviewError);
}

// 유저 프로필
Expand All @@ -229,7 +189,7 @@ export async function patchUsersMe(user: PatchUserProfilePayload): Promise<User>
method: "PATCH",
body: JSON.stringify(user),
});
await throwApiError(res, "프로필 수정에 실패했습니다.");
await throwApiError(res, MYPAGE_MESSAGES.updateProfileError);

return res.json();
}
23 changes: 23 additions & 0 deletions features/mypage/message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
const RETRY_LATER_SUFFIX = "\n잠시 후 다시 시도해주세요.";

export const MYPAGE_MESSAGES = {
fetchListError: "목록 조회에 실패했습니다." + RETRY_LATER_SUFFIX,
patchMeetingStatusSuccess: (status: "CONFIRMED" | "CANCELED") =>
`모임이 ${status === "CONFIRMED" ? "확정" : "취소"}되었습니다.`,
patchMeetingStatusError: "모임 상태 변경에 실패했습니다." + RETRY_LATER_SUFFIX,
deleteMeetingSuccess: "모임이 삭제 되었습니다.",
deleteMeetingError: "모임 삭제에 실패했습니다." + RETRY_LATER_SUFFIX,
deleteMeetingNotFoundError: "이미 삭제된 모임입니다.",
deleteMeetingJoinSuccess: "모임 예약이 취소 되었습니다.",
deleteMeetingJoinError: "모임 참여 취소에 실패했습니다." + RETRY_LATER_SUFFIX,
createReviewSuccess: "리뷰가 작성 되었습니다.",
createReviewError: "리뷰 작성에 실패했습니다." + RETRY_LATER_SUFFIX,
updateReviewSuccess: "리뷰가 수정 되었습니다.",
updateReviewError: "리뷰 수정에 실패했습니다." + RETRY_LATER_SUFFIX,
deleteReviewSuccess: "리뷰가 삭제 되었습니다.",
deleteReviewError: "리뷰 삭제에 실패했습니다." + RETRY_LATER_SUFFIX,
updateProfileSuccess: "프로필이 수정되었습니다.",
updateProfileError: "프로필 수정에 실패했습니다." + RETRY_LATER_SUFFIX,
uploadProfileImageSuccess: "이미지가 업로드되었습니다.",
uploadProfileImageError: "이미지 업로드에 실패했습니다." + RETRY_LATER_SUFFIX,
};
Loading
Loading