diff --git a/docs/conventions/wds-component-usage.md b/docs/conventions/wds-component-usage.md index 4beaefb..2aca321 100644 --- a/docs/conventions/wds-component-usage.md +++ b/docs/conventions/wds-component-usage.md @@ -504,7 +504,7 @@ WDS에 이름이 비슷한 `ProgressIndicator`가 실제로 존재해서(`제외 | 블록 | Figma | 코드 | |---|---|---| -| `lockerGroup` | Zone Area + Locker Grid | `LockersLockerGrid`(`bordered`면 `Line/Solid/Alternative` 테두리 상자) | +| `lockerGroup` | Zone Area + Locker Grid | `LockersGrid`(`bordered`면 `Line/Solid/Alternative` 테두리 상자) | | `label` | Direction Label·Aisle | `LockersMapLabel` — 세로면 한 글자씩 줄바꿈하고 단어 사이에 빈 줄 | | `area` | Room Label·Zone Label·Stairs Area | `LockersMapArea` — 구역 선택 평면도와 같은 점선 상자 | | `shelfLabel` | Shelf Label | `LockersShelfLabel` | @@ -620,7 +620,22 @@ Figma는 헤더 배경이 없어서 배너 그림자(`Shadow/Spread/Small`, 60px ### 빈 상태는 WDS `FallbackView`가 아니라 홈 로컬 `HomeEmptyState` -일러스트 44px + 문구 한 줄 + 작은 버튼이라 행사 목록 Empty State 때와 같은 이유(`FallbackView`의 일러스트 폭·패딩 고정)로 맞지 않는다. 그림은 빌릴게 반납 빈 상태(`icons/bililge-empty/return-items.svg`)·행사 목록 빈 상태(`icons/events/empty-events.svg`)와 같은 도형이라 크기만 줄여 그대로 쓴다. +일러스트 44px + 문구 한 줄 + 작은 버튼이라 행사 목록 Empty State 때와 같은 이유(`FallbackView`의 일러스트 폭·패딩 고정)로 맞지 않는다. 그림은 빌릴게 반납 빈 상태(`icons/bililge-empty/return-items.svg`)·행사 목록 빈 상태(`icons/common/empty-events.svg`)와 같은 도형이라 크기만 줄여 그대로 쓴다. + +## 내 내역 화면(열린피드백 작성내역 `3147:147748`·`3147:147818`·`3147:147756`·`3147:147779` / 학생회비 납부내역 `3147:147860`·`3147:147879`·`3147:147841` / 사물함 배정 상태 `3147:148357`·`3147:148331`) 구현 중 확정된 매핑 + +| Figma | 코드 | 확인 내용 | +|---|---|---| +| `Content Badge` (답변완료·답변대기, 납부확인중·확인필요·납부완료, 배정완료) | `ContentBadge size="small" variant="solid" color="accent"` | 홈 내 정보 목록과 같은 상태 이름·색이다. 초록 green, 주황 orange, 빨강 red. 상태→배지 매핑은 `FEE_STATUS_BADGES`(`features/fee/constants`)·`LOCKER_STATUS_BADGES`(`features/lockers/constants`) 한 곳에 두고 홈과 같이 쓴다 | +| 작성내역 상세 `해당 회차 답변 모아보기` | `Button color="primary" size="medium" variant="outlined"` | | +| 학생회비 `문의하기`, 사물함 `사물함 위치 보기` | `Button color="assistive" size="medium" variant="outlined" fullWidth` | 위치 보기의 꺾쇠는 `trailingContent={}` | +| 빈 상태 버튼 `열린피드백 작성하기` / `열린피드백 보러 가기` | `Button color="assistive" size="small" variant="solid"` | 행사 목록 빈 상태(outlined)와 variant가 다르다 | +| 뒤로가기 헤더 | `ScreenHeader variant="normal"` + `TopNavigationButton` | `useBackNavigation(fallbackPath)`(`hooks/`) — 바로 들어와 뒤로 갈 기록이 없으면 홈(상세는 목록, 위치 보기는 배정 상태)으로 보낸다. 대비 경로는 각 기능 `constants`의 경로 상수(`FEE_PAYMENTS_PATH`·`MY_FEEDBACKS_PATH`·`LOCKER_ASSIGNMENT_PATH` 등)를 쓰고, 데이터가 없을 때의 ``도 화면이 넘긴 `fallbackPath`로 간다 | + +- **카드·빈 상태는 Stream 로컬**: 작성내역 카드(`FeedbacksHistoryCard`), 납부 카드(`FeePaymentCard`), 배정 카드는 흰 바탕 `rounded-xl p-4` 로컬 카드다. 빈 상태는 행사 목록 Empty State와 같은 이유(`FallbackView`의 일러스트 크기·패딩 고정)로 로컬이다. 열린피드백은 `FeedbacksEmptyState`(그림 `icons/feedbacks/empty-feedback.svg`)를 쓰고, 사물함은 `LockersEmptyState`(그림은 행사 목록과 같은 `icons/common/empty-events.svg`)를 쓴다. +- **답변 강조 표시는 두 상세 화면이 공유한다**: 답변 글의 `**굵게**`를 볼드 `Typography`로 바꾸는 `FeedbacksAnswerText`를 모아보기 상세와 작성내역 상세가 같이 쓴다. +- **사물함 위치 보기(`/my/locker/location`)는 Figma에 없다**: 칸 선택 화면 배치를 보기 전용으로 쓴다. 칸 영역(미니맵·확대 스크롤)은 `LockersMapView`로 두 화면이 공유하고, 범례는 화면이 `legend`로 넘긴다(신청 ``, 위치 보기 `` — "내 사물함" 하나). 내 칸은 배정 정보(`lockerLabel`) 하나에서 정해서 `selectedLockerNumber`로 넘기고, 선택된 칸 모양(Primary/Normal 배경 + 흰 번호)으로 그린다. 보기 전용 여부는 `onSelect`가 없는지로만 판단하고, 이때 칸을 버튼으로 그리지 않는다. 처음 열 때 선택된 칸(`data-selected`)이 화면 밖이면 보일 만큼만 스크롤한다. 신청 화면의 내 칸(구역 목데이터 `isMine`, 초록 테두리, 선택 불가)은 보기 전용에서는 그리지 않는다. +- **Figma와 다르게 둔 값**: 요일은 날짜로 계산해서 Figma의 요일 표기와 다를 수 있다. 사물함 번호는 칸 배치 목데이터가 있는 A-1 구역에 맞춰 A-7로 두었다(Figma는 B-25). 사물함 빈 상태 문구의 연도는 기기 시계의 올해다(API 연동 때 서버 값으로 — 코드에 TODO). 날짜는 `lib/date`의 `formatDate`·`formatDateTime`으로 맞추고, 작성내역 상세만 Figma(`3147:147756`)대로 `2026-04-25` 하이픈 표기다. ## 알림(`3595:107100` 목록 / `3595:107112` empty / `3628:110837` 설정) 구현 중 확정된 매핑 diff --git a/src/app/router.tsx b/src/app/router.tsx index 1604574..f9dd831 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -16,14 +16,19 @@ import EventsApplicationCompleteScreen from "@/features/events/EventsApplication import EventsApplicationScreen from "@/features/events/EventsApplicationScreen"; import EventsDetailScreen from "@/features/events/EventsDetailScreen"; import EventsListScreen from "@/features/events/EventsListScreen"; +import FeePaymentsScreen from "@/features/fee/FeePaymentsScreen"; import FeedbacksDetailScreen from "@/features/feedbacks/FeedbacksDetailScreen"; import FeedbacksListScreen from "@/features/feedbacks/FeedbacksListScreen"; +import FeedbacksMineDetailScreen from "@/features/feedbacks/FeedbacksMineDetailScreen"; +import FeedbacksMineScreen from "@/features/feedbacks/FeedbacksMineScreen"; import FeedbacksNewScreen from "@/features/feedbacks/FeedbacksNewScreen"; import HomeScreen from "@/features/home/HomeScreen"; import LockersApplyCompleteScreen from "@/features/lockers/LockersApplyCompleteScreen"; import LockersApplyFailureScreen from "@/features/lockers/LockersApplyFailureScreen"; import LockersApplyScreen from "@/features/lockers/LockersApplyScreen"; -import LockersLockerSelectScreen from "@/features/lockers/LockersLockerSelectScreen"; +import LockersAssignmentScreen from "@/features/lockers/LockersAssignmentScreen"; +import LockersCellSelectScreen from "@/features/lockers/LockersCellSelectScreen"; +import LockersMineScreen from "@/features/lockers/LockersMineScreen"; import LockersSectionSelectScreen from "@/features/lockers/LockersSectionSelectScreen"; import NoticesDetailScreen from "@/features/notices/NoticesDetailScreen"; import NoticesListScreen from "@/features/notices/NoticesListScreen"; @@ -83,6 +88,42 @@ const routes = [ } satisfies ScreenRouteHandle, path: "/feedbacks/:feedbackId", }, + { + element: , + // 내 내역 화면 — 하단 탭 없이 회색 배경(기본값) + handle: { hasBottomNav: false } satisfies ScreenRouteHandle, + path: "/my/feedbacks", + }, + { + element: , + // 작성내역 상세 — 열린피드백 상세와 같은 흰 배경, 하단 탭 없음 + handle: { + background: "normal", + hasBottomNav: false, + } satisfies ScreenRouteHandle, + path: "/my/feedbacks/:feedbackId", + }, + { + element: , + // 내 내역 화면 — 하단 탭 없이 회색 배경(기본값) + handle: { hasBottomNav: false } satisfies ScreenRouteHandle, + path: "/my/fee", + }, + { + element: , + // 내 내역 화면 — 하단 탭 없이 회색 배경(기본값) + handle: { hasBottomNav: false } satisfies ScreenRouteHandle, + path: "/my/locker", + }, + { + element: , + // 칸 선택 화면과 같은 흰 배경 + handle: { + background: "normal", + hasBottomNav: false, + } satisfies ScreenRouteHandle, + path: "/my/locker/location", + }, { element: , // Bottom Nav 대신 하단 고정 버튼(Action Area)이 있는 화면 @@ -177,7 +218,7 @@ const routes = [ path: "/lockers/apply/sections", }, { - element: , + element: , // 구역 선택 화면과 같다 — 하단 고정 선택 영역이 있고, 칸 배경이 흰 면 위에 놓인다 handle: { background: "normal", diff --git a/src/assets/icons/home/arrow-right.svg b/src/assets/icons/common/arrow-right.svg similarity index 100% rename from src/assets/icons/home/arrow-right.svg rename to src/assets/icons/common/arrow-right.svg diff --git a/src/assets/icons/events/empty-events.svg b/src/assets/icons/common/empty-events.svg similarity index 100% rename from src/assets/icons/events/empty-events.svg rename to src/assets/icons/common/empty-events.svg diff --git a/src/assets/icons/feedbacks/empty-feedback.svg b/src/assets/icons/feedbacks/empty-feedback.svg new file mode 100644 index 0000000..8ca4539 --- /dev/null +++ b/src/assets/icons/feedbacks/empty-feedback.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/entities/fee/feeApi.ts b/src/entities/fee/feeApi.ts new file mode 100644 index 0000000..61e4fd5 --- /dev/null +++ b/src/entities/fee/feeApi.ts @@ -0,0 +1,8 @@ +import { FEE_PAYMENTS_MOCK } from "@/entities/fee/feeMock"; +import type { FeePayment } from "@/entities/fee/types"; +import { mockupApi } from "@/lib/mockupApi"; + +// 실 API가 붙으면 함수 안쪽만 요청 코드로 바꾼다 — 화면은 이 함수로만 데이터를 받는다. +export function fetchFeePayments(): Promise { + return mockupApi("fee/payments", () => FEE_PAYMENTS_MOCK); +} diff --git a/src/entities/fee/feeMock.ts b/src/entities/fee/feeMock.ts new file mode 100644 index 0000000..a7ef8ca --- /dev/null +++ b/src/entities/fee/feeMock.ts @@ -0,0 +1,57 @@ +import type { FeePayment } from "@/entities/fee/types"; + +const TITLE = "소프트웨어융합대학 학생회비"; + +// Figma: 학생회비 납부내역 납부확인중 (nodeId 3147:147860), 확인필요 (3147:147879), 납부완료 (3147:147841) +const CHECKING: FeePayment = { + amount: 150000, + confirmedAt: null, + id: "2026-2", + paidAt: "2026-09-04T11:49", + status: "checking", + title: TITLE, +}; + +const NEEDS_CHECK: FeePayment = { + amount: null, + confirmedAt: null, + id: "2026-2", + paidAt: "2026-09-04T11:49", + reviewNote: + "입금자 정보를 확인하지 못했어요. 문제가 있을 경우 학생회에 문의해 주세요.", + status: "needs-check", + title: TITLE, +}; + +const PAID: FeePayment = { + amount: 150000, + confirmedAt: "2026-09-05T13:30", + id: "2026-2", + paidAt: "2026-09-04T11:49", + status: "paid", + title: TITLE, +}; + +// 여러 학기 내역이 쌓인 경우 — 최신순 +const MULTIPLE: FeePayment[] = [ + NEEDS_CHECK, + { + ...PAID, + confirmedAt: "2026-03-05T13:30", + id: "2026-1", + paidAt: "2026-03-04T11:49", + }, +]; + +// 상태를 바꿔 납부내역 화면을 확인한다 +type FeeMockState = "checking" | "needs-check" | "paid" | "multiple"; +const FEE_MOCK_STATE: FeeMockState = "multiple"; + +const FEE_PAYMENTS: Record = { + checking: [CHECKING], + multiple: MULTIPLE, + "needs-check": [NEEDS_CHECK], + paid: [PAID], +}; + +export const FEE_PAYMENTS_MOCK = FEE_PAYMENTS[FEE_MOCK_STATE]; diff --git a/src/entities/fee/types.ts b/src/entities/fee/types.ts new file mode 100644 index 0000000..e99824a --- /dev/null +++ b/src/entities/fee/types.ts @@ -0,0 +1,18 @@ +// 납부확인중 · 확인필요 · 납부완료 — 미납이면 내역이 없다(홈에서 납부하기로 안내) +export type FeePaymentStatus = "checking" | "needs-check" | "paid"; + +interface FeePaymentBase { + id: string; + title: string; + // 확인 전이거나 입금자를 확인하지 못하면 금액·확인일시가 비어 있다 + amount: number | null; + paidAt: string; + confirmedAt: string | null; +} + +// 학생회 확인 내용은 확인필요일 때만 온다 — 상태로 갈라 두어 화면이 상태만 보고 판단하게 한다 +export type FeePayment = FeePaymentBase & + ( + | { status: "checking" | "paid" } + | { status: "needs-check"; reviewNote: string } + ); diff --git a/src/entities/feedbacks/feedbacksApi.ts b/src/entities/feedbacks/feedbacksApi.ts new file mode 100644 index 0000000..c60b30e --- /dev/null +++ b/src/entities/feedbacks/feedbacksApi.ts @@ -0,0 +1,18 @@ +import { MY_FEEDBACKS_MOCK } from "@/entities/feedbacks/feedbacksMock"; +import type { MyFeedback } from "@/entities/feedbacks/types"; +import { mockupApi } from "@/lib/mockupApi"; + +// 실 API가 붙으면 함수 안쪽만 요청 코드로 바꾼다 — 화면은 이 함수로만 데이터를 받는다. +export function fetchMyFeedbacks(): Promise { + return mockupApi("feedbacks/mine", () => MY_FEEDBACKS_MOCK); +} + +// 없는 피드백은 null — 작성내역 상세 화면이 작성내역 목록으로 보낸다. +export function fetchMyFeedback( + feedbackId: string, +): Promise { + return mockupApi( + `feedbacks/mine/${feedbackId}`, + () => MY_FEEDBACKS_MOCK.find((item) => item.id === feedbackId) ?? null, + ); +} diff --git a/src/entities/feedbacks/feedbacksMock.ts b/src/entities/feedbacks/feedbacksMock.ts new file mode 100644 index 0000000..6a130dd --- /dev/null +++ b/src/entities/feedbacks/feedbacksMock.ts @@ -0,0 +1,74 @@ +import type { Feedback, MyFeedback } from "@/entities/feedbacks/types"; + +// 문단은 빈 줄(\n\n)로 나누고, 굵게 강조할 구간은 **텍스트**로 표시한다 — 둘 다 Figma +// 원본(nodeId 1410:50009)의 문단 구분·볼드 스팬을 그대로 옮긴 것이다. +// `FeedbacksAnswerText`가 이 문법을 해석해서 렌더링하고, 카드 미리보기(FeedbacksQaCard)는 +// **만 지운 평문으로 보여준다. +const MT_ANSWER = [ + "학우 여러분의 경제적 부담을 덜어드리기 위해 더욱 고민하겠습니다. 최근 **지속적인 물가 상승**으로 인해 숙박 및 대관료가 전반적으로 크게 인상된 상황이었습니다.", + "실제로는 기존보다 참가비가 인상될 수밖에 없는 여건이었으나, 학생회에서는 학우분들의 **부담을 최소화**하기 위해 업체 측과 수차례 협의를 진행하며 최대한 비용을 조정했고, 학교 측 지원금 확보 노력 등을 통해 기존 수준으로 **참가비를 동결**하고자 최선을 다했습니다.", + "그럼에도 불구하고 학우분들께서 체감하시는 부담을 충분히 줄여드리지 못한 점은 학생회 역시 무겁게 받아들이고 있습니다.", + "향후 행사에서는 보다 **합리적이고 만족도 높은 행사 운영**이 이루어질 수 있도록 최선을 다하겠습니다.", +].join("\n\n"); + +// Figma: 게시판 - 열린피드백 (nodeId 1410:50011) 목업 데이터 +export const FEEDBACKS: Feedback[] = [ + { + answer: MT_ANSWER, + answerDate: "2026-05-06", + id: "1", + question: "MT 참가 비용이 다소 비싼 편이라 부담스러웠어요.", + round: "2차", + }, + { + answer: + "말씀 주신 대로 다음 학기부터는 개강총회를 목요일에 진행해 다음 날 부담을 줄일 수 있도록 반영하겠습니다.", + answerDate: "2026-05-06", + id: "2", + question: "목요일에 개강총회를 진행해서 다음 날 부담이 적어 좋았습니다.", + round: "2차", + }, + { + answer: "건의해주신 내용은 다음 학생회 운영위에서 함께 논의하겠습니다.", + answerDate: "2026-03-14", + id: "3", + question: "동아리방 냉난방기 가동 시간을 조금 더 늘려주시면 좋겠습니다.", + round: "1차", + }, + { + answer: "안내드린 대로 다음 공지에 상세 시간표를 함께 첨부하겠습니다.", + answerDate: "2026-07-02", + id: "4", + question: + "축제 공연 시간표를 미리 공지해주시면 일정을 맞추기 편할 것 같아요.", + round: "3차", + }, + { + answer: "학생증 재발급 신청 절차를 학과 사무실과 다시 공유드리겠습니다.", + answerDate: "2026-08-20", + id: "5", + question: "학생증 재발급 절차가 헷갈려서 안내가 더 필요해요.", + round: "4차", + }, +]; + +// Figma: 열린피드백 작성내역 (nodeId 3147:147748), 상세 (3147:147756 답변완료, 3147:147779 답변대기) +// 답변이 달린 건 열린피드백 목록의 피드백과 같은 id다. +const MY_FEEDBACKS: MyFeedback[] = [ + { ...FEEDBACKS[0], createdAt: "2026-04-25" }, + { ...FEEDBACKS[1], createdAt: "2026-05-02" }, + { + createdAt: "2026-04-25", + id: "mine-1", + question: + "다양한 학과 전공 관련 대회나 활동을 통해 포트폴리오를 채워나갈 수 있는 활동이 좋았습니다. 3~5명 정도의 원하는 사람들과 팀을 구성해서 나갈 수 있는 대회나 공모전 정보가 더 많았으면 좋겠어요.", + round: "2차", + }, +]; + +// 상태를 바꿔 작성내역 화면을 확인한다. empty: 빈 상태 (Figma 3147:147818) +type MyFeedbacksMockState = "list" | "empty"; +const MY_FEEDBACKS_MOCK_STATE: MyFeedbacksMockState = "list"; + +export const MY_FEEDBACKS_MOCK = + MY_FEEDBACKS_MOCK_STATE === "list" ? MY_FEEDBACKS : []; diff --git a/src/entities/feedbacks/types.ts b/src/entities/feedbacks/types.ts new file mode 100644 index 0000000..e1fa5fe --- /dev/null +++ b/src/entities/feedbacks/types.ts @@ -0,0 +1,15 @@ +export type FeedbackRound = "1차" | "2차" | "3차" | "4차"; + +export interface Feedback { + id: string; + question: string; + round: FeedbackRound; + answer?: string; + // 상세페이지(모아보기)의 "학생회 답변" 날짜. 답변이 없으면 의미가 없어 answer와 함께 옵셔널이다. + answerDate?: string; +} + +// 내가 쓴 피드백 — 작성일이 붙는다. 답변이 달리면 같은 id로 열린피드백 목록에도 보인다. +export interface MyFeedback extends Feedback { + createdAt: string; +} diff --git a/src/entities/lockers/lockersApi.ts b/src/entities/lockers/lockersApi.ts new file mode 100644 index 0000000..8e7541b --- /dev/null +++ b/src/entities/lockers/lockersApi.ts @@ -0,0 +1,8 @@ +import { LOCKER_ASSIGNMENT_MOCK } from "@/entities/lockers/lockersMock"; +import type { LockerAssignment } from "@/entities/lockers/types"; +import { mockupApi } from "@/lib/mockupApi"; + +// 실 API가 붙으면 함수 안쪽만 요청 코드로 바꾼다 — 화면은 이 함수로만 데이터를 받는다. +export function fetchMyLockerAssignment(): Promise { + return mockupApi("lockers/assignment", () => LOCKER_ASSIGNMENT_MOCK); +} diff --git a/src/entities/lockers/lockersMock.ts b/src/entities/lockers/lockersMock.ts new file mode 100644 index 0000000..bf7adc3 --- /dev/null +++ b/src/entities/lockers/lockersMock.ts @@ -0,0 +1,19 @@ +import type { LockerAssignment } from "@/entities/lockers/types"; + +// Figma: 사물함 배정 상태 배정완료 (nodeId 3147:148357), 빈 상태 (3147:148331) +// Figma는 B-25지만 칸 배치 목데이터가 A-1·A-2만 있어서 위치 보기가 열리도록 A-1구역 칸으로 둔다. +const ASSIGNED: LockerAssignment = { + appliedAt: "2026-09-05T13:59", + lockerLabel: "A-7", + sectionId: "A-1", + semester: "2026-2학기", + usageEndDate: "2026-12-15", + usageStartDate: "2026-09-07", +}; + +// 상태를 바꿔 배정 상태 화면을 확인한다 +type LockerAssignmentMockState = "assigned" | "empty"; +const LOCKER_ASSIGNMENT_MOCK_STATE: LockerAssignmentMockState = "assigned"; + +export const LOCKER_ASSIGNMENT_MOCK = + LOCKER_ASSIGNMENT_MOCK_STATE === "assigned" ? ASSIGNED : null; diff --git a/src/entities/lockers/types.ts b/src/entities/lockers/types.ts new file mode 100644 index 0000000..957f3ae --- /dev/null +++ b/src/entities/lockers/types.ts @@ -0,0 +1,13 @@ +// 내 사물함 배정 — 신청하면 바로 확정이라 배정대기 상태가 없다. 신청하지 않았으면 null. +export interface LockerAssignment { + semester: string; + appliedAt: string; + usageStartDate: string; + usageEndDate: string; + lockerLabel: string; + // 사물함 위치 보기 — 이 구역의 칸 화면을 연다 + sectionId: string; +} + +// 홈 내 정보 목록의 사물함 배정 상태 — 미신청 · 배정완료 · 기간종료 +export type LockerAssignmentStatus = "none" | "assigned" | "expired"; diff --git a/src/entities/search/searchApi.ts b/src/entities/search/searchApi.ts index 8007351..6f8f12f 100644 --- a/src/entities/search/searchApi.ts +++ b/src/entities/search/searchApi.ts @@ -1,9 +1,9 @@ import { ARCHIVES } from "@/entities/archives/archivesMock"; import { BILILGE_ITEMS } from "@/entities/bililge/bililgeMock"; import { EVENTS } from "@/entities/events/eventsMock"; +import { FEEDBACKS } from "@/entities/feedbacks/feedbacksMock"; import { NOTICES } from "@/entities/notices/noticesMock"; import type { SearchResults } from "@/entities/search/types"; -import { FEEDBACKS } from "@/features/feedbacks/constants/feedbacks"; import { mockupApi } from "@/lib/mockupApi"; function includesKeyword(text: string, keyword: string): boolean { diff --git a/src/entities/search/types.ts b/src/entities/search/types.ts index e7712bc..9d37c5b 100644 --- a/src/entities/search/types.ts +++ b/src/entities/search/types.ts @@ -1,8 +1,8 @@ import type { ArchiveItem } from "@/entities/archives/types"; import type { BililgeItem } from "@/entities/bililge/types"; import type { EventItem } from "@/entities/events/types"; +import type { Feedback } from "@/entities/feedbacks/types"; import type { Notice } from "@/entities/notices/types"; -import type { Feedback } from "@/features/feedbacks/constants/feedbacks"; // 검색 결과는 카테고리별 배열이다 — 키는 용어 사전(terminology.md)의 코드 용어를 그대로 쓴다. export interface SearchResults { diff --git a/src/features/events/components/EventsEmptyState.tsx b/src/features/events/components/EventsEmptyState.tsx index b37911b..7bc0c42 100644 --- a/src/features/events/components/EventsEmptyState.tsx +++ b/src/features/events/components/EventsEmptyState.tsx @@ -1,6 +1,6 @@ import { Button, Typography } from "@wanteddev/wds"; -import emptyEventsIllustration from "@/assets/icons/events/empty-events.svg"; +import emptyEventsIllustration from "@/assets/icons/common/empty-events.svg"; interface EventsEmptyStateProps { title: string; diff --git a/src/features/fee/FeePaymentsScreen.tsx b/src/features/fee/FeePaymentsScreen.tsx new file mode 100644 index 0000000..b59a0a5 --- /dev/null +++ b/src/features/fee/FeePaymentsScreen.tsx @@ -0,0 +1,47 @@ +import { TopNavigationButton } from "@wanteddev/wds"; +import { IconChevronLeft } from "@wanteddev/wds-icon"; +import { Suspense } from "react"; + +import ScreenHeader from "@/components/ui/ScreenHeader"; +import { useScreenHeader } from "@/components/ui/useScreenHeader"; +import FeePaymentList from "@/features/fee/components/FeePaymentList"; +import FeePaymentsSkeleton from "@/features/fee/components/FeePaymentsSkeleton"; +import { STUDENT_COUNCIL_KAKAO_CHANNEL_URL } from "@/features/fee/constants/fee"; +import { useBackNavigation } from "@/hooks/useBackNavigation"; + +// Figma: 학생회비 납부내역 (nodeId 3147:147860 납부확인중, 3147:147879 확인필요, 3147:147841 납부완료) +function FeePaymentsScreen() { + // 바로 들어와 뒤로 갈 히스토리가 없으면 홈으로 보낸다 + const goBack = useBackNavigation("/"); + + useScreenHeader( + + + + } + title="학생회비 납부내역" + variant="normal" + />, + ); + + // 채널 주소가 정해지기 전에는 눌러도 반응 없는 버튼이 되지 않게 문의하기를 넘기지 않는다(버튼이 숨는다) + const openKakaoChannel = STUDENT_COUNCIL_KAKAO_CHANNEL_URL + ? () => window.open(STUDENT_COUNCIL_KAKAO_CHANNEL_URL, "_blank", "noopener") + : undefined; + + return ( +
+ }> + + +
+ ); +} + +export default FeePaymentsScreen; diff --git a/src/features/fee/components/FeePaymentCard.tsx b/src/features/fee/components/FeePaymentCard.tsx new file mode 100644 index 0000000..f595cd9 --- /dev/null +++ b/src/features/fee/components/FeePaymentCard.tsx @@ -0,0 +1,108 @@ +import { Button, ContentBadge, Typography } from "@wanteddev/wds"; + +import type { FeePayment } from "@/entities/fee/types"; +import { FEE_STATUS_BADGES } from "@/features/fee/constants/fee"; +import { formatDateTime } from "@/lib/date"; + +interface FeePaymentCardProps { + payment: FeePayment; + /** 없으면 문의하기 버튼을 그리지 않는다(채널 주소가 아직 없을 때) */ + onContact?: () => void; +} + +function formatAmount(value: number | null) { + return value === null ? "-" : `${value.toLocaleString("ko-KR")}원`; +} + +// Figma: 학생회비 납부내역 납부확인중 (nodeId 3147:147860), 확인필요 (3147:147879), 납부완료 (3147:147841) +// 납부 카드 아래에, 확인필요일 때만 학생회 확인 내용 카드가 붙는다. 둘 다 Stream 로컬 카드다. +function FeePaymentCard({ payment, onContact }: FeePaymentCardProps) { + const badge = FEE_STATUS_BADGES[payment.status]; + const rows = [ + { label: "납부금액", value: formatAmount(payment.amount) }, + { label: "납부일시", value: formatDateTime(payment.paidAt) }, + { + label: "확인일시", + value: payment.confirmedAt ? formatDateTime(payment.confirmedAt) : "-", + }, + ]; + + return ( +
+
+ + {badge.label} + + + {payment.title} + + {rows.map((row) => ( +
+ + {row.label} + + + {row.value} + +
+ ))} +
+ + {payment.status === "needs-check" && ( +
+
+ + 학생회 확인 내용 + + + {payment.reviewNote} + +
+ {onContact && ( + + )} +
+ )} +
+ ); +} + +export default FeePaymentCard; diff --git a/src/features/fee/components/FeePaymentList.tsx b/src/features/fee/components/FeePaymentList.tsx new file mode 100644 index 0000000..f29f1d6 --- /dev/null +++ b/src/features/fee/components/FeePaymentList.tsx @@ -0,0 +1,28 @@ +import { use } from "react"; + +import { fetchFeePayments } from "@/entities/fee/feeApi"; +import FeePaymentCard from "@/features/fee/components/FeePaymentCard"; + +interface FeePaymentListProps { + /** 없으면 문의하기 버튼을 그리지 않는다(채널 주소가 아직 없을 때) */ + onContact?: () => void; +} + +// 학기별 납부 내역을 최신순으로 쌓는다. Figma는 한 건만 그렸고, 여러 건이면 건 사이를 카드 간격보다 넓게(12px) 둔다. +function FeePaymentList({ onContact }: FeePaymentListProps) { + const payments = use(fetchFeePayments()); + + return ( +
+ {payments.map((payment) => ( + + ))} +
+ ); +} + +export default FeePaymentList; diff --git a/src/features/fee/components/FeePaymentsSkeleton.tsx b/src/features/fee/components/FeePaymentsSkeleton.tsx new file mode 100644 index 0000000..1561b87 --- /dev/null +++ b/src/features/fee/components/FeePaymentsSkeleton.tsx @@ -0,0 +1,48 @@ +import { Skeleton } from "@wanteddev/wds"; + +import { useSkeletonAnimation } from "@/components/ui/ScreenSkeleton"; + +// 납부내역을 받는 동안 보이는 스켈레톤 — 납부 카드(배지 + 제목 + 3줄) 배치만 따른다. +function FeePaymentsSkeleton() { + const animation = useSkeletonAnimation(); + + return ( +
+
+ + + + + +
+
+ ); +} + +export default FeePaymentsSkeleton; diff --git a/src/features/fee/constants/fee.ts b/src/features/fee/constants/fee.ts new file mode 100644 index 0000000..0e4b109 --- /dev/null +++ b/src/features/fee/constants/fee.ts @@ -0,0 +1,18 @@ +import type { FeePaymentStatus } from "@/entities/fee/types"; + +// 학생회 카카오톡 채널 — 주소를 받으면 채운다. 비어 있으면 확인필요 카드에 문의하기 버튼을 그리지 않는다. +export const STUDENT_COUNCIL_KAKAO_CHANNEL_URL = ""; + +// 학생회비 납부 상태 배지 — 납부내역 카드와 홈 내 정보 목록(HomeInfoList)이 같이 쓴다 +// Figma: 학생회비 납부 여부 뱃지 (nodeId 3147:146475) +export const FEE_STATUS_BADGES: Record< + FeePaymentStatus, + { label: string; color: "orange" | "red" | "green" } +> = { + checking: { color: "orange", label: "납부확인중" }, + "needs-check": { color: "red", label: "확인필요" }, + paid: { color: "green", label: "납부완료" }, +}; + +// 학생회비 납부내역 화면 경로 +export const FEE_PAYMENTS_PATH = "/my/fee"; diff --git a/src/features/feedbacks/FeedbacksDetailScreen.tsx b/src/features/feedbacks/FeedbacksDetailScreen.tsx index 5a1712d..e64b429 100644 --- a/src/features/feedbacks/FeedbacksDetailScreen.tsx +++ b/src/features/feedbacks/FeedbacksDetailScreen.tsx @@ -5,37 +5,15 @@ import { Typography, } from "@wanteddev/wds"; import { IconClose } from "@wanteddev/wds-icon"; -import { - Fragment, - type UIEvent, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import { type UIEvent, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import answerIcon from "@/assets/icons/feedbacks/answer.svg"; import questionIcon from "@/assets/icons/feedbacks/question.svg"; import ScreenHeader from "@/components/ui/ScreenHeader"; import { useScreenHeader } from "@/components/ui/useScreenHeader"; -import { FEEDBACKS } from "@/features/feedbacks/constants/feedbacks"; - -// 답변 텍스트의 **강조** 구간을 볼드 Typography로 바꿔 그린다(Figma nodeId 1410:50009의 -// 인라인 볼드 스팬). 문단 구분(\n\n)은 별도 파싱 없이 부모의 white-space: pre-wrap에 맡긴다. -function renderAnswerText(text: string, idPrefix: string) { - return text.split(/(\*\*[^*]+\*\*)/g).map((segment, index) => { - const key = `${idPrefix}-${index}`; - if (segment.startsWith("**") && segment.endsWith("**")) { - return ( - - {segment.slice(2, -2)} - - ); - } - return {segment}; - }); -} +import { FEEDBACKS } from "@/entities/feedbacks/feedbacksMock"; +import FeedbacksAnswerText from "@/features/feedbacks/components/FeedbacksAnswerText"; // Figma: 열린피드백 상세페이지 (nodeId 1410:49988) // 제목·진행 바는 같은 회차(round)의 답변된 피드백들을 한데 묶어 보여준다는 뜻에서 @@ -216,15 +194,7 @@ function FeedbacksDetailScreen() { - - {renderAnswerText(item.answer, item.id)} - + )} diff --git a/src/features/feedbacks/FeedbacksListScreen.tsx b/src/features/feedbacks/FeedbacksListScreen.tsx index 5fd74d9..89c851d 100644 --- a/src/features/feedbacks/FeedbacksListScreen.tsx +++ b/src/features/feedbacks/FeedbacksListScreen.tsx @@ -18,12 +18,10 @@ import FilterChipGroup from "@/components/ui/FilterChipGroup"; import ScreenHeader from "@/components/ui/ScreenHeader"; import ScreenToast from "@/components/ui/ScreenToast"; import { useScreenHeader } from "@/components/ui/useScreenHeader"; +import { FEEDBACKS } from "@/entities/feedbacks/feedbacksMock"; import FeedbacksCard from "@/features/feedbacks/components/FeedbacksCard"; import FeedbacksQaCard from "@/features/feedbacks/components/FeedbacksQaCard"; -import { - FEEDBACK_ROUND_FILTERS, - FEEDBACKS, -} from "@/features/feedbacks/constants/feedbacks"; +import { FEEDBACK_ROUND_FILTERS } from "@/features/feedbacks/constants/feedbacks"; import { NOTIFICATIONS_PATH } from "@/features/notifications/constants/notifications"; import { getSearchPath } from "@/features/search/constants/search"; diff --git a/src/features/feedbacks/FeedbacksMineDetailScreen.tsx b/src/features/feedbacks/FeedbacksMineDetailScreen.tsx new file mode 100644 index 0000000..f1f227f --- /dev/null +++ b/src/features/feedbacks/FeedbacksMineDetailScreen.tsx @@ -0,0 +1,55 @@ +import { TopNavigationButton } from "@wanteddev/wds"; +import { IconChevronLeft } from "@wanteddev/wds-icon"; +import { Suspense } from "react"; +import { useNavigate, useParams } from "react-router-dom"; + +import ScreenHeader from "@/components/ui/ScreenHeader"; +import { useScreenHeader } from "@/components/ui/useScreenHeader"; +import FeedbacksMineDetailContent from "@/features/feedbacks/components/FeedbacksMineDetailContent"; +import FeedbacksMineDetailSkeleton from "@/features/feedbacks/components/FeedbacksMineDetailSkeleton"; +import { MY_FEEDBACKS_PATH } from "@/features/feedbacks/constants/feedbacks"; +import { useBackNavigation } from "@/hooks/useBackNavigation"; + +// Figma: 열린피드백 작성내역 상세 (nodeId 3147:147756 답변완료, 3147:147779 답변대기) +function FeedbacksMineDetailScreen() { + const navigate = useNavigate(); + const { feedbackId = "" } = useParams(); + + // 바로 들어와 뒤로 갈 히스토리가 없으면 작성내역 목록으로 보낸다 + const goBack = useBackNavigation(MY_FEEDBACKS_PATH); + + useScreenHeader( + + + + } + title="작성내역 상세" + variant="normal" + />, + ); + + return ( +
+ }> + + navigate("/feedbacks", { viewTransition: true }) + } + onViewRound={(id) => + navigate(`/feedbacks/${id}`, { viewTransition: true }) + } + /> + +
+ ); +} + +export default FeedbacksMineDetailScreen; diff --git a/src/features/feedbacks/FeedbacksMineScreen.tsx b/src/features/feedbacks/FeedbacksMineScreen.tsx new file mode 100644 index 0000000..e5331cf --- /dev/null +++ b/src/features/feedbacks/FeedbacksMineScreen.tsx @@ -0,0 +1,50 @@ +import { TopNavigationButton } from "@wanteddev/wds"; +import { IconChevronLeft } from "@wanteddev/wds-icon"; +import { Suspense } from "react"; +import { useNavigate } from "react-router-dom"; + +import ScreenHeader from "@/components/ui/ScreenHeader"; +import { useScreenHeader } from "@/components/ui/useScreenHeader"; +import FeedbacksMineList from "@/features/feedbacks/components/FeedbacksMineList"; +import FeedbacksMineSkeleton from "@/features/feedbacks/components/FeedbacksMineSkeleton"; +import { getMyFeedbackPath } from "@/features/feedbacks/constants/feedbacks"; +import { useBackNavigation } from "@/hooks/useBackNavigation"; + +// Figma: 열린피드백 작성내역 (nodeId 3147:147748) +function FeedbacksMineScreen() { + const navigate = useNavigate(); + + // 바로 들어와 뒤로 갈 히스토리가 없으면 홈으로 보낸다 + const goBack = useBackNavigation("/"); + + useScreenHeader( + + + + } + title="열린피드백 작성내역" + variant="normal" + />, + ); + + return ( +
+ }> + + navigate(getMyFeedbackPath(feedbackId), { viewTransition: true }) + } + onWrite={() => navigate("/feedbacks/new", { viewTransition: true })} + /> + +
+ ); +} + +export default FeedbacksMineScreen; diff --git a/src/features/feedbacks/components/FeedbacksAnswerText.tsx b/src/features/feedbacks/components/FeedbacksAnswerText.tsx new file mode 100644 index 0000000..83014e5 --- /dev/null +++ b/src/features/feedbacks/components/FeedbacksAnswerText.tsx @@ -0,0 +1,41 @@ +import { Typography } from "@wanteddev/wds"; +import { Fragment } from "react"; + +interface FeedbacksAnswerTextProps { + text: string; +} + +// 학생회 답변 본문 — **강조** 구간을 볼드 Typography로 바꿔 그린다(Figma nodeId 1410:50009의 +// 인라인 볼드 스팬). 문단 구분(\n\n)은 별도 파싱 없이 white-space: pre-wrap에 맡긴다. +// 열린피드백 상세와 작성내역 상세가 같이 쓴다. +function FeedbacksAnswerText({ text }: FeedbacksAnswerTextProps) { + return ( + + {text.split(/(\*\*[^*]+\*\*)/g).map((segment, index) => { + // 같은 답변 안에서 순서가 바뀌지 않는 조각이라 위치를 key로 쓴다 + const key = `${index}-${segment}`; + if (segment.startsWith("**") && segment.endsWith("**")) { + return ( + + {segment.slice(2, -2)} + + ); + } + return {segment}; + })} + + ); +} + +export default FeedbacksAnswerText; diff --git a/src/features/feedbacks/components/FeedbacksEmptyState.tsx b/src/features/feedbacks/components/FeedbacksEmptyState.tsx new file mode 100644 index 0000000..2bb92b3 --- /dev/null +++ b/src/features/feedbacks/components/FeedbacksEmptyState.tsx @@ -0,0 +1,58 @@ +import { Button, Typography } from "@wanteddev/wds"; + +import emptyFeedback from "@/assets/icons/feedbacks/empty-feedback.svg"; + +interface FeedbacksEmptyStateProps { + title: string; + description: string; + actionLabel: string; + onAction: () => void; +} + +// Figma: 열린피드백 작성내역 empty > Empty State (nodeId 3147:147823) — 행사 목록 Empty State와 같은 +// 틀(81px 그림 + 타이틀·설명 + 작은 버튼)이고 그림·버튼 색만 다르다. WDS FallbackView를 쓰지 않는 이유도 같다. +function FeedbacksEmptyState({ + title, + description, + actionLabel, + onAction, +}: FeedbacksEmptyStateProps) { + return ( +
+ {/* Figma 그림 자리 81px(size-20.25) */} +
+ +
+
+
+ + {title} + + + {description} + +
+ +
+
+ ); +} + +export default FeedbacksEmptyState; diff --git a/src/features/feedbacks/components/FeedbacksHistoryCard.tsx b/src/features/feedbacks/components/FeedbacksHistoryCard.tsx new file mode 100644 index 0000000..0ea3993 --- /dev/null +++ b/src/features/feedbacks/components/FeedbacksHistoryCard.tsx @@ -0,0 +1,65 @@ +import { ContentBadge, Typography } from "@wanteddev/wds"; + +import arrowRight from "@/assets/icons/common/arrow-right.svg"; +import type { MyFeedback } from "@/entities/feedbacks/types"; +import { hasFeedbackAnswer } from "@/features/feedbacks/constants/feedbacks"; +import { formatDate } from "@/lib/date"; + +interface FeedbacksHistoryCardProps { + feedback: MyFeedback; + onSelect: () => void; +} + +// Figma: Feedback History Card (nodeId 2663:183140) — Stream 로컬. 답변이 있으면 답변완료(초록), +// 없으면 답변대기(주황) 배지를 붙인다. +function FeedbacksHistoryCard({ + feedback, + onSelect, +}: FeedbacksHistoryCardProps) { + const isAnswered = hasFeedbackAnswer(feedback); + + return ( + + ); +} + +export default FeedbacksHistoryCard; diff --git a/src/features/feedbacks/components/FeedbacksMineDetailContent.tsx b/src/features/feedbacks/components/FeedbacksMineDetailContent.tsx new file mode 100644 index 0000000..378c425 --- /dev/null +++ b/src/features/feedbacks/components/FeedbacksMineDetailContent.tsx @@ -0,0 +1,123 @@ +import { Button, Typography } from "@wanteddev/wds"; +import { use } from "react"; +import { Navigate } from "react-router-dom"; + +import answerIcon from "@/assets/icons/feedbacks/answer.svg"; +import questionIcon from "@/assets/icons/feedbacks/question.svg"; +import { fetchMyFeedback } from "@/entities/feedbacks/feedbacksApi"; +import FeedbacksAnswerText from "@/features/feedbacks/components/FeedbacksAnswerText"; +import FeedbacksEmptyState from "@/features/feedbacks/components/FeedbacksEmptyState"; +import { hasFeedbackAnswer } from "@/features/feedbacks/constants/feedbacks"; + +interface FeedbacksMineDetailContentProps { + feedbackId: string; + /** 없는 피드백이면 보낼 곳 — 이동할 경로는 화면이 정한다 */ + fallbackPath: string; + onBrowseFeedbacks: () => void; + onViewRound: (feedbackId: string) => void; +} + +// Figma: 열린피드백 작성내역 상세 답변완료 (nodeId 3147:147756), 답변대기 (3147:147779) +// 질문·답변 머리(32px 배경 원 + 아이콘)는 열린피드백 상세(FeedbacksDetailScreen)와 같다. +function FeedbacksMineDetailContent({ + feedbackId, + fallbackPath, + onBrowseFeedbacks, + onViewRound, +}: FeedbacksMineDetailContentProps) { + const feedback = use(fetchMyFeedback(feedbackId)); + + if (!feedback) { + return ; + } + + // 목록 카드와 같은 기준(hasFeedbackAnswer)으로 답변이 있는지 본다 + const answer = hasFeedbackAnswer(feedback) ? feedback.answer : undefined; + + return ( +
+
+
+
+ +
+
+ + 피드백 내용 + + + {feedback.createdAt} + +
+
+ + {feedback.question} + +
+ +
+ +
+
+
+ +
+
+ + 학생회 답변 + + + {feedback.answerDate ?? "-"} + +
+
+ + {answer ? ( +
+ + +
+ ) : ( +
+ +
+ )} +
+
+ ); +} + +export default FeedbacksMineDetailContent; diff --git a/src/features/feedbacks/components/FeedbacksMineDetailSkeleton.tsx b/src/features/feedbacks/components/FeedbacksMineDetailSkeleton.tsx new file mode 100644 index 0000000..f372d52 --- /dev/null +++ b/src/features/feedbacks/components/FeedbacksMineDetailSkeleton.tsx @@ -0,0 +1,71 @@ +import { Skeleton } from "@wanteddev/wds"; + +import { useSkeletonAnimation } from "@/components/ui/ScreenSkeleton"; + +// 작성내역 상세를 받는 동안 보이는 스켈레톤 — 질문 머리·본문, 구분 띠, 답변 머리·본문 배치만 따른다. +function FeedbacksMineDetailSkeleton() { + const animation = useSkeletonAnimation(); + + const header = ( +
+ +
+ + +
+
+ ); + + return ( +
+
+ {header} + +
+
+
+ {header} + + + +
+
+ ); +} + +export default FeedbacksMineDetailSkeleton; diff --git a/src/features/feedbacks/components/FeedbacksMineList.tsx b/src/features/feedbacks/components/FeedbacksMineList.tsx new file mode 100644 index 0000000..48a0fa2 --- /dev/null +++ b/src/features/feedbacks/components/FeedbacksMineList.tsx @@ -0,0 +1,43 @@ +import { use } from "react"; + +import { fetchMyFeedbacks } from "@/entities/feedbacks/feedbacksApi"; +import FeedbacksEmptyState from "@/features/feedbacks/components/FeedbacksEmptyState"; +import FeedbacksHistoryCard from "@/features/feedbacks/components/FeedbacksHistoryCard"; + +interface FeedbacksMineListProps { + onSelect: (feedbackId: string) => void; + onWrite: () => void; +} + +// Figma: 열린피드백 작성내역 (nodeId 3147:147748), empty (3147:147818) +function FeedbacksMineList({ onSelect, onWrite }: FeedbacksMineListProps) { + const feedbacks = use(fetchMyFeedbacks()); + + if (feedbacks.length === 0) { + // Figma는 헤더 아래 200px 지점에 빈 상태를 둔다 + return ( +
+ +
+ ); + } + + return ( +
+ {feedbacks.map((feedback) => ( + onSelect(feedback.id)} + /> + ))} +
+ ); +} + +export default FeedbacksMineList; diff --git a/src/features/feedbacks/components/FeedbacksMineSkeleton.tsx b/src/features/feedbacks/components/FeedbacksMineSkeleton.tsx new file mode 100644 index 0000000..fdd8aad --- /dev/null +++ b/src/features/feedbacks/components/FeedbacksMineSkeleton.tsx @@ -0,0 +1,44 @@ +import { Skeleton } from "@wanteddev/wds"; + +import { + SKELETON_ROW_KEYS, + useSkeletonAnimation, +} from "@/components/ui/ScreenSkeleton"; + +// 작성내역을 받는 동안 목록 자리에 보이는 스켈레톤 — 카드(배지 + 내용 + 날짜) 배치만 따른다. +function FeedbacksMineSkeleton() { + const animation = useSkeletonAnimation(); + + return ( +
+ {SKELETON_ROW_KEYS.slice(0, 3).map((key) => ( +
+ + + +
+ ))} +
+ ); +} + +export default FeedbacksMineSkeleton; diff --git a/src/features/feedbacks/constants/feedbacks.ts b/src/features/feedbacks/constants/feedbacks.ts index 394dce8..2fe3a5a 100644 --- a/src/features/feedbacks/constants/feedbacks.ts +++ b/src/features/feedbacks/constants/feedbacks.ts @@ -1,15 +1,5 @@ import type { FilterChipOption } from "@/components/ui/FilterChipGroup"; - -export type FeedbackRound = "1차" | "2차" | "3차" | "4차"; - -export interface Feedback { - id: string; - question: string; - round: FeedbackRound; - answer?: string; - // 상세페이지(모아보기)의 "학생회 답변" 날짜. 답변이 없으면 의미가 없어 answer와 함께 옵셔널이다. - answerDate?: string; -} +import type { Feedback } from "@/entities/feedbacks/types"; export const FEEDBACK_ROUND_FILTERS: FilterChipOption[] = [ { label: "전체", value: "all" }, @@ -19,54 +9,15 @@ export const FEEDBACK_ROUND_FILTERS: FilterChipOption[] = [ { label: "4차", value: "4차" }, ]; -// 문단은 빈 줄(\n\n)로 나누고, 굵게 강조할 구간은 **텍스트**로 표시한다 — 둘 다 Figma -// 원본(nodeId 1410:50009)의 문단 구분·볼드 스팬을 그대로 옮긴 것이다. -// FeedbacksDetailScreen이 이 문법을 해석해서 렌더링하고, 카드 미리보기(FeedbacksQaCard)는 -// **만 지운 평문으로 보여준다. -const MT_ANSWER = [ - "학우 여러분의 경제적 부담을 덜어드리기 위해 더욱 고민하겠습니다. 최근 **지속적인 물가 상승**으로 인해 숙박 및 대관료가 전반적으로 크게 인상된 상황이었습니다.", - "실제로는 기존보다 참가비가 인상될 수밖에 없는 여건이었으나, 학생회에서는 학우분들의 **부담을 최소화**하기 위해 업체 측과 수차례 협의를 진행하며 최대한 비용을 조정했고, 학교 측 지원금 확보 노력 등을 통해 기존 수준으로 **참가비를 동결**하고자 최선을 다했습니다.", - "그럼에도 불구하고 학우분들께서 체감하시는 부담을 충분히 줄여드리지 못한 점은 학생회 역시 무겁게 받아들이고 있습니다.", - "향후 행사에서는 보다 **합리적이고 만족도 높은 행사 운영**이 이루어질 수 있도록 최선을 다하겠습니다.", -].join("\n\n"); +// 열린피드백 작성내역 목록·상세 경로 +export const MY_FEEDBACKS_PATH = "/my/feedbacks"; -// Figma: 게시판 - 열린피드백 (nodeId 1410:50011) 목업 데이터 -export const FEEDBACKS: Feedback[] = [ - { - answer: MT_ANSWER, - answerDate: "2026-05-06", - id: "1", - question: "MT 참가 비용이 다소 비싼 편이라 부담스러웠어요.", - round: "2차", - }, - { - answer: - "말씀 주신 대로 다음 학기부터는 개강총회를 목요일에 진행해 다음 날 부담을 줄일 수 있도록 반영하겠습니다.", - answerDate: "2026-05-06", - id: "2", - question: "목요일에 개강총회를 진행해서 다음 날 부담이 적어 좋았습니다.", - round: "2차", - }, - { - answer: "건의해주신 내용은 다음 학생회 운영위에서 함께 논의하겠습니다.", - answerDate: "2026-03-14", - id: "3", - question: "동아리방 냉난방기 가동 시간을 조금 더 늘려주시면 좋겠습니다.", - round: "1차", - }, - { - answer: "안내드린 대로 다음 공지에 상세 시간표를 함께 첨부하겠습니다.", - answerDate: "2026-07-02", - id: "4", - question: - "축제 공연 시간표를 미리 공지해주시면 일정을 맞추기 편할 것 같아요.", - round: "3차", - }, - { - answer: "학생증 재발급 신청 절차를 학과 사무실과 다시 공유드리겠습니다.", - answerDate: "2026-08-20", - id: "5", - question: "학생증 재발급 절차가 헷갈려서 안내가 더 필요해요.", - round: "4차", - }, -]; +export function getMyFeedbackPath(feedbackId: string) { + return `${MY_FEEDBACKS_PATH}/${feedbackId}`; +} + +// 학생회 답변이 달렸는지 — 목록 카드(답변완료/답변대기)와 상세(답변/준비 중)가 같은 기준을 쓴다. +// 빈 문자열로 와도 답변이 없는 것으로 본다. +export function hasFeedbackAnswer(feedback: Feedback) { + return Boolean(feedback.answer?.trim()); +} diff --git a/src/features/home/components/HomeAppliedEventSection.tsx b/src/features/home/components/HomeAppliedEventSection.tsx index 1e001da..1a65ce7 100644 --- a/src/features/home/components/HomeAppliedEventSection.tsx +++ b/src/features/home/components/HomeAppliedEventSection.tsx @@ -1,7 +1,6 @@ import { Button, Divider, Typography } from "@wanteddev/wds"; - -import emptyEvents from "@/assets/icons/events/empty-events.svg"; -import arrowRight from "@/assets/icons/home/arrow-right.svg"; +import arrowRight from "@/assets/icons/common/arrow-right.svg"; +import emptyEvents from "@/assets/icons/common/empty-events.svg"; import HomeEmptyState from "@/features/home/components/HomeEmptyState"; import type { HomeAppliedEvent } from "@/features/home/constants/homeMock"; diff --git a/src/features/home/components/HomeInfoList.tsx b/src/features/home/components/HomeInfoList.tsx index da78be3..81d04ec 100644 --- a/src/features/home/components/HomeInfoList.tsx +++ b/src/features/home/components/HomeInfoList.tsx @@ -1,39 +1,23 @@ import { ContentBadge, Typography } from "@wanteddev/wds"; import type { ReactNode } from "react"; -import arrowRight from "@/assets/icons/home/arrow-right.svg"; +import arrowRight from "@/assets/icons/common/arrow-right.svg"; import arrowUpRight from "@/assets/icons/home/arrow-up-right.svg"; import infoFee from "@/assets/icons/home/info-fee.svg"; import infoFeedback from "@/assets/icons/home/info-feedback.svg"; import infoLocker from "@/assets/icons/home/info-locker.svg"; -import type { - HomeFeeStatus, - HomeLockerStatus, - HomeMyInfo, -} from "@/features/home/constants/homeMock"; - -type BadgeColor = "orange" | "red" | "green"; +import { FEE_STATUS_BADGES } from "@/features/fee/constants/fee"; +import type { HomeMyInfo } from "@/features/home/constants/homeMock"; +import { LOCKER_STATUS_BADGES } from "@/features/lockers/constants/lockers"; +// 학생회비·사물함 배지는 각 기능의 배지 표(FEE_STATUS_BADGES·LOCKER_STATUS_BADGES)를 그대로 쓴다. +// 학생회비 미납은 배지 대신 납부 안내 줄로 바뀐다. interface InfoBadge { label: string; // 없으면 neutral(회색) 배지 - color?: BadgeColor; + color?: "orange" | "red" | "green"; } -// Figma: 학생회비 납부 여부 뱃지 (nodeId 3147:146475). 미납은 배지 대신 납부 안내 줄로 바뀐다. -const FEE_BADGES: Record, InfoBadge> = { - checking: { color: "orange", label: "납부확인중" }, - "needs-check": { color: "red", label: "확인필요" }, - paid: { color: "green", label: "납부완료" }, -}; - -// Figma: 사물함 배정 상태 뱃지 (nodeId 3147:146479) -const LOCKER_BADGES: Record = { - assigned: { color: "green", label: "배정완료" }, - expired: { color: "red", label: "기간종료" }, - none: { label: "미신청" }, -}; - function InfoBadgeView({ badge }: { badge: InfoBadge }) { return ( ) : ( @@ -132,7 +116,7 @@ function HomeInfoList({ myInfo }: { myInfo: HomeMyInfo }) { title="열린피드백 작성내역" /> - {formatUsageDate(usageStartDate)} - {formatUsageDate(usageEndDate)} + {formatDate(usageStartDate, { weekday: true })} -{" "} + {formatDate(usageEndDate, { weekday: true })}
diff --git a/src/features/lockers/LockersAssignmentScreen.tsx b/src/features/lockers/LockersAssignmentScreen.tsx new file mode 100644 index 0000000..dc9fd8e --- /dev/null +++ b/src/features/lockers/LockersAssignmentScreen.tsx @@ -0,0 +1,49 @@ +import { TopNavigationButton } from "@wanteddev/wds"; +import { IconChevronLeft } from "@wanteddev/wds-icon"; +import { Suspense } from "react"; +import { useNavigate } from "react-router-dom"; + +import ScreenHeader from "@/components/ui/ScreenHeader"; +import { useScreenHeader } from "@/components/ui/useScreenHeader"; +import LockersAssignmentContent from "@/features/lockers/components/LockersAssignmentContent"; +import LockersAssignmentSkeleton from "@/features/lockers/components/LockersAssignmentSkeleton"; +import { MY_LOCKER_LOCATION_PATH } from "@/features/lockers/constants/lockers"; +import { useBackNavigation } from "@/hooks/useBackNavigation"; + +// Figma: 사물함 배정 상태 배정완료 (nodeId 3147:148357), 빈 상태 (3147:148331) +function LockersAssignmentScreen() { + const navigate = useNavigate(); + + // 바로 들어와 뒤로 갈 히스토리가 없으면 홈으로 보낸다 + const goBack = useBackNavigation("/"); + + useScreenHeader( + + + + } + title="사물함 배정 상태" + variant="normal" + />, + ); + + return ( +
+ }> + + navigate(MY_LOCKER_LOCATION_PATH, { viewTransition: true }) + } + /> + +
+ ); +} + +export default LockersAssignmentScreen; diff --git a/src/features/lockers/LockersLockerSelectScreen.tsx b/src/features/lockers/LockersCellSelectScreen.tsx similarity index 56% rename from src/features/lockers/LockersLockerSelectScreen.tsx rename to src/features/lockers/LockersCellSelectScreen.tsx index f83a443..064abf0 100644 --- a/src/features/lockers/LockersLockerSelectScreen.tsx +++ b/src/features/lockers/LockersCellSelectScreen.tsx @@ -1,6 +1,6 @@ import { TopNavigationButton } from "@wanteddev/wds"; import { IconChevronLeft, IconImage, IconReset } from "@wanteddev/wds-icon"; -import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useMemo, useState } from "react"; import { useLocation, useNavigate, useParams } from "react-router-dom"; import ComingSoonScreen from "@/app/ComingSoonScreen"; @@ -9,10 +9,8 @@ import ScreenHeader from "@/components/ui/ScreenHeader"; import SubmittingOverlay from "@/components/ui/SubmittingOverlay"; import { useScreenHeader } from "@/components/ui/useScreenHeader"; import LockersLayoutRenderer from "@/features/lockers/components/LockersLayoutRenderer"; -import LockersLockerLegend from "@/features/lockers/components/LockersLockerLegend"; -import LockersLockerMinimap, { - type LockersLockerMinimapViewport, -} from "@/features/lockers/components/LockersLockerMinimap"; +import LockersLegend from "@/features/lockers/components/LockersLegend"; +import LockersMapView from "@/features/lockers/components/LockersMapView"; import LockersSectionPhotoModal from "@/features/lockers/components/LockersSectionPhotoModal"; import LockersSelectedLockerBar from "@/features/lockers/components/LockersSelectedLockerBar"; import { LOCKERS_APPLY_SUBMITTING_TEXT } from "@/features/lockers/constants/lockersApplySubmit"; @@ -21,23 +19,9 @@ import { type LockersSectionDetail, } from "@/features/lockers/constants/lockersSectionDetails"; import { useLockersApplySubmit } from "@/features/lockers/hooks/useLockersApplySubmit"; -import { useLockersPinchZoom } from "@/features/lockers/hooks/useLockersPinchZoom"; - -// 배치 영역 안쪽 여백(px-5, pb-5) — 미니맵에 보이는 영역을 배치 기준으로 계산할 때 뺀다 -const CONTENT_PADDING = 20; - -interface MinimapState { - viewport: LockersLockerMinimapViewport; - layoutWidth: number; - isScrollable: boolean; -} - -function clampRatio(value: number) { - return Math.min(Math.max(value, 0), 1); -} // Figma: A-1구역 (nodeId 2159:110753), A-2구역 (2159:109533), A-1구역 실제사진 (2159:110174) -function LockersLockerSelectScreen() { +function LockersCellSelectScreen() { const { sectionId = "" } = useParams(); const detail = LOCKERS_SECTION_DETAILS[sectionId]; @@ -58,10 +42,6 @@ function SectionLockerSelect({ detail }: { detail: LockersSectionDetail }) { const [isPhotoOpen, setIsPhotoOpen] = useState(false); const [isConfirmOpen, setIsConfirmOpen] = useState(false); const { isSubmitting, submit } = useLockersApplySubmit(detail.section); - const [minimap, setMinimap] = useState(null); - const [scrollerWidth, setScrollerWidth] = useState(); - const scrollRef = useRef(null); - const contentRef = useRef(null); const lockers = useMemo( () => @@ -73,52 +53,6 @@ function SectionLockerSelect({ detail }: { detail: LockersSectionDetail }) { ? undefined : lockers.get(selectedLockerNumber); - const updateViewport = useCallback(() => { - const scroller = scrollRef.current; - const content = contentRef.current; - if (!scroller || !content) { - return; - } - // 제스처 중에는 배율이 상태보다 앞서 DOM에만 반영돼 있어서 DOM 값을 읽는다 - const zoom = Number(content.style.zoom) || 1; - const rect = content.getBoundingClientRect(); - const layoutWidth = rect.width / zoom - CONTENT_PADDING * 2; - const layoutHeight = rect.height / zoom - CONTENT_PADDING; - const visibleLeft = scroller.scrollLeft / zoom - CONTENT_PADDING; - const visibleTop = scroller.scrollTop / zoom; - const left = clampRatio(visibleLeft / layoutWidth); - const top = clampRatio(visibleTop / layoutHeight); - const right = clampRatio( - (visibleLeft + scroller.clientWidth / zoom) / layoutWidth, - ); - const bottom = clampRatio( - (visibleTop + scroller.clientHeight / zoom) / layoutHeight, - ); - - setScrollerWidth(scroller.clientWidth); - setMinimap({ - isScrollable: - scroller.scrollWidth > scroller.clientWidth + 1 || - scroller.scrollHeight > scroller.clientHeight + 1, - layoutWidth, - viewport: { height: bottom - top, left, top, width: right - left }, - }); - }, []); - - useLayoutEffect(() => { - const el = scrollRef.current; - if (!el) { - return; - } - updateViewport(); - const observer = new ResizeObserver(updateViewport); - observer.observe(el); - - return () => observer.disconnect(); - }, [updateViewport]); - - const zoom = useLockersPinchZoom(scrollRef, contentRef, updateViewport); - useScreenHeader( -
- {/* Figma Minimap Section */} -
- {minimap ? ( - - {map} - - ) : ( -
- )} - -
- {/* touch-pan: 두 손가락 동작을 페이지 확대 대신 이 영역의 확대로 받는다. - minWidth를 %가 아니라 px로 주는 이유: zoom을 걸면 %는 배율과 상관없이 화면 폭으로 - 계산돼서, 화면보다 좁은 구역을 확대할 때 "fill" 상자가 비율대로 커지지 않는다 */} -
-
- {map} -
-
-
+ } map={map} /> + + + } + title="내 사물함" + trailing={ + setIsPhotoOpen(true)} + variant="icon" + > + + + } + variant="normal" + />, + ); + + return ( +
+ }> + setIsPhotoOpen(false)} + /> + +
+ ); +} + +export default LockersMineScreen; diff --git a/src/features/lockers/components/LockersAssignmentContent.tsx b/src/features/lockers/components/LockersAssignmentContent.tsx new file mode 100644 index 0000000..b798fe5 --- /dev/null +++ b/src/features/lockers/components/LockersAssignmentContent.tsx @@ -0,0 +1,129 @@ +import { Button, ContentBadge, Typography } from "@wanteddev/wds"; +import { IconChevronRight } from "@wanteddev/wds-icon"; +import { use } from "react"; + +import { fetchMyLockerAssignment } from "@/entities/lockers/lockersApi"; +import LockersEmptyState from "@/features/lockers/components/LockersEmptyState"; +import { LOCKER_STATUS_BADGES } from "@/features/lockers/constants/lockers"; +import { formatDate, formatDateTime } from "@/lib/date"; + +// 이 화면에 오는 배정은 항상 배정완료다(기간종료·미신청은 홈 배지에서만 쓴다) +const ASSIGNED_BADGE = LOCKER_STATUS_BADGES.assigned; + +interface LockersAssignmentContentProps { + onViewLocation: () => void; +} + +// Figma: 사물함 배정 상태 배정완료 (nodeId 3147:148357), 빈 상태 (3147:148331) +// 배정 요약 카드와 배정된 사물함 카드 둘 다 Stream 로컬 카드다(학생회비 납부내역 카드와 같은 틀). +function LockersAssignmentContent({ + onViewLocation, +}: LockersAssignmentContentProps) { + const assignment = use(fetchMyLockerAssignment()); + + if (!assignment) { + // TODO: 연도(학기 기준)는 API가 붙으면 서버 값으로 받는다 — 지금은 기기 시계의 올해다 + const year = new Date().getFullYear(); + return ( +
+ +
+ ); + } + + const rows = [ + { + label: "신청일시", + value: formatDateTime(assignment.appliedAt, { weekday: true }), + }, + { + label: "사용기한", + value: `${formatDate(assignment.usageStartDate, { weekday: true })} - ${formatDate(assignment.usageEndDate, { weekday: true })}`, + }, + ]; + + return ( +
+
+ + {ASSIGNED_BADGE.label} + + + {assignment.semester} 사물함 + +
+ {rows.map((row) => ( +
+ + {row.label} + + + {row.value} + +
+ ))} +
+
+ +
+
+ + 배정된 사물함 + + + + {assignment.lockerLabel} + {" "} + 사물함 + +
+ +
+
+ ); +} + +export default LockersAssignmentContent; diff --git a/src/features/lockers/components/LockersAssignmentSkeleton.tsx b/src/features/lockers/components/LockersAssignmentSkeleton.tsx new file mode 100644 index 0000000..4a40d2d --- /dev/null +++ b/src/features/lockers/components/LockersAssignmentSkeleton.tsx @@ -0,0 +1,63 @@ +import { Skeleton } from "@wanteddev/wds"; + +import { useSkeletonAnimation } from "@/components/ui/ScreenSkeleton"; + +// 배정 상태를 받는 동안 보이는 스켈레톤 — 배정 요약 카드(배지 + 제목 + 2줄)와 사물함 카드 배치만 따른다. +function LockersAssignmentSkeleton() { + const animation = useSkeletonAnimation(); + + return ( +
+
+ + + + +
+
+ + + +
+
+ ); +} + +export default LockersAssignmentSkeleton; diff --git a/src/features/lockers/components/LockersLockerCell.tsx b/src/features/lockers/components/LockersCell.tsx similarity index 51% rename from src/features/lockers/components/LockersLockerCell.tsx rename to src/features/lockers/components/LockersCell.tsx index a338328..ff86564 100644 --- a/src/features/lockers/components/LockersLockerCell.tsx +++ b/src/features/lockers/components/LockersCell.tsx @@ -3,10 +3,11 @@ import { Typography } from "@wanteddev/wds"; import lockerUnavailableIcon from "@/assets/icons/lockers/locker-unavailable.svg"; import type { LockersSectionLocker } from "@/features/lockers/constants/lockersSectionDetails"; -interface LockersLockerCellProps { +interface LockersCellProps { locker: LockersSectionLocker; isSelected: boolean; - onSelect: (lockerNumber: number) => void; + /** 없으면 보기 전용 — 칸을 누를 수 없다(내 사물함 화면) */ + onSelect?: (lockerNumber: number) => void; } // Figma: Locker Cell (nodeId 2159:110833 외) — Stream 로컬. 28px 정사각형, Orange/95 배경에 @@ -20,12 +21,14 @@ interface LockersLockerCellProps { // Background/Normal/Normal(흰색) SemiBold로 바꾼다. // // 내 사물함은 Figma에 상태가 없어서 임시로 초록 테두리를 둔다(이미 신청한 칸이라 고를 수 없다). -function LockersLockerCell({ - locker, - isSelected, - onSelect, -}: LockersLockerCellProps) { - if (locker.isMine) { +// +// 보기 전용(onSelect 없음 — 내 사물함 화면)에서는 칸을 버튼으로 그리지 않는다. 선택된 칸은 같은 +// 선택 모양으로 그려서, 내 사물함 화면은 내 칸을 선택된 칸으로 넘기기만 하면 된다. +// 선택된 칸에는 data-selected를 붙인다 — LockersMapView가 처음 열 때 이 칸이 보이게 스크롤한다. +function LockersCell({ locker, isSelected, onSelect }: LockersCellProps) { + const isViewOnly = onSelect === undefined; + + if (locker.isMine && !isViewOnly) { return (
+ {locker.lockerNumber} + + ); + + if (isViewOnly) { + return ( +
+ {number} +
+ ); + } + return ( ); } -export default LockersLockerCell; +export default LockersCell; diff --git a/src/features/lockers/components/LockersEmptyState.tsx b/src/features/lockers/components/LockersEmptyState.tsx new file mode 100644 index 0000000..ce69790 --- /dev/null +++ b/src/features/lockers/components/LockersEmptyState.tsx @@ -0,0 +1,30 @@ +import { Typography } from "@wanteddev/wds"; + +import emptyEventsIllustration from "@/assets/icons/common/empty-events.svg"; + +interface LockersEmptyStateProps { + title: string; +} + +// Figma: 사물함 배정 상태 empty > Empty State (nodeId 3147:148336) — 행사 목록 Empty State와 같은 +// 그림에 타이틀만 있다(설명·버튼 없음). WDS FallbackView를 쓰지 않는 이유는 행사 목록과 같다. +function LockersEmptyState({ title }: LockersEmptyStateProps) { + return ( +
+ {/* Figma 그림 자리 81px(size-20.25) */} +
+ +
+ + {title} + +
+ ); +} + +export default LockersEmptyState; diff --git a/src/features/lockers/components/LockersLockerGrid.tsx b/src/features/lockers/components/LockersGrid.tsx similarity index 81% rename from src/features/lockers/components/LockersLockerGrid.tsx rename to src/features/lockers/components/LockersGrid.tsx index 945379f..8d6476a 100644 --- a/src/features/lockers/components/LockersLockerGrid.tsx +++ b/src/features/lockers/components/LockersGrid.tsx @@ -1,21 +1,22 @@ -import LockersLockerCell from "@/features/lockers/components/LockersLockerCell"; +import LockersCell from "@/features/lockers/components/LockersCell"; import type { LockersSectionLocker } from "@/features/lockers/constants/lockersSectionDetails"; -interface LockersLockerGridProps { +interface LockersGridProps { rows: (number | null)[][]; lockers: ReadonlyMap; selectedLockerNumber: number | null; - onSelect: (lockerNumber: number) => void; + /** 없으면 보기 전용 — 칸을 누를 수 없다(내 사물함 화면) */ + onSelect?: (lockerNumber: number) => void; } // Figma: Locker Grid (nodeId 2159:110832 외) // layout에는 있는데 lockers에 없는 번호(서버 데이터가 어긋난 경우)는 빈 자리로 둔다. -function LockersLockerGrid({ +function LockersGrid({ rows, lockers, selectedLockerNumber, onSelect, -}: LockersLockerGridProps) { +}: LockersGridProps) { const columnCount = Math.max(...rows.map((row) => row.length)); return ( @@ -39,7 +40,7 @@ function LockersLockerGrid({ } return ( - ; selectedLockerNumber: number | null; - onSelect: (lockerNumber: number) => void; + /** 없으면 보기 전용 — 칸을 누를 수 없다(내 사물함 화면) */ + onSelect?: (lockerNumber: number) => void; } type LockersLayoutBlockContext = Omit; @@ -102,7 +103,7 @@ function LockersLayoutBlockView({ ); case "lockerGroup": { const grid = ( - + } + label="내 사물함" + /> +
+ ); + } + return (
(null); + const [scrollerWidth, setScrollerWidth] = useState(); + const scrollRef = useRef(null); + const contentRef = useRef(null); + + const updateViewport = useCallback(() => { + const scroller = scrollRef.current; + const content = contentRef.current; + if (!scroller || !content) { + return; + } + // 제스처 중에는 배율이 상태보다 앞서 DOM에만 반영돼 있어서 DOM 값을 읽는다 + const zoom = Number(content.style.zoom) || 1; + const rect = content.getBoundingClientRect(); + const layoutWidth = rect.width / zoom - CONTENT_PADDING * 2; + const layoutHeight = rect.height / zoom - CONTENT_PADDING; + const visibleLeft = scroller.scrollLeft / zoom - CONTENT_PADDING; + const visibleTop = scroller.scrollTop / zoom; + const left = clampRatio(visibleLeft / layoutWidth); + const top = clampRatio(visibleTop / layoutHeight); + const right = clampRatio( + (visibleLeft + scroller.clientWidth / zoom) / layoutWidth, + ); + const bottom = clampRatio( + (visibleTop + scroller.clientHeight / zoom) / layoutHeight, + ); + + setScrollerWidth(scroller.clientWidth); + setMinimap({ + isScrollable: + scroller.scrollWidth > scroller.clientWidth + 1 || + scroller.scrollHeight > scroller.clientHeight + 1, + layoutWidth, + viewport: { height: bottom - top, left, top, width: right - left }, + }); + }, []); + + useLayoutEffect(() => { + const el = scrollRef.current; + if (!el) { + return; + } + updateViewport(); + const observer = new ResizeObserver(updateViewport); + observer.observe(el); + + return () => observer.disconnect(); + }, [updateViewport]); + + const zoom = useLockersPinchZoom(scrollRef, contentRef, updateViewport); + + // 확대 훅이 처음에 가운데로 맞춘 뒤에 돌아야 해서 훅 호출 아래에 둔다. + // 처음 열 때 선택된 칸(내 사물함 화면의 내 칸)이 화면 밖이면 안쪽 여백까지 보이도록 필요한 만큼만 + // 스크롤한다(가운데로 맞추진 않는다). 칸 선택 화면은 처음에 선택된 칸이 없어서 그대로다. + // 미니맵에도 같은 칸이 있어서 본문 안에서만 찾는다. + useLayoutEffect(() => { + const scroller = scrollRef.current; + const cell = contentRef.current?.querySelector("[data-selected]"); + if (!scroller || !cell) { + return; + } + const scrollerRect = scroller.getBoundingClientRect(); + const cellRect = cell.getBoundingClientRect(); + const overflowRight = cellRect.right + CONTENT_PADDING - scrollerRect.right; + const overflowLeft = scrollerRect.left - (cellRect.left - CONTENT_PADDING); + const overflowBottom = + cellRect.bottom + CONTENT_PADDING - scrollerRect.bottom; + const overflowTop = scrollerRect.top - cellRect.top; + if (overflowRight > 0) { + scroller.scrollLeft += overflowRight; + } else if (overflowLeft > 0) { + scroller.scrollLeft -= overflowLeft; + } + if (overflowBottom > 0) { + scroller.scrollTop += overflowBottom; + } else if (overflowTop > 0) { + scroller.scrollTop -= overflowTop; + } + updateViewport(); + }, [updateViewport]); + + return ( +
+ {/* Figma Minimap Section */} +
+ {minimap ? ( + + {map} + + ) : ( +
+ )} + {legend} +
+ {/* touch-pan: 두 손가락 동작을 페이지 확대 대신 이 영역의 확대로 받는다. + minWidth를 %가 아니라 px로 주는 이유: zoom을 걸면 %는 배율과 상관없이 화면 폭으로 + 계산돼서, 화면보다 좁은 구역을 확대할 때 "fill" 상자가 비율대로 커지지 않는다 */} +
+
+ {map} +
+
+
+ ); +} + +export default LockersMapView; diff --git a/src/features/lockers/components/LockersMineBar.tsx b/src/features/lockers/components/LockersMineBar.tsx new file mode 100644 index 0000000..637502f --- /dev/null +++ b/src/features/lockers/components/LockersMineBar.tsx @@ -0,0 +1,35 @@ +import { Typography } from "@wanteddev/wds"; + +interface LockersMineBarProps { + lockerLabel: string; +} + +// 내 사물함 화면 하단 — 칸 선택 화면의 선택한 사물함 카드(LockersSelectedLockerBar)와 같은 모양에서 +// 신청 버튼만 뺐다. 보기 전용이라 할 동작이 없다. +function LockersMineBar({ lockerLabel }: LockersMineBarProps) { + return ( +
+
+ + 내 사물함 + + + {lockerLabel} + +
+
+
+ ); +} + +export default LockersMineBar; diff --git a/src/features/lockers/components/LockersMineContent.tsx b/src/features/lockers/components/LockersMineContent.tsx new file mode 100644 index 0000000..1e12d82 --- /dev/null +++ b/src/features/lockers/components/LockersMineContent.tsx @@ -0,0 +1,70 @@ +import { use, useMemo } from "react"; +import { Navigate } from "react-router-dom"; + +import { fetchMyLockerAssignment } from "@/entities/lockers/lockersApi"; +import LockersLayoutRenderer from "@/features/lockers/components/LockersLayoutRenderer"; +import LockersLegend from "@/features/lockers/components/LockersLegend"; +import LockersMapView from "@/features/lockers/components/LockersMapView"; +import LockersMineBar from "@/features/lockers/components/LockersMineBar"; +import LockersSectionPhotoModal from "@/features/lockers/components/LockersSectionPhotoModal"; +import { LOCKERS_SECTION_DETAILS } from "@/features/lockers/constants/lockersSectionDetails"; + +interface LockersMineContentProps { + /** 배정이 없거나 구역 배치가 없으면 보낼 곳 — 이동할 경로는 화면이 정한다 */ + fallbackPath: string; + isPhotoOpen: boolean; + onClosePhoto: () => void; +} + +// 배정된 구역의 칸 배치를 보기 전용으로 그리고 내 칸만 표시한다. +// 내 칸은 배정 정보(entities/lockers)의 칸 이름 하나로 정한다 — 구역 칸 데이터의 isMine에 기대지 않고, +// 그 칸을 선택된 칸으로 넘겨 선택 모양(파란 배경)으로 그린다. +function LockersMineContent({ + fallbackPath, + isPhotoOpen, + onClosePhoto, +}: LockersMineContentProps) { + const assignment = use(fetchMyLockerAssignment()); + const detail = assignment + ? LOCKERS_SECTION_DETAILS[assignment.sectionId] + : undefined; + + const lockers = useMemo( + () => + new Map( + (detail?.lockers ?? []).map((locker) => [locker.lockerNumber, locker]), + ), + [detail], + ); + + // 배정이 없거나 구역 배치가 없으면 볼 칸이 없어서 배정 상태 화면으로 돌려보낸다 + if (!assignment || !detail) { + return ; + } + + const myLocker = detail.lockers.find( + (locker) => locker.lockerLabel === assignment.lockerLabel, + ); + const map = ( + + ); + + return ( + <> + } map={map} /> + + + + ); +} + +export default LockersMineContent; diff --git a/src/features/lockers/components/LockersMineSkeleton.tsx b/src/features/lockers/components/LockersMineSkeleton.tsx new file mode 100644 index 0000000..649ae90 --- /dev/null +++ b/src/features/lockers/components/LockersMineSkeleton.tsx @@ -0,0 +1,31 @@ +import { Skeleton } from "@wanteddev/wds"; + +import { useSkeletonAnimation } from "@/components/ui/ScreenSkeleton"; + +// 배정 정보를 받는 동안 보이는 스켈레톤 — 미니맵 자리와 칸 배치 영역만 따른다. +function LockersMineSkeleton() { + const animation = useSkeletonAnimation(); + + return ( +
+
+ +
+ +
+ ); +} + +export default LockersMineSkeleton; diff --git a/src/features/lockers/components/LockersLockerMinimap.tsx b/src/features/lockers/components/LockersMinimap.tsx similarity index 94% rename from src/features/lockers/components/LockersLockerMinimap.tsx rename to src/features/lockers/components/LockersMinimap.tsx index fc63bc7..beea293 100644 --- a/src/features/lockers/components/LockersLockerMinimap.tsx +++ b/src/features/lockers/components/LockersMinimap.tsx @@ -1,19 +1,19 @@ import { type ReactNode, useLayoutEffect, useRef, useState } from "react"; /** 칸 배치 전체 대비 지금 보이는 영역(0~1 비율) */ -export interface LockersLockerMinimapViewport { +export interface LockersMinimapViewport { left: number; top: number; width: number; height: number; } -interface LockersLockerMinimapProps { +interface LockersMinimapProps { /** 본문과 같은 칸 배치 — 축소해서 그린다 */ children: ReactNode; /** 본문 칸 배치의 1배 기준 폭. 같은 폭으로 그려야 "fill" 상자 비율이 본문과 같다 */ layoutWidth: number; - viewport: LockersLockerMinimapViewport; + viewport: LockersMinimapViewport; /** 스크롤이 없으면(전체가 보이면) 테두리를 숨긴다 */ isScrollable: boolean; } @@ -29,12 +29,12 @@ const EDGE_EPSILON = 0.001; // Figma: Minimap (nodeId 2159:110757) — Stream 로컬 // Figma는 칸·글자를 따로 줄여 그렸지만, 본문 배치를 zoom으로 줄여 그린다(배치를 두 벌 관리하지 않고, // 4px 글자가 브라우저 최소 글자 크기에 걸리지 않는다). 높이는 고정, 폭은 배치 비율에 맞추되 남은 폭을 넘지 않는다. -function LockersLockerMinimap({ +function LockersMinimap({ children, layoutWidth, viewport, isScrollable, -}: LockersLockerMinimapProps) { +}: LockersMinimapProps) { const areaRef = useRef(null); const contentRef = useRef(null); const [scale, setScale] = useState(null); @@ -127,4 +127,4 @@ function LockersLockerMinimap({ ); } -export default LockersLockerMinimap; +export default LockersMinimap; diff --git a/src/features/lockers/constants/lockers.ts b/src/features/lockers/constants/lockers.ts new file mode 100644 index 0000000..352fa56 --- /dev/null +++ b/src/features/lockers/constants/lockers.ts @@ -0,0 +1,18 @@ +import type { LockerAssignmentStatus } from "@/entities/lockers/types"; + +// 사물함 배정 상태 배지 — 사물함 배정 상태 화면과 홈 내 정보 목록(HomeInfoList)이 같이 쓴다. +// color가 없으면 회색(neutral) 배지다. +// Figma: 사물함 배정 상태 뱃지 (nodeId 3147:146479) +// satisfies로 두어 상태별 값(예: 배정완료는 green)이 타입에 그대로 남는다 +export const LOCKER_STATUS_BADGES = { + assigned: { color: "green", label: "배정완료" }, + expired: { color: "red", label: "기간종료" }, + none: { label: "미신청" }, +} as const satisfies Record< + LockerAssignmentStatus, + { label: string; color?: "red" | "green" } +>; + +// 사물함 배정 상태·내 사물함(위치 보기) 화면 경로 +export const LOCKER_ASSIGNMENT_PATH = "/my/locker"; +export const MY_LOCKER_LOCATION_PATH = "/my/locker/location"; diff --git a/src/hooks/useBackNavigation.ts b/src/hooks/useBackNavigation.ts new file mode 100644 index 0000000..e214edb --- /dev/null +++ b/src/hooks/useBackNavigation.ts @@ -0,0 +1,27 @@ +import { useCallback } from "react"; +import { useNavigate } from "react-router-dom"; + +import { getHistoryIndex } from "@/lib/historyIndex"; + +/** + * 헤더 뒤로가기 동작. 앱 안에서 타고 들어왔으면 한 단계 뒤로 가고, 이 화면으로 **바로** + * 들어왔으면(딥링크·앱 WebView 진입·주소 직접 입력) `fallbackPath`로 보낸다. + * + * 바로 들어온 경우 `navigate(-1)`은 뒤에 갈 화면이 없어서 아무 일도 하지 않는다 — 사용자 + * 입장에서는 뒤로가기 버튼이 먹통이고, 모달을 띄워 둔 화면이면 그대로 갇힌다. + * + * `fallbackPath`는 그 화면의 "한 단계 위"를 준다(알림 설정 → 알림 목록, 행사 신청 → 행사 목록). + * 되돌아올 화면이 아니라 대신 가는 곳이라 히스토리를 치환(`replace`)한다. + */ +export function useBackNavigation(fallbackPath: string) { + const navigate = useNavigate(); + + return useCallback(() => { + const historyIndex = getHistoryIndex(); + if (historyIndex !== null && historyIndex > 0) { + navigate(-1); + return; + } + navigate(fallbackPath, { replace: true }); + }, [navigate, fallbackPath]); +} diff --git a/src/lib/date.ts b/src/lib/date.ts new file mode 100644 index 0000000..040c204 --- /dev/null +++ b/src/lib/date.ts @@ -0,0 +1,25 @@ +// 목데이터·API의 날짜("2026-09-05")·일시("2026-09-05T13:59")를 화면 표기로 바꾼다. +// 시간대에 따라 날짜가 밀리지 않게 Date로 파싱하지 않고 문자열을 직접 나눠 읽는다(요일 계산만 Date를 쓴다). + +const WEEKDAYS = ["일", "월", "화", "수", "목", "금", "토"]; + +interface DateFormatOptions { + /** 날짜 뒤에 "(토)"처럼 요일을 붙인다 */ + weekday?: boolean; +} + +// "2026-09-05" → "2026.09.05", weekday면 "2026.09.05(토)" +export function formatDate(date: string, options: DateFormatOptions = {}) { + const dotted = date.replaceAll("-", "."); + if (!options.weekday) { + return dotted; + } + const [year, month, day] = date.split("-").map(Number); + return `${dotted}(${WEEKDAYS[new Date(year, month - 1, day).getDay()]})`; +} + +// "2026-09-05T13:59" → "2026.09.05 13:59", weekday면 "2026.09.05(토) 13:59" +export function formatDateTime(value: string, options: DateFormatOptions = {}) { + const [date, time] = value.split("T"); + return `${formatDate(date, options)} ${time}`; +} diff --git a/src/lib/historyIndex.ts b/src/lib/historyIndex.ts new file mode 100644 index 0000000..bf2d505 --- /dev/null +++ b/src/lib/historyIndex.ts @@ -0,0 +1,18 @@ +// createBrowserRouter는 history.state.idx에 히스토리 스택 위치를 적어 둔다(push마다 1씩 증가). +// 화면으로 바로 들어오면(딥링크·앱 WebView 진입·새로고침 후 첫 화면) 0이다. +// +// react-router 내부가 정한 형태라 라이브러리를 올릴 때 깨질 수 있는 자리다. 여러 곳에서 각자 +// 캐스팅하지 않고 여기서만 읽는다 — 읽는 쪽은 `ScreenLayoutRoute`(전환 방향 판정)와 +// `useBackNavigation`(뒤로 갈 곳이 있는지)뿐이다. +export function getHistoryIndex(): number | null { + const state: unknown = window.history.state; + if ( + typeof state === "object" && + state !== null && + "idx" in state && + typeof state.idx === "number" + ) { + return state.idx; + } + return null; +}