Skip to content
Draft
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
6 changes: 6 additions & 0 deletions src/features/events/EventsDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";

import EventsDetailContent from "@/features/events/components/EventsDetailContent";
import EventsDetailSkeleton from "@/features/events/components/EventsDetailSkeleton";
import { EVENTS_TAB_PATHS } from "@/features/events/constants/events";

// 행사 상세 화면 — 이동 같은 화면 동작만 정하고, 행사 데이터를 받는 동안은 상세 배치를 따른
// 스켈레톤을 보여준다. 헤더도 데이터(사진 유무)에 따라 달라서(EventsDetailContent 주석 참고) 화면 전체가 데이터 영역이다.
Expand All @@ -18,6 +19,11 @@ function EventsDetailScreen() {
navigate(`/events/${id}/apply`, { viewTransition: true })
}
onBack={() => navigate(-1)}
onViewApplication={(applicationId) =>
navigate(`${EVENTS_TAB_PATHS.application}/${applicationId}`, {
viewTransition: true,
})
}
/>
</Suspense>
);
Expand Down
5 changes: 5 additions & 0 deletions src/features/events/EventsListScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ function EventsListScreen({ tab }: EventsListScreenProps) {
onSelect={(eventId) =>
navigate(`/events/${eventId}`, { viewTransition: true })
}
onViewApplication={(applicationId) =>
navigate(`${EVENTS_TAB_PATHS.application}/${applicationId}`, {
viewTransition: true,
})
}
statusFilter={listStatusFilter}
/>
</Suspense>
Expand Down
43 changes: 32 additions & 11 deletions src/features/events/components/EventsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import type { EventListItem } from "@/shared/entities/events/eventsTypes";
interface EventsCardProps {
event: EventListItem;
onApply?: () => void;
/** 이미 신청한 행사의 "신청완료" 버튼 탭 — 신청내역 상세로 보낸다 */
onViewApplication?: (applicationId: number) => void;
/** 카드(CTA 제외 영역) 탭 — 상세 화면으로 보낸다 */
onSelect?: () => void;
}
Expand All @@ -22,8 +24,16 @@ interface EventsCardProps {
// 색 보정이 필요 없다 — WDS Button의 size="small"이 padding 7/14·radius 8·label2로 Figma와 맞고,
// disabled 상태 색(label.assistive + interaction.disable)도 모집예정/모집종료 스펙과 그대로 일치한다
// (button/style.js의 &[aria-disabled='true'] 블록 확인). 그래서 sx 없이 disabled prop만 쓴다.
function EventsCard({ event, onApply, onSelect }: EventsCardProps) {
const { title, recruitStatus, thumbnailUrl } = event;
//
// 이미 신청한 행사는 모집 상태와 상관없이 "신청완료"를 outlined로 그리고, 누르면 신청내역 상세로 보낸다.
// Figma에 이 상태가 없어서 정한 것이다 — disabled로 막으면 모집예정·모집종료와 같은 회색이 돼 구분되지 않는다.
function EventsCard({
event,
onApply,
onViewApplication,
onSelect,
}: EventsCardProps) {
const { title, recruitStatus, thumbnailUrl, applicationId } = event;
const isOpen = recruitStatus === "OPEN";

return (
Expand Down Expand Up @@ -70,15 +80,26 @@ function EventsCard({ event, onApply, onSelect }: EventsCardProps) {
</button>

<div className="flex justify-end">
<Button
color="primary"
disabled={!isOpen}
onClick={onApply}
size="small"
variant="solid"
>
{getEventActionLabel(event)}
</Button>
{applicationId === null ? (
<Button
color="primary"
disabled={!isOpen}
onClick={onApply}
size="small"
variant="solid"
>
{getEventActionLabel(event)}
</Button>
) : (
<Button
color="primary"
onClick={() => onViewApplication?.(applicationId)}
size="small"
variant="outlined"
>
신청완료
</Button>
)}
</div>
</div>
</div>
Expand Down
30 changes: 24 additions & 6 deletions src/features/events/components/EventsDetailContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ interface EventsDetailContentProps {
eventId: string;
onBack: () => void;
onApply: (eventId: string) => void;
onViewApplication: (applicationId: string) => void;
}

// Figma: 행사 상세 (nodeId 1133:42433 모집중 / 1156:53992 모집예정 / 1156:54125 모집종료)
Expand All @@ -43,10 +44,13 @@ interface EventsDetailContentProps {
// - 모집중: 활성. 단 신청 자격(isFeeEligible)이 없으면 disabled에 "학생회비 납부자만 신청할 수 있어요"
// - 모집예정: disabled에 신청 시작 일시("8월 10일 18시 오픈")
// - 모집종료: Figma "CTA 없음"(1156:54125)대로 하단 버튼(Action Area)을 그리지 않는다
// 이미 신청한 행사는 모집 상태와 상관없이 하단 버튼이 "신청내역 보기"가 돼 신청내역 상세로 보낸다 —
// 모집종료여도 내 신청을 확인하러 올 수 있어서 버튼을 그린다. Figma에 이 상태가 없어서 정한 것이다.
function EventsDetailContent({
eventId,
onBack,
onApply,
onViewApplication,
}: EventsDetailContentProps) {
const { data: event } = useSuspenseQuery(eventsQueries.detail(eventId));
const photoCount = event?.images.length ?? 0;
Expand Down Expand Up @@ -85,11 +89,25 @@ function EventsDetailContent({
// 레거시 납부자를 미납으로 보여서 쓰지 않는다
const isFeeRequired = !event.isFeeEligible;
const canApply = event.recruitStatus === "OPEN" && !isFeeRequired;
const actionLabel =
const applyLabel =
event.recruitStatus === "OPEN" && isFeeRequired
? EVENTS_FEE_PAID_ONLY_LABEL
: getEventDetailActionLabel(event);
const hasActionArea = event.recruitStatus !== "CLOSED";
const { applicationId } = event;
const action =
applicationId === null
? {
disabled: !canApply,
label: applyLabel,
onClick: () => onApply(eventId),
}
: {
disabled: false,
label: "신청내역 보기",
onClick: () => onViewApplication(String(applicationId)),
};
const hasActionArea =
applicationId !== null || event.recruitStatus !== "CLOSED";

return (
// Figma 상세 루트 배경은 Background/Normal/Normal(#FFFFFF)이다 — ScreenLayout 기본
Expand Down Expand Up @@ -218,7 +236,7 @@ function EventsDetailContent({
{/* Action Area가 본문을 덮으므로 끝부분이 영구히 가리지 않도록 같은 높이를 비워둔다.
96px = Action Area 위아래 padding 20+20 + 버튼 56. 데스크톱 프레임에서는
홈 인디케이터 여백 14px이 더 붙는다(아래 filler와 같은 규칙).
모집종료는 Action Area가 없어서 이 여백도 두지 않는다 — Figma(1156:54125)도 본문에서 끝난다. */}
신청하지 않은 모집종료는 Action Area가 없어서 이 여백도 두지 않는다 — Figma(1156:54125)도 본문에서 끝난다. */}
{hasActionArea && <div className="h-24 sm:h-[110px]" />}
</div>

Expand All @@ -228,11 +246,11 @@ function EventsDetailContent({
{/* WDS ActionAreaButton은 항상 Button size="large"(padding 12px 28px → 48px)로 그리는데,
Figma Main Action은 padding 16px 28px(56px)이라 세로 padding만 sx로 맞춘다. */}
<ActionAreaButton
disabled={!canApply}
onClick={() => onApply(eventId)}
disabled={action.disabled}
onClick={action.onClick}
sx={{ paddingBlock: "16px" }}
>
{actionLabel}
{action.label}
</ActionAreaButton>
</ActionArea>
{/* Figma Action Area(110px)는 버튼 아래가 iOS Home Bar 여백까지 합쳐 34px인데,
Expand Down
5 changes: 5 additions & 0 deletions src/features/events/components/EventsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ interface EventsListProps {
statusFilter: EventStatusFilter;
onSelect: (eventId: string) => void;
onApply: (eventId: string) => void;
onViewApplication: (applicationId: string) => void;
onBrowseArchives: () => void;
}

Expand All @@ -48,6 +49,7 @@ function EventsList({
statusFilter,
onSelect,
onApply,
onViewApplication,
onBrowseArchives,
}: EventsListProps) {
// 모집 상태는 서버가 걸러 준다 — 페이지로 나눠 받아서 받은 것만 거르면 다음 페이지의 행사가 빠진다
Expand Down Expand Up @@ -104,6 +106,9 @@ function EventsList({
event={event}
onApply={() => onApply(String(event.eventId))}
onSelect={() => onSelect(String(event.eventId))}
onViewApplication={(applicationId) =>
onViewApplication(String(applicationId))
}
/>
</Fragment>
))}
Expand Down
4 changes: 4 additions & 0 deletions src/features/search/components/SearchCategoryItems.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Fragment, type ReactNode } from "react";
import { Link, useNavigate } from "react-router-dom";
import BillilgeItemCard from "@/features/billilge/components/BillilgeItemCard";
import EventsCard from "@/features/events/components/EventsCard";
import { EVENTS_TAB_PATHS } from "@/features/events/constants/events";
import FeedbacksCard from "@/features/feedbacks/components/FeedbacksCard";
import {
formatFeedbackRound,
Expand Down Expand Up @@ -63,6 +64,9 @@ function SearchCategoryItems({ entries, footer }: SearchCategoryItemsProps) {
event={event}
onApply={() => navigate(`/events/${event.eventId}/apply`)}
onSelect={() => navigate(`/events/${event.eventId}`)}
onViewApplication={(applicationId) =>
navigate(`${EVENTS_TAB_PATHS.application}/${applicationId}`)
}
/>
),
}));
Expand Down
5 changes: 3 additions & 2 deletions src/shared/entities/events/eventsKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import type { EventRecruitStatus } from "@/shared/entities/events/eventsTypes";
// 조회 캐시 key. 조회와 무효화가 같은 함수로 key를 만들어서, 한쪽만 바뀌어 무효화가 빗나가지 않게 한다.
// 행사 목록은 모집 상태 필터·검색어마다 따로 받아서 lists() 아래에 둔다 — lists() 하나로 모든 필터를 같이 무효화한다.
// 신청내역 상세는 목록 key 아래에 둬서, 목록 key 하나로 목록·상세를 같이 무효화한다.
// 행사 상세는 details() 아래에 둔다 — 신청 취소는 어느 행사인지 받지 않아서 모든 상세를 같이 무효화한다.
export const eventsKeys = {
root: () => ["events"] as const,
lists: () => [...eventsKeys.root(), "list"] as const,
list: (recruitStatus: EventRecruitStatus | null, keyword: string | null) =>
[...eventsKeys.lists(), recruitStatus, keyword] as const,
detail: (eventId: string) =>
[...eventsKeys.root(), "detail", eventId] as const,
details: () => [...eventsKeys.root(), "detail"] as const,
detail: (eventId: string) => [...eventsKeys.details(), eventId] as const,
form: (eventId: string) => [...eventsKeys.root(), "form", eventId] as const,
applications: () => [...eventsKeys.root(), "applications"] as const,
application: (applicationId: string) =>
Expand Down
15 changes: 13 additions & 2 deletions src/shared/entities/events/eventsQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
EventRecruitStatus,
} from "@/shared/entities/events/eventsTypes";
import { homeKeys } from "@/shared/entities/home/homeKeys";
import { searchKeys } from "@/shared/entities/search/searchKeys";

// 서버 enum(RecruitStatus·RecruitType·QuestionType·EventApplicationStatus) 값 — 타입은 eventsTypes.ts가 여기서 만든다
export const EVENT_RECRUIT_STATUSES = [
Expand Down Expand Up @@ -44,6 +45,8 @@ export const eventListItemSchema = z.looseObject({
recruitStatus: z.enum(EVENT_RECRUIT_STATUSES),
// 신청 마감일까지 남은 날 수(0이면 오늘 마감). 모집중일 때만 있다
daysUntilDeadline: z.number().nullable(),
// 이 학생이 낸 신청 중 취소하지 않은 것의 ID(stream-server#302). 신청하지 않았거나 취소했으면 null이다
applicationId: z.number().nullable(),
});

const eventListPageSchema = z.looseObject({
Expand Down Expand Up @@ -75,6 +78,8 @@ export const eventDetailSchema = z.looseObject({
// 이 학생이 학생회비 조건을 채우는지. 납부자 전용이 아니면 항상 true, 납부자 전용이면 납부자 명부에 있을 때만 true다.
// 모집 기간·정원은 반영하지 않아서 recruitStatus와 같이 본다
isFeeEligible: z.boolean(),
// 목록 항목과 같다 — 신청했으면 모집 상태와 상관없이 있다
applicationId: z.number().nullable(),
});

// 신청서 질문 — 양식 조회와 신청 상세가 같이 싣는 부분. 화면에는 eventsQuestions.ts에서 EventsQuestion으로 바꿔 넘긴다
Expand Down Expand Up @@ -270,7 +275,8 @@ export const eventsQueries = {
};

// 행사 신청(POST /v1/app/events/{eventId}/applications). 신청이 생기고 정원이 차서 모집 상태가 바뀔 수도 있어서
// 신청내역과 행사 목록·상세를 무효화한다. 홈의 신청 안내 카드(신청 여부)와 신청한 행사도 바뀌어서 홈도 무효화한다.
// 신청내역과 행사 목록·상세를 무효화한다. 홈의 신청 안내 카드(신청 여부)와 신청한 행사도 바뀌어서 홈도 무효화하고,
// 통합 검색의 행사 카드도 신청 여부(applicationId)를 그려서 같이 무효화한다.
// 마감·이미 신청·신청 자격 없음으로 실패해도 지금 캐시가 낡았다는 뜻이라 같이 무효화한다.
// 신청서 양식은 무효화하지 않는다 — 신청서 화면이 떠 있는 동안 다시 받으면 마감된 양식이 화면을 마감으로 보낸다.
// 응답 형식이 달라도 서버에는 이미 신청이 생겼으니 실패로 바꾸지 않고 null을 돌려준다(사물함 신청과 같다).
Expand All @@ -282,6 +288,7 @@ export function useApplyEventMutation(eventId: string) {
queryClient.invalidateQueries({ queryKey: eventsKeys.lists() }),
queryClient.invalidateQueries({ queryKey: eventsKeys.detail(eventId) }),
queryClient.invalidateQueries({ queryKey: homeKeys.root() }),
queryClient.invalidateQueries({ queryKey: searchKeys.root() }),
]);

return useMutation({
Expand Down Expand Up @@ -314,7 +321,8 @@ export function useApplyEventMutation(eventId: string) {

// 신청 취소(PATCH /v1/app/events/applications/{applicationId}/cancel). 신청 기록은 남고 상태만 취소로 바뀐다.
// 성공하면 신청내역 목록·상세를 다시 받아 취소 상태를 보여 준다. 홈의 신청한 행사·신청 안내 카드도 바뀌어서
// 홈도 무효화한다. 이미 취소된 신청이었다면 지금 캐시가 낡았다는 뜻이라 똑같이 다시 받는다.
// 홈도 무효화한다. 행사 목록·상세·통합 검색도 신청 여부(applicationId)를 그려서 무효화한다 — 취소는 어느 행사인지
// 받지 않아서 행사 상세는 모두 무효화한다. 이미 취소된 신청이었다면 지금 캐시가 낡았다는 뜻이라 똑같이 다시 받는다.
export function useCancelEventApplicationMutation() {
const queryClient = useQueryClient();
// Promise를 돌려줘서 다시 받은 데이터가 들어온 뒤에 mutation이 끝나게 한다 —
Expand All @@ -330,6 +338,9 @@ export function useCancelEventApplicationMutation() {
queryKey: eventsKeys.applications(),
}),
queryClient.invalidateQueries({ queryKey: homeKeys.root() }),
queryClient.invalidateQueries({ queryKey: eventsKeys.lists() }),
queryClient.invalidateQueries({ queryKey: eventsKeys.details() }),
queryClient.invalidateQueries({ queryKey: searchKeys.root() }),
]);

return useMutation({
Expand Down
Loading