From fec1a3d33c19a09aae17669d0e742fffa7e9c932 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:53:05 +0900 Subject: [PATCH 01/22] =?UTF-8?q?fix:=20=EC=84=9C=EB=B2=84=20=EC=9D=BC?= =?UTF-8?q?=EC=8B=9C=EB=A5=BC=20=EC=8B=9C:=EB=B6=84=EA=B9=8C=EC=A7=80?= =?UTF-8?q?=EB=A7=8C=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/date.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/date.ts b/src/lib/date.ts index 040c204c..477c633b 100644 --- a/src/lib/date.ts +++ b/src/lib/date.ts @@ -19,7 +19,8 @@ export function formatDate(date: string, options: DateFormatOptions = {}) { } // "2026-09-05T13:59" → "2026.09.05 13:59", weekday면 "2026.09.05(토) 13:59" +// 서버(LocalDateTime)는 초와 소수 초까지 내려 주므로("13:59:00.123456") 시:분까지만 남긴다 export function formatDateTime(value: string, options: DateFormatOptions = {}) { const [date, time] = value.split("T"); - return `${formatDate(date, options)} ${time}`; + return `${formatDate(date, options)} ${time.slice(0, 5)}`; } From 5e6a8cd91599483ac7c93b65c806ad1a620c7599 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:56:25 +0900 Subject: [PATCH 02/22] =?UTF-8?q?refactor:=20=ED=95=99=EC=83=9D=ED=9A=8C?= =?UTF-8?q?=EB=B9=84=20=EB=82=A9=EB=B6=80=20=EC=83=81=ED=83=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=EC=9D=84=20=EC=84=9C=EB=B2=84=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EB=AC=B8=EC=9E=90=EC=97=B4=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/fee/feeMock.ts | 12 ++++++------ src/entities/fee/types.ts | 17 +++++++---------- .../events/components/EventsDetailContent.tsx | 2 +- src/features/fee/components/FeePaymentCard.tsx | 2 +- src/features/fee/constants/fee.ts | 6 +++--- src/features/home/components/HomeInfoList.tsx | 2 +- src/features/home/constants/homeMock.ts | 13 +++++-------- 7 files changed, 24 insertions(+), 30 deletions(-) diff --git a/src/entities/fee/feeMock.ts b/src/entities/fee/feeMock.ts index e2c824a1..54fa3792 100644 --- a/src/entities/fee/feeMock.ts +++ b/src/entities/fee/feeMock.ts @@ -1,4 +1,4 @@ -import type { FeePayment, MyFeeStatus } from "@/entities/fee/types"; +import type { FeePayment, FeeStatus } from "@/entities/fee/types"; const TITLE = "소프트웨어융합대학 학생회비"; @@ -8,7 +8,7 @@ const CHECKING: FeePayment = { confirmedAt: null, id: "2026-2", paidAt: "2026-09-04T11:49", - status: "checking", + status: "PENDING", title: TITLE, }; @@ -19,7 +19,7 @@ const NEEDS_CHECK: FeePayment = { paidAt: "2026-09-04T11:49", reviewNote: "입금자 정보를 확인하지 못했어요. 문제가 있을 경우 학생회에 문의해 주세요.", - status: "needs-check", + status: "REJECTED", title: TITLE, }; @@ -28,7 +28,7 @@ const PAID: FeePayment = { confirmedAt: "2026-09-05T13:30", id: "2026-2", paidAt: "2026-09-04T11:49", - status: "paid", + status: "PAID", title: TITLE, }; @@ -56,5 +56,5 @@ const FEE_PAYMENTS: Record = { export const FEE_PAYMENTS_MOCK = FEE_PAYMENTS[FEE_MOCK_STATE]; -// 상태를 바꿔 행사 상세의 학생회비 납부자 전용 처리를 확인한다. unpaid·pending이면 신청 버튼이 막힌다. -export const MY_FEE_STATUS_MOCK: MyFeeStatus = "paid"; +// 상태를 바꿔 행사 상세의 학생회비 납부자 전용 처리를 확인한다. PAID가 아니면 신청 버튼이 막힌다. +export const MY_FEE_STATUS_MOCK: FeeStatus = "PAID"; diff --git a/src/entities/fee/types.ts b/src/entities/fee/types.ts index 022cabdc..bed16965 100644 --- a/src/entities/fee/types.ts +++ b/src/entities/fee/types.ts @@ -1,5 +1,9 @@ -// 납부확인중 · 확인필요 · 납부완료 — 미납이면 내역이 없다(홈에서 납부하기로 안내) -export type FeePaymentStatus = "checking" | "needs-check" | "paid"; +// 학생 본인의 학생회비 납부 상태 — 서버(GET /v1/app/fee/me의 feeStatus) 문자열 그대로 쓴다. +// 한 번도 요청하지 않았으면 UNPAID(서버 DB에는 없고 응답에서만 쓰는 상태)다. 납부확인중(PENDING)은 아직 납부로 보지 않는다. +export type FeeStatus = "UNPAID" | "PENDING" | "PAID" | "REJECTED"; + +// 납부를 요청한 뒤의 상태(납부확인중 · 확인필요 · 납부완료) — 미납이면 내역이 없다(홈에서 납부하기로 안내) +export type FeePaymentStatus = Exclude; interface FeePaymentBase { id: string; @@ -12,11 +16,4 @@ interface FeePaymentBase { // 학생회 확인 내용은 확인필요일 때만 온다 — 상태로 갈라 두어 화면이 상태만 보고 판단하게 한다 export type FeePayment = FeePaymentBase & - ( - | { status: "checking" | "paid" } - | { status: "needs-check"; reviewNote: string } - ); - -// 학생 본인의 학생회비 납부 상태 — GET /v1/app/fee/me의 feeStatus(UNPAID·PENDING·PAID). -// 납부확인중(pending)은 아직 납부로 보지 않는다. -export type MyFeeStatus = "unpaid" | "pending" | "paid"; + ({ status: "PENDING" | "PAID" } | { status: "REJECTED"; reviewNote: string }); diff --git a/src/features/events/components/EventsDetailContent.tsx b/src/features/events/components/EventsDetailContent.tsx index 207d55e7..c70664c0 100644 --- a/src/features/events/components/EventsDetailContent.tsx +++ b/src/features/events/components/EventsDetailContent.tsx @@ -62,7 +62,7 @@ function EventsDetailContent({ ); } - const isFeeRequired = event.feePaidOnly && feeStatus !== "paid"; + const isFeeRequired = event.feePaidOnly && feeStatus !== "PAID"; const canApply = event.status === "open" && !isFeeRequired; const actionLabel = event.status === "open" && isFeeRequired diff --git a/src/features/fee/components/FeePaymentCard.tsx b/src/features/fee/components/FeePaymentCard.tsx index c0482d75..e4d310ac 100644 --- a/src/features/fee/components/FeePaymentCard.tsx +++ b/src/features/fee/components/FeePaymentCard.tsx @@ -36,7 +36,7 @@ function FeePaymentCard({ payment, onContact }: FeePaymentCardProps) { title={payment.title} /> - {payment.status === "needs-check" && ( + {payment.status === "REJECTED" && (
= { - checking: { color: "orange", label: "납부확인중" }, - "needs-check": { color: "red", label: "확인필요" }, - paid: { color: "green", label: "납부완료" }, + PAID: { color: "green", label: "납부완료" }, + PENDING: { color: "orange", label: "납부확인중" }, + REJECTED: { color: "red", label: "확인필요" }, }; diff --git a/src/features/home/components/HomeInfoList.tsx b/src/features/home/components/HomeInfoList.tsx index 7dfe4b9b..b5473d6f 100644 --- a/src/features/home/components/HomeInfoList.tsx +++ b/src/features/home/components/HomeInfoList.tsx @@ -132,7 +132,7 @@ function HomeInfoList({ return (
- {feeStatus === "unpaid" ? ( + {feeStatus === "UNPAID" ? ( = { applyCards: [], myInfo: { feedbackCount: 1, - feeStatus: "unpaid", + feeStatus: "UNPAID", lockerLabel: "B-21", lockerStatus: "assigned", }, @@ -161,7 +158,7 @@ const HOME_DATA: Record = { applyCards: [SPORTS_DAY, LOCKER_2026], myInfo: { feedbackCount: 0, - feeStatus: "unpaid", + feeStatus: "UNPAID", lockerLabel: "B-21", lockerStatus: "assigned", }, @@ -195,7 +192,7 @@ const HOME_DATA: Record = { applyCards: [SPORTS_DAY], myInfo: { feedbackCount: 1, - feeStatus: "checking", + feeStatus: "PENDING", lockerLabel: "B-21", lockerStatus: "assigned", }, From 4c8cfd229265d0091c8e1f6758b2a04b9870554b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:58:03 +0900 Subject: [PATCH 03/22] =?UTF-8?q?feat:=20=EB=82=B4=20=ED=95=99=EC=83=9D?= =?UTF-8?q?=ED=9A=8C=EB=B9=84=20=EB=82=A9=EB=B6=80=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=EC=84=9C=EB=B2=84=20API=EB=A1=9C?= =?UTF-8?q?=20=EC=97=B0=EA=B2=B0=ED=95=98=EA=B3=A0=20=EB=82=A9=EB=B6=80?= =?UTF-8?q?=EB=82=B4=EC=97=AD=EC=9D=84=20=EC=B5=9C=EC=8B=A0=20=ED=95=9C=20?= =?UTF-8?q?=EA=B1=B4=EC=9C=BC=EB=A1=9C=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/fee/feeApi.ts | 23 +++---- src/entities/fee/feeMock.ts | 60 ------------------- src/entities/fee/types.ts | 27 +++++---- .../events/components/EventsDetailContent.tsx | 14 ++++- .../fee/components/FeePaymentCard.tsx | 23 ++++--- .../fee/components/FeePaymentList.tsx | 35 +++++++---- src/features/fee/constants/fee.ts | 3 + 7 files changed, 79 insertions(+), 106 deletions(-) delete mode 100644 src/entities/fee/feeMock.ts diff --git a/src/entities/fee/feeApi.ts b/src/entities/fee/feeApi.ts index 21c964c9..964abac6 100644 --- a/src/entities/fee/feeApi.ts +++ b/src/entities/fee/feeApi.ts @@ -1,25 +1,20 @@ import { queryOptions } from "@tanstack/react-query"; -import { FEE_PAYMENTS_MOCK, MY_FEE_STATUS_MOCK } from "@/entities/fee/feeMock"; -import { mockupResponse } from "@/lib/mockupApi"; +import type { FeeResponse } from "@/entities/fee/types"; +import { api } from "@/lib/api/client"; const feeKeys = { root: () => ["fee"] as const, - payments: () => [...feeKeys.root(), "payments"] as const, - myStatus: () => [...feeKeys.root(), "me"] as const, + me: () => [...feeKeys.root(), "me"] as const, }; -// 실 API가 붙으면 queryFn 안쪽만 요청 코드로 바꾼다 — 화면은 이 queryOptions로만 데이터를 받는다. +// 화면은 이 queryOptions로만 데이터를 받는다. 화면 모양으로 바꿔야 하면 화면 쪽에서 select를 더한다. export const feeQueries = { - payments: () => + // 내 납부 현황(GET /v1/app/fee/me) — 납부내역과 행사 상세의 학생회비 납부자 전용 판단이 같이 쓴다. + me: () => queryOptions({ - queryKey: feeKeys.payments(), - queryFn: () => mockupResponse(() => FEE_PAYMENTS_MOCK), - }), - // 내 납부 상태(GET /v1/app/fee/me) — 학생회비 납부자 전용 행사의 신청 가능 여부에 쓴다. - myStatus: () => - queryOptions({ - queryKey: feeKeys.myStatus(), - queryFn: () => mockupResponse(() => MY_FEE_STATUS_MOCK), + queryKey: feeKeys.me(), + queryFn: ({ signal }) => + api.get("/v1/app/fee/me", { signal }), }), }; diff --git a/src/entities/fee/feeMock.ts b/src/entities/fee/feeMock.ts deleted file mode 100644 index 54fa3792..00000000 --- a/src/entities/fee/feeMock.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { FeePayment, FeeStatus } 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: "PENDING", - title: TITLE, -}; - -const NEEDS_CHECK: FeePayment = { - amount: null, - confirmedAt: null, - id: "2026-2", - paidAt: "2026-09-04T11:49", - reviewNote: - "입금자 정보를 확인하지 못했어요. 문제가 있을 경우 학생회에 문의해 주세요.", - status: "REJECTED", - 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]; - -// 상태를 바꿔 행사 상세의 학생회비 납부자 전용 처리를 확인한다. PAID가 아니면 신청 버튼이 막힌다. -export const MY_FEE_STATUS_MOCK: FeeStatus = "PAID"; diff --git a/src/entities/fee/types.ts b/src/entities/fee/types.ts index bed16965..59944ef1 100644 --- a/src/entities/fee/types.ts +++ b/src/entities/fee/types.ts @@ -5,15 +5,22 @@ export type FeeStatus = "UNPAID" | "PENDING" | "PAID" | "REJECTED"; // 납부를 요청한 뒤의 상태(납부확인중 · 확인필요 · 납부완료) — 미납이면 내역이 없다(홈에서 납부하기로 안내) export type FeePaymentStatus = Exclude; -interface FeePaymentBase { - id: string; - title: string; - // 확인 전이거나 입금자를 확인하지 못하면 금액·확인일시가 비어 있다 - amount: number | null; - paidAt: string; +// 내 학생회비 납부 현황(서버 FeeResponse). 학생회비 조회·납부 요청이 같은 모양으로 준다. +// 금액·송금 링크는 지금 낼 수 있을 때(UNPAID·REJECTED에 남은 학기 수를 보냈을 때)만 계산해 주고, +// PENDING·PAID면 요청 때 남긴 금액만 준다(링크는 null). 일시는 시간대 없는 서버 시각("2026-09-04T11:49:00")이다. +export interface FeeResponse { + /** 한 번도 요청하지 않았으면 null */ + studentTransferStatusId: number | null; + feeStatus: FeeStatus; + bank: string; + accountNo: string; + remainingSemesters: number | null; + transferAmount: number | null; + /** 금액이 채워진 토스 송금 딥링크(supertoss://send?…) */ + transferLinkUrl: string | null; + requestedAt: string | null; + /** 납부완료(PAID)일 때만 있다 */ confirmedAt: string | null; + /** 확인필요(REJECTED)일 때만 있다 — 그때는 서버가 항상 채운다 */ + rejectReason: string | null; } - -// 학생회 확인 내용은 확인필요일 때만 온다 — 상태로 갈라 두어 화면이 상태만 보고 판단하게 한다 -export type FeePayment = FeePaymentBase & - ({ status: "PENDING" | "PAID" } | { status: "REJECTED"; reviewNote: string }); diff --git a/src/features/events/components/EventsDetailContent.tsx b/src/features/events/components/EventsDetailContent.tsx index c70664c0..57c5a619 100644 --- a/src/features/events/components/EventsDetailContent.tsx +++ b/src/features/events/components/EventsDetailContent.tsx @@ -13,6 +13,7 @@ import EmptyState from "@/components/ui/EmptyState"; import PhotoGallery from "@/components/ui/PhotoGallery"; import { eventsQueries } from "@/entities/events/eventsApi"; import { feeQueries } from "@/entities/fee/feeApi"; +import type { FeeResponse } from "@/entities/fee/types"; import EventsStatusBadge from "@/features/events/components/EventsStatusBadge"; import { EVENTS_FEE_PAID_ONLY_LABEL, @@ -45,8 +46,15 @@ function EventsDetailContent({ }: EventsDetailContentProps) { // 납부 상태는 행사와 따로 기다리지 않게(요청이 줄줄이 이어지지 않게) 한 번에 같이 받는다. // 납부자 전용이 아닌 행사에서는 쓰지 않는다. - const [{ data: event }, { data: feeStatus }] = useSuspenseQueries({ - queries: [eventsQueries.detail(eventId), feeQueries.myStatus()], + const [{ data: event }, { data: isFeePaid }] = useSuspenseQueries({ + queries: [ + eventsQueries.detail(eventId), + // 납부완료만 납부로 본다 — 납부확인중·확인필요는 아직 아니다 + { + ...feeQueries.me(), + select: (fee: FeeResponse) => fee.feeStatus === "PAID", + }, + ], }); if (!event) { @@ -62,7 +70,7 @@ function EventsDetailContent({ ); } - const isFeeRequired = event.feePaidOnly && feeStatus !== "PAID"; + const isFeeRequired = event.feePaidOnly && !isFeePaid; const canApply = event.status === "open" && !isFeeRequired; const actionLabel = event.status === "open" && isFeeRequired diff --git a/src/features/fee/components/FeePaymentCard.tsx b/src/features/fee/components/FeePaymentCard.tsx index e4d310ac..2297ce16 100644 --- a/src/features/fee/components/FeePaymentCard.tsx +++ b/src/features/fee/components/FeePaymentCard.tsx @@ -1,12 +1,13 @@ import { Button, Typography } from "@wanteddev/wds"; import StatusSummaryCard from "@/components/ui/StatusSummaryCard"; -import type { FeePayment } from "@/entities/fee/types"; -import { FEE_STATUS_BADGES } from "@/features/fee/constants/fee"; +import type { FeePaymentStatus, FeeResponse } from "@/entities/fee/types"; +import { FEE_STATUS_BADGES, FEE_TITLE } from "@/features/fee/constants/fee"; import { formatDateTime } from "@/lib/date"; interface FeePaymentCardProps { - payment: FeePayment; + /** 납부를 요청한 적이 있는 응답 — 미납(UNPAID)은 그릴 내역이 없어서 목록이 거른다 */ + payment: FeeResponse & { feeStatus: FeePaymentStatus }; /** 없으면 문의하기 버튼을 그리지 않는다(채널 주소가 아직 없을 때) */ onContact?: () => void; } @@ -19,9 +20,13 @@ function formatAmount(value: number | null) { // 납부 요약은 공용 StatusSummaryCard이고, 그 아래에 확인필요일 때만 학생회 확인 내용 카드(학생회비 로컬)가 붙는다. // 행 간격은 Figma(8px)가 아니라 다른 요약 카드와 같은 6px다 — Figma 쪽 실수로 보고 맞췄다(#120). function FeePaymentCard({ payment, onContact }: FeePaymentCardProps) { + // 확인필요면 서버가 금액을 비워서 준다(다시 낼 때 학기 수부터 새로 받기 때문) — 그때는 "-"다 const rows = [ - { label: "납부금액", value: formatAmount(payment.amount) }, - { label: "납부일시", value: formatDateTime(payment.paidAt) }, + { label: "납부금액", value: formatAmount(payment.transferAmount) }, + { + label: "납부일시", + value: payment.requestedAt ? formatDateTime(payment.requestedAt) : "-", + }, { label: "확인일시", value: payment.confirmedAt ? formatDateTime(payment.confirmedAt) : "-", @@ -31,12 +36,12 @@ function FeePaymentCard({ payment, onContact }: FeePaymentCardProps) { return (
- {payment.status === "REJECTED" && ( + {payment.feeStatus === "REJECTED" && (
- {payment.reviewNote} + {payment.rejectReason}
{onContact && ( diff --git a/src/features/fee/components/FeePaymentList.tsx b/src/features/fee/components/FeePaymentList.tsx index dc99e960..695e2277 100644 --- a/src/features/fee/components/FeePaymentList.tsx +++ b/src/features/fee/components/FeePaymentList.tsx @@ -1,5 +1,7 @@ import { useSuspenseQuery } from "@tanstack/react-query"; +import emptyEventsIllustration from "@/assets/icons/common/empty-events.svg"; +import EmptyState from "@/components/ui/EmptyState"; import { feeQueries } from "@/entities/fee/feeApi"; import FeePaymentCard from "@/features/fee/components/FeePaymentCard"; @@ -8,19 +10,32 @@ interface FeePaymentListProps { onContact?: () => void; } -// 학기별 납부 내역을 최신순으로 쌓는다. Figma는 한 건만 그렸고, 여러 건이면 건 사이를 카드 간격보다 넓게(12px) 둔다. +// 서버가 학생의 최신 납부 요청 한 건만 주므로 카드도 한 장만 그린다. function FeePaymentList({ onContact }: FeePaymentListProps) { - const { data: payments } = useSuspenseQuery(feeQueries.payments()); + const { data: payment } = useSuspenseQuery({ + ...feeQueries.me(), + // 한 번도 요청하지 않았으면(UNPAID) 그릴 내역이 없다 + select: (fee) => + fee.feeStatus === "UNPAID" ? null : { ...fee, feeStatus: fee.feeStatus }, + }); - return ( -
- {payments.map((payment) => ( - + - ))} +
+ ); + } + + return ( +
+
); } diff --git a/src/features/fee/constants/fee.ts b/src/features/fee/constants/fee.ts index 2eb70d61..cbb8ee4b 100644 --- a/src/features/fee/constants/fee.ts +++ b/src/features/fee/constants/fee.ts @@ -9,6 +9,9 @@ export const FEE_TRANSFER_PATH = "/fee/transfer"; // 학생회비 납부내역 화면 경로 export const FEE_PAYMENTS_PATH = "/my/fee"; +// 납부내역 카드 제목 — 서버 응답에 없는 값이라 화면이 들고 있는다 +export const FEE_TITLE = "소프트웨어융합대학 학생회비"; + // 아직 받지 못한 외부 연동 주소들. 값이 있을 때만 열도록 해서 빈 창이 뜨는 걸 막는다. // // - kSmartMentor: 학점이수현황을 확인하는 국민대 K-스마트멘토 주소 From 9ebba1b8ad64c7091987edeb50300dcbd39d1da0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:00:44 +0900 Subject: [PATCH 04/22] =?UTF-8?q?feat:=20=EB=82=A8=EC=9D=80=20=ED=95=99?= =?UTF-8?q?=EA=B8=B0=20=EC=88=98=EB=A1=9C=20=EC=84=9C=EB=B2=84=EA=B0=80=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=ED=95=9C=20=ED=86=A0=EC=8A=A4=20=EC=86=A1?= =?UTF-8?q?=EA=B8=88=20=EB=A7=81=ED=81=AC=EB=A5=BC=20=EB=B0=9B=EC=95=84=20?= =?UTF-8?q?=EC=97=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/fee/feeApi.ts | 13 ++ src/features/fee/FeeTransferScreen.tsx | 159 ++++++++++++------ src/features/fee/constants/fee.ts | 5 +- .../fee/constants/feeTransferFunnel.ts | 17 +- .../fee/steps/FeeTransferTossStep.tsx | 4 +- .../fee/steps/FeeTransferTranscriptStep.tsx | 4 + 6 files changed, 138 insertions(+), 64 deletions(-) diff --git a/src/entities/fee/feeApi.ts b/src/entities/fee/feeApi.ts index 964abac6..5ece5c9c 100644 --- a/src/entities/fee/feeApi.ts +++ b/src/entities/fee/feeApi.ts @@ -6,6 +6,8 @@ import { api } from "@/lib/api/client"; const feeKeys = { root: () => ["fee"] as const, me: () => [...feeKeys.root(), "me"] as const, + transferGuide: (remainingSemesters: number) => + [...feeKeys.root(), "transfer-guide", remainingSemesters] as const, }; // 화면은 이 queryOptions로만 데이터를 받는다. 화면 모양으로 바꿔야 하면 화면 쪽에서 select를 더한다. @@ -17,4 +19,15 @@ export const feeQueries = { queryFn: ({ signal }) => api.get("/v1/app/fee/me", { signal }), }), + // 남은 학기 수로 계산한 금액과 토스 송금 링크 — 같은 API에 학기 수를 실어 보낸다(서버는 아무것도 저장하지 않는다). + // 지금 낼 수 없는 상태(납부확인중·납부완료)면 링크가 null이다. + transferGuide: (remainingSemesters: number) => + queryOptions({ + queryKey: feeKeys.transferGuide(remainingSemesters), + queryFn: ({ signal }) => + api.get("/v1/app/fee/me", { + params: { remainingSemesters }, + signal, + }), + }), }; diff --git a/src/features/fee/FeeTransferScreen.tsx b/src/features/fee/FeeTransferScreen.tsx index b3add421..eaf60761 100644 --- a/src/features/fee/FeeTransferScreen.tsx +++ b/src/features/fee/FeeTransferScreen.tsx @@ -1,3 +1,8 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; + +import ScreenToast from "@/components/ui/ScreenToast"; +import { feeQueries } from "@/entities/fee/feeApi"; import { FEE_EXTERNAL_LINKS } from "@/features/fee/constants/fee"; import { FEE_TRANSFER_STEP_OPTIONS, @@ -28,6 +33,11 @@ const SLIDE = { viewTransition: true }; // 값을 다시 계산하지 않게 한다. const INITIAL_STATE = { context: {}, step: "semester" } as const; +// 단계를 넘기지 못했을 때 띄우는 토스트 문구 +const TOAST_MESSAGES = { + transferGuideFailed: "송금 정보를 불러오지 못했어요. 다시 시도해 주세요.", +} as const; + function FeeTransferScreen() { // 첫 단계의 뒤로가기 — 타고 들어왔으면 한 단계 뒤로, 플로우로 바로 들어왔으면(딥링크·앱 // WebView 진입·주소 직접 입력) 홈으로 보낸다. `navigate(-1)`만 쓰면 먹통이 된다(#107 리뷰). @@ -40,63 +50,104 @@ function FeeTransferScreen() { initial: INITIAL_STATE, steps: FEE_TRANSFER_STEP_OPTIONS, }); + const queryClient = useQueryClient(); + // 캡처 단계의 "다음" — 송금 링크를 받는 동안 버튼을 막는다 + const [isPreparingToss, setPreparingToss] = useState(false); + // 닫힌 뒤에도 마지막 문구를 들고 있어서 닫히는 동안 문구가 바뀌지 않는다. + // 떠 있는 동안 다시 띄워도 새로 안내되게 key를 올린다(ScreenToast의 key) + const [toast, setToast] = useState({ isOpen: false, key: 0, message: "" }); + + const showToast = (message: string) => + setToast((prev) => ({ isOpen: true, key: prev.key + 1, message })); return ( - ( - exitFunnel(index)} /> - )} - confirm={({ history, index }) => ( - history.back()} - // 확인 요청을 보내면 송금 확인으로 돌아올 일이 없어서 히스토리를 대체한다 - onConfirm={() => history.replace("complete", {}, SLIDE)} - // 송금을 미루고 나가는 길 — 다음에 들어오면 플로우를 처음부터 다시 탄다 - onPostpone={() => exitFunnel(index)} - /> - )} - semester={({ context, history }) => ( - - history.replace("semester", { semester }) - } - onNext={(semester) => history.push("transcript", { semester }, SLIDE)} - /> - )} - toss={({ history }) => ( - history.back()} - onOpenToss={() => { - openExternalLink(FEE_EXTERNAL_LINKS.tossTransfer); - // 토스에 다녀오면 돌아올 화면이 송금 확인 단계다. 딥링크가 실제로 열렸는지는 - // 알 수 없어서 열기만 하고 넘긴다 — 송금하지 못했으면 그 단계에서 빠져나간다. - history.push("confirm", {}, SLIDE); - }} - /> - )} - transcript={({ context, history }) => ( - history.back()} - // 단계를 바꾸지 않고 context만 갈아 끼운다(replace) — 토스를 다녀와도 고른 파일이 남는다 - onChangeTranscript={(transcript) => - history.replace("transcript", (prev) => ({ ...prev, transcript })) - } - onNext={(transcript) => history.push("toss", { transcript }, SLIDE)} - // 주소를 아직 못 받아서 비어 있을 수 있다 — 빈 주소로 열면 빈 탭이 뜬다 - onOpenGuide={() => { - if (FEE_EXTERNAL_LINKS.kSmartMentor) { - openExternalLink(FEE_EXTERNAL_LINKS.kSmartMentor); + <> + ( + exitFunnel(index)} /> + )} + confirm={({ history, index }) => ( + history.back()} + // 확인 요청을 보내면 송금 확인으로 돌아올 일이 없어서 히스토리를 대체한다 + onConfirm={() => history.replace("complete", {}, SLIDE)} + // 송금을 미루고 나가는 길 — 다음에 들어오면 플로우를 처음부터 다시 탄다 + onPostpone={() => exitFunnel(index)} + /> + )} + semester={({ context, history }) => ( + + history.replace("semester", { semester }) } - }} - /> - )} - /> + onNext={(semester) => + history.push("transcript", { semester }, SLIDE) + } + /> + )} + toss={({ context, history }) => ( + history.back()} + onOpenToss={() => { + if (!context.transferLinkUrl) { + return; + } + openExternalLink(context.transferLinkUrl); + // 토스에 다녀오면 돌아올 화면이 송금 확인 단계다. 딥링크가 실제로 열렸는지는 + // 알 수 없어서 열기만 하고 넘긴다 — 송금하지 못했으면 그 단계에서 빠져나간다. + history.push("confirm", {}, SLIDE); + }} + /> + )} + transcript={({ context, history }) => ( + history.back()} + // 단계를 바꾸지 않고 context만 갈아 끼운다(replace) — 토스를 다녀와도 고른 파일이 남는다 + onChangeTranscript={(transcript) => + history.replace("transcript", (prev) => ({ ...prev, transcript })) + } + // 송금 링크는 남은 학기 수마다 금액이 달라서 토스 단계로 넘어가기 직전에 서버에서 받는다 + onNext={(transcript) => { + setPreparingToss(true); + queryClient + .fetchQuery(feeQueries.transferGuide(context.semester)) + .then(({ transferLinkUrl }) => + history.push("toss", { transcript, transferLinkUrl }, SLIDE), + ) + .catch((error: unknown) => { + console.error(error); + showToast(TOAST_MESSAGES.transferGuideFailed); + }) + .finally(() => setPreparingToss(false)); + }} + // 주소를 아직 못 받아서 비어 있을 수 있다 — 빈 주소로 열면 빈 탭이 뜬다 + onOpenGuide={() => { + if (FEE_EXTERNAL_LINKS.kSmartMentor) { + openExternalLink(FEE_EXTERNAL_LINKS.kSmartMentor); + } + }} + /> + )} + /> + + { + if (!open) { + setToast((prev) => ({ ...prev, isOpen: false })); + } + }} + open={toast.isOpen} + /> + ); } diff --git a/src/features/fee/constants/fee.ts b/src/features/fee/constants/fee.ts index cbb8ee4b..6d36ec4a 100644 --- a/src/features/fee/constants/fee.ts +++ b/src/features/fee/constants/fee.ts @@ -17,12 +17,11 @@ export const FEE_TITLE = "소프트웨어융합대학 학생회비"; // - kSmartMentor: 학점이수현황을 확인하는 국민대 K-스마트멘토 주소 // - studentCouncilKakaoChannel: 학생회 카카오톡 채널. 비어 있으면 확인필요 카드에 // 문의하기 버튼을 그리지 않는다. -// - tossTransfer: 계좌·금액이 채워진 토스 송금 딥링크. 학생회 계좌와 납부 금액 규칙이 정해져야 -// 만들 수 있다(남은 학기 수에 따라 금액이 달라진다). +// +// 토스 송금 딥링크는 남은 학기 수마다 금액이 달라서 서버가 계산해 준다(feeQueries.transferGuide). export const FEE_EXTERNAL_LINKS = { kSmartMentor: "", studentCouncilKakaoChannel: "", - tossTransfer: "", } as const; // 업로드 받는 이미지 형식 — 학점이수현황 "캡처"라 스크린샷 확장자만 받는다 diff --git a/src/features/fee/constants/feeTransferFunnel.ts b/src/features/fee/constants/feeTransferFunnel.ts index c06fb62c..a4b6e323 100644 --- a/src/features/fee/constants/feeTransferFunnel.ts +++ b/src/features/fee/constants/feeTransferFunnel.ts @@ -7,10 +7,13 @@ // history.state에 실려서, 토스 단계에서 뒤로 돌아와도 고른 파일이 남는다(별도 저장소 불필요). // 다만 history.state는 브라우저마다 크기 상한이 있어서(Firefox 16MiB) 업로드 API가 붙으면 // 파일 대신 서버가 준 식별자를 담는 쪽으로 바꾸는 게 안전하다. +// +// 토스 송금 링크(`transferLinkUrl`)는 캡처 단계에서 "다음"을 누를 때 서버에서 받아 싣는다. 서버가 +// 링크를 주지 않으면(지금 낼 수 없는 상태) null이고, 토스 단계가 이동 실패 화면을 띄운다. export type FeeTransferSteps = { semester: { semester?: number }; transcript: { semester: number; transcript?: File }; - toss: { semester: number; transcript: File }; + toss: { semester: number; transcript: File; transferLinkUrl: string | null }; confirm: { semester: number; transcript: File }; complete: Record; }; @@ -19,14 +22,17 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } -// 남은 학기 수로 받을 수 있는 값. 0학기는 납부할 이유가 없고, 상한은 실제 학적 기준이 없어 -// 두 자리까지만 받는다 — 서버 검증 규칙이 정해지면 맞춘다. -export const SEMESTER_PATTERN = /^[1-9][0-9]?$/; +// 남은 학기 수로 받을 수 있는 값 — 서버 회비 정책(RemainingSemesters)과 같은 1~8이다. +export const SEMESTER_PATTERN = /^[1-8]$/; function isSemester(value: unknown): value is number { return typeof value === "number" && SEMESTER_PATTERN.test(String(value)); } +function isTransferLinkUrl(value: unknown): value is string | null { + return value === null || typeof value === "string"; +} + // 새로고침이나 주소 직접 입력처럼 앞 단계의 값이 없는 채로 중간 단계에 들어오면 guard가 걸러서 // 첫 단계(남은 학기 수)로 돌려보낸다 — 학기 수나 캡처 없이 토스 이동 화면이 열리는 일이 없다. // `File`은 history.state에서 되살아나지 못하는 경우가 있어 `instanceof`로 확인한다. @@ -50,7 +56,8 @@ export const FEE_TRANSFER_STEP_OPTIONS = { guard: (data: unknown): data is FeeTransferSteps["toss"] => isRecord(data) && isSemester(data.semester) && - data.transcript instanceof File, + data.transcript instanceof File && + isTransferLinkUrl(data.transferLinkUrl), }, transcript: { guard: (data: unknown): data is FeeTransferSteps["transcript"] => diff --git a/src/features/fee/steps/FeeTransferTossStep.tsx b/src/features/fee/steps/FeeTransferTossStep.tsx index 6dcf3e2c..75d81b8f 100644 --- a/src/features/fee/steps/FeeTransferTossStep.tsx +++ b/src/features/fee/steps/FeeTransferTossStep.tsx @@ -10,8 +10,8 @@ import FeeTransferStepLayout from "@/features/fee/components/FeeTransferStepLayo // 두 프레임은 같은 단계의 상태 차이다 — 실패하면 일러스트가 빠지고 문구와 버튼 라벨만 바뀐다. // 그래서 퍼널 단계를 나누지 않고 실패 여부 state로 토글한다. // -// 토스는 딥링크라 열렸는지 브라우저가 알려주지 않는다. 그래서 실패 화면은 "링크 주소가 아직 -// 없을 때"(`hasTossLink`)만 띄운다 — 주소가 채워지면 열고 바로 송금 확인 단계로 넘어간다. +// 토스는 딥링크라 열렸는지 브라우저가 알려주지 않는다. 그래서 실패 화면은 "서버가 송금 링크를 +// 주지 않았을 때"(`hasTossLink`)만 띄운다 — 링크가 있으면 열고 바로 송금 확인 단계로 넘어간다. // 실제로 여는 일은 퍼널(FeeTransferScreen)이 `lib/openExternalLink`로 맡는다. // // ₩ 아이콘(3562:163032)은 획이 그려지는 Figma Motion이라 Lottie로 받아 재생한다. 2초 diff --git a/src/features/fee/steps/FeeTransferTranscriptStep.tsx b/src/features/fee/steps/FeeTransferTranscriptStep.tsx index ba3835d5..656bccbc 100644 --- a/src/features/fee/steps/FeeTransferTranscriptStep.tsx +++ b/src/features/fee/steps/FeeTransferTranscriptStep.tsx @@ -16,12 +16,15 @@ import FeeUploadedFileCard from "@/features/fee/components/FeeUploadedFileCard"; // 띄우고, 연동할 때 이 자리에서 업로드 요청을 보내면 된다. // K-스마트멘토를 여는 일은 퍼널(FeeTransferScreen)이 맡고 여기는 `onOpenGuide`만 부른다. function FeeTransferTranscriptStep({ + isNextPending, transcript, onBack, onChangeTranscript, onNext, onOpenGuide, }: { + /** "다음"을 누른 뒤 토스 단계로 넘어갈 준비(송금 링크 받기)를 하는 중인지 */ + isNextPending: boolean; /** 고른 캡처 — 퍼널 context가 들고 있는 값이 유일한 출처다 */ transcript: File | undefined; onBack: () => void; @@ -61,6 +64,7 @@ function FeeTransferTranscriptStep({ actions={ transcript && onNext(transcript)} sx={{ paddingBlock: "16px" }} > From 267a47fdabab2e4fcb781e3112c7c7cd45b72f97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:03:37 +0900 Subject: [PATCH 05/22] =?UTF-8?q?feat:=20=EC=BA=A1=EC=B2=98=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=EC=97=90=EC=84=9C=20=EB=8B=A4=EC=9D=8C=EC=9D=84=20?= =?UTF-8?q?=EB=88=84=EB=A5=B4=EB=A9=B4=20=ED=95=99=EC=A0=90=EC=9D=B4?= =?UTF-8?q?=EC=88=98=ED=98=84=ED=99=A9=20=EC=BA=A1=EC=B2=98=EB=A5=BC=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/files/filesApi.ts | 34 +++++++++ src/entities/files/types.ts | 13 ++++ src/features/fee/FeeTransferScreen.tsx | 73 +++++++++++++++---- src/features/fee/constants/fee.ts | 16 ++++ .../fee/constants/feeTransferFunnel.ts | 21 ++++-- .../fee/steps/FeeTransferTranscriptStep.tsx | 5 +- 6 files changed, 138 insertions(+), 24 deletions(-) create mode 100644 src/entities/files/filesApi.ts create mode 100644 src/entities/files/types.ts diff --git a/src/entities/files/filesApi.ts b/src/entities/files/filesApi.ts new file mode 100644 index 00000000..e00a8d46 --- /dev/null +++ b/src/entities/files/filesApi.ts @@ -0,0 +1,34 @@ +import { useMutation } from "@tanstack/react-query"; +import axios from "axios"; + +import type { FileCategory, FileUploadUrl } from "@/entities/files/types"; +import { api } from "@/lib/api/client"; + +interface UploadFileVariables { + file: File; + category: FileCategory; +} + +// 파일을 스토리지에 올리고, 다른 API에 넘길 파일 키를 돌려준다. +// 서버에서 업로드 URL을 받은 뒤(POST /v1/files/presigned-url) 그 주소로 파일 본문을 바로 PUT한다. +// PUT은 apiClient를 거치지 않는다 — 스토리지 주소라 baseURL·인증 헤더가 붙으면 안 되고, 응답도 서버 봉투가 아니다. +export function useUploadFileMutation() { + return useMutation({ + mutationFn: async ({ file, category }: UploadFileVariables) => { + const { fileKey, uploadUrl } = await api.post( + "/v1/files/presigned-url", + { + category, + contentType: file.type, + fileSize: file.size, + originalName: file.name, + }, + ); + // 서명에 Content-Type이 들어가 있어서 발급 때 보낸 값과 같아야 한다 + await axios.put(uploadUrl, file, { + headers: { "Content-Type": file.type }, + }); + return fileKey; + }, + }); +} diff --git a/src/entities/files/types.ts b/src/entities/files/types.ts new file mode 100644 index 00000000..245bc16e --- /dev/null +++ b/src/entities/files/types.ts @@ -0,0 +1,13 @@ +// 업로드 URL을 받을 때 밝히는 파일 용도(서버 FileCategory) — 용도마다 서버가 받는 형식·용량이 다르다. +// 앱이 올리는 용도만 둔다. +export type FileCategory = "FEE_TRANSCRIPT_IMAGE"; + +// POST /v1/files/presigned-url 응답(서버 FileUploadUrlIssueResponse) +export interface FileUploadUrl { + fileId: number; + /** 스토리지에 파일 본문을 바로 PUT하는 서명된 주소 */ + uploadUrl: string; + /** 다른 API(학생회비 납부 요청 등)에 넘기는 파일 키 */ + fileKey: string; + expiresAt: string; +} diff --git a/src/features/fee/FeeTransferScreen.tsx b/src/features/fee/FeeTransferScreen.tsx index eaf60761..75d57b44 100644 --- a/src/features/fee/FeeTransferScreen.tsx +++ b/src/features/fee/FeeTransferScreen.tsx @@ -1,9 +1,13 @@ import { useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; +import { useRef, useState } from "react"; import ScreenToast from "@/components/ui/ScreenToast"; import { feeQueries } from "@/entities/fee/feeApi"; -import { FEE_EXTERNAL_LINKS } from "@/features/fee/constants/fee"; +import { useUploadFileMutation } from "@/entities/files/filesApi"; +import { + FEE_EXTERNAL_LINKS, + isAcceptableTranscript, +} from "@/features/fee/constants/fee"; import { FEE_TRANSFER_STEP_OPTIONS, type FeeTransferSteps, @@ -22,7 +26,8 @@ import { useFunnel, useFunnelExit } from "@/lib/useFunnel"; // useFunnel 하나로 묶은 화면이다. 단계 화면은 `steps/`에 하나씩 나눠 두고, 이 파일은 단계 전환 // (어느 단계에서 어디로, 무슨 값을 넘기는지)만 맡는다 — 로그인·온보딩 퍼널(AuthSignupScreen)과 같은 구성이다. // 단계는 `/fee/transfer?fee.step=...`으로 나뉘고 단계마다 브라우저 뒤로가기가 한 단계씩 돌아간다. -// 단계 사이에 넘기는 값(남은 학기 수·캡처 이미지)은 퍼널 context가 들고 있어서 뒤로 돌아와도 유지된다. +// 단계 사이에 넘기는 값(남은 학기 수·캡처 이미지와 그 파일 키·송금 링크)은 퍼널 context가 들고 있어서 +// 뒤로 돌아와도 유지된다. // 단계가 바뀌는 이동에만 슬라이드를 켠다 — 학기 수 입력·파일 선택처럼 같은 단계의 context만 // 갈아 끼우는 replace에는 켜지 않는다. 뒤로가기는 React Router가 앞선 이동을 기억해 반대 방향으로 @@ -35,9 +40,17 @@ const INITIAL_STATE = { context: {}, step: "semester" } as const; // 단계를 넘기지 못했을 때 띄우는 토스트 문구 const TOAST_MESSAGES = { - transferGuideFailed: "송금 정보를 불러오지 못했어요. 다시 시도해 주세요.", + invalidTranscript: "10MB 이하의 PNG·JPG·WEBP 이미지만 올릴 수 있어요.", + prepareTossFailed: "다음 단계로 넘어가지 못했어요. 다시 시도해 주세요.", } as const; +// history.state에서 되살아난 File은 고를 때와 다른 객체라 이름·크기·수정 시각으로 같은 파일인지 본다 +function isSameFile(a: File, b: File) { + return ( + a.name === b.name && a.size === b.size && a.lastModified === b.lastModified + ); +} + function FeeTransferScreen() { // 첫 단계의 뒤로가기 — 타고 들어왔으면 한 단계 뒤로, 플로우로 바로 들어왔으면(딥링크·앱 // WebView 진입·주소 직접 입력) 홈으로 보낸다. `navigate(-1)`만 쓰면 먹통이 된다(#107 리뷰). @@ -51,8 +64,11 @@ function FeeTransferScreen() { steps: FEE_TRANSFER_STEP_OPTIONS, }); const queryClient = useQueryClient(); - // 캡처 단계의 "다음" — 송금 링크를 받는 동안 버튼을 막는다 + const { mutateAsync: uploadFile } = useUploadFileMutation(); + // 캡처 단계의 "다음" — 캡처를 올리고 송금 링크를 받는 동안 버튼을 막는다 const [isPreparingToss, setPreparingToss] = useState(false); + // 마지막으로 올린 캡처와 그 키 — 토스에서 돌아와 같은 파일로 다시 "다음"을 누르면 또 올리지 않는다 + const lastUploadRef = useRef<{ file: File; fileKey: string } | null>(null); // 닫힌 뒤에도 마지막 문구를 들고 있어서 닫히는 동안 문구가 바뀌지 않는다. // 떠 있는 동안 다시 띄워도 새로 안내되게 key를 올린다(ScreenToast의 key) const [toast, setToast] = useState({ isOpen: false, key: 0, message: "" }); @@ -60,6 +76,19 @@ function FeeTransferScreen() { const showToast = (message: string) => setToast((prev) => ({ isOpen: true, key: prev.key + 1, message })); + const uploadTranscript = async (file: File) => { + const lastUpload = lastUploadRef.current; + if (lastUpload && isSameFile(lastUpload.file, file)) { + return lastUpload.fileKey; + } + const fileKey = await uploadFile({ + category: "FEE_TRANSCRIPT_IMAGE", + file, + }); + lastUploadRef.current = { file, fileKey }; + return fileKey; + }; + return ( <> history.back()} // 단계를 바꾸지 않고 context만 갈아 끼운다(replace) — 토스를 다녀와도 고른 파일이 남는다 - onChangeTranscript={(transcript) => - history.replace("transcript", (prev) => ({ ...prev, transcript })) - } - // 송금 링크는 남은 학기 수마다 금액이 달라서 토스 단계로 넘어가기 직전에 서버에서 받는다 + onChangeTranscript={(transcript) => { + if (transcript && !isAcceptableTranscript(transcript)) { + showToast(TOAST_MESSAGES.invalidTranscript); + return; + } + history.replace("transcript", (prev) => ({ + ...prev, + transcript, + })); + }} + // 송금하러 가기 전에 캡처를 올려 둔다 — 업로드가 실패하면 송금 전에 알 수 있다. + // 송금 링크는 남은 학기 수마다 금액이 달라서 이때 같이 받는다 onNext={(transcript) => { setPreparingToss(true); - queryClient - .fetchQuery(feeQueries.transferGuide(context.semester)) - .then(({ transferLinkUrl }) => - history.push("toss", { transcript, transferLinkUrl }, SLIDE), + Promise.all([ + uploadTranscript(transcript), + queryClient.fetchQuery( + feeQueries.transferGuide(context.semester), + ), + ]) + .then(([proofImageKey, { transferLinkUrl }]) => + history.push( + "toss", + { proofImageKey, transcript, transferLinkUrl }, + SLIDE, + ), ) .catch((error: unknown) => { console.error(error); - showToast(TOAST_MESSAGES.transferGuideFailed); + showToast(TOAST_MESSAGES.prepareTossFailed); }) .finally(() => setPreparingToss(false)); }} diff --git a/src/features/fee/constants/fee.ts b/src/features/fee/constants/fee.ts index 6d36ec4a..9cf02e3a 100644 --- a/src/features/fee/constants/fee.ts +++ b/src/features/fee/constants/fee.ts @@ -27,6 +27,22 @@ export const FEE_EXTERNAL_LINKS = { // 업로드 받는 이미지 형식 — 학점이수현황 "캡처"라 스크린샷 확장자만 받는다 export const FEE_TRANSCRIPT_ACCEPT = "image/png,image/jpeg,image/webp"; +// 서버 업로드 정책(FileUploadPolicy의 FEE_TRANSCRIPT_IMAGE)과 같은 값. 서버는 MIME이 아니라 +// 파일 이름의 확장자로 판단해서, `accept`를 통과해도 이름에 확장자가 없으면 거절한다. +const FEE_TRANSCRIPT_EXTENSIONS = ["jpg", "jpeg", "png", "webp"]; +const FEE_TRANSCRIPT_MAX_BYTES = 10 * 1024 * 1024; + +// 서버가 받아 줄 캡처인지 — 송금하러 가기 전에 걸러야 해서 고를 때 먼저 본다 +export function isAcceptableTranscript(file: File): boolean { + const extension = file.name.split(".").pop()?.toLowerCase() ?? ""; + return ( + file.name.includes(".") && + FEE_TRANSCRIPT_EXTENSIONS.includes(extension) && + file.size > 0 && + file.size <= FEE_TRANSCRIPT_MAX_BYTES + ); +} + // 학생회비 납부 상태 배지 — 납부내역 카드와 홈 내 정보 목록(HomeInfoList)이 같이 쓴다 // Figma: 학생회비 납부 여부 뱃지 (nodeId 3147:146475) export const FEE_STATUS_BADGES: Record< diff --git a/src/features/fee/constants/feeTransferFunnel.ts b/src/features/fee/constants/feeTransferFunnel.ts index a4b6e323..f19d7955 100644 --- a/src/features/fee/constants/feeTransferFunnel.ts +++ b/src/features/fee/constants/feeTransferFunnel.ts @@ -5,16 +5,21 @@ // // 캡처 이미지는 `File`을 context에 그대로 담는다 — `File`은 구조화 복제가 되는 값이라 // history.state에 실려서, 토스 단계에서 뒤로 돌아와도 고른 파일이 남는다(별도 저장소 불필요). -// 다만 history.state는 브라우저마다 크기 상한이 있어서(Firefox 16MiB) 업로드 API가 붙으면 -// 파일 대신 서버가 준 식별자를 담는 쪽으로 바꾸는 게 안전하다. +// history.state는 브라우저마다 크기 상한이 있지만(Firefox 16MiB) 서버 업로드 상한(10MB)보다 커서 넘지 않는다. // -// 토스 송금 링크(`transferLinkUrl`)는 캡처 단계에서 "다음"을 누를 때 서버에서 받아 싣는다. 서버가 -// 링크를 주지 않으면(지금 낼 수 없는 상태) null이고, 토스 단계가 이동 실패 화면을 띄운다. +// 캡처 단계에서 "다음"을 누르면 캡처를 서버에 올리고 토스 송금 링크를 받아 함께 싣는다. +// - `proofImageKey`: 올린 캡처의 파일 키 — 송금 확인에서 납부 요청에 넘긴다 +// - `transferLinkUrl`: 서버가 링크를 주지 않으면(지금 낼 수 없는 상태) null이고, 토스 단계가 이동 실패 화면을 띄운다 export type FeeTransferSteps = { semester: { semester?: number }; transcript: { semester: number; transcript?: File }; - toss: { semester: number; transcript: File; transferLinkUrl: string | null }; - confirm: { semester: number; transcript: File }; + toss: { + semester: number; + transcript: File; + proofImageKey: string; + transferLinkUrl: string | null; + }; + confirm: { semester: number; transcript: File; proofImageKey: string }; complete: Record; }; @@ -45,7 +50,8 @@ export const FEE_TRANSFER_STEP_OPTIONS = { guard: (data: unknown): data is FeeTransferSteps["confirm"] => isRecord(data) && isSemester(data.semester) && - data.transcript instanceof File, + data.transcript instanceof File && + typeof data.proofImageKey === "string", }, semester: { guard: (data: unknown): data is FeeTransferSteps["semester"] => @@ -57,6 +63,7 @@ export const FEE_TRANSFER_STEP_OPTIONS = { isRecord(data) && isSemester(data.semester) && data.transcript instanceof File && + typeof data.proofImageKey === "string" && isTransferLinkUrl(data.transferLinkUrl), }, transcript: { diff --git a/src/features/fee/steps/FeeTransferTranscriptStep.tsx b/src/features/fee/steps/FeeTransferTranscriptStep.tsx index 656bccbc..408f348c 100644 --- a/src/features/fee/steps/FeeTransferTranscriptStep.tsx +++ b/src/features/fee/steps/FeeTransferTranscriptStep.tsx @@ -12,8 +12,7 @@ import FeeUploadedFileCard from "@/features/fee/components/FeeUploadedFileCard"; // K-스마트멘토 이동 확인 모달 (3562:162940) / 업로드한 이미지 미리보기 (3595:70574) // // 네 프레임이 한 단계의 상태 변화라 퍼널 단계를 나누지 않고 업로드 여부·모달 열림으로 토글한다. -// 업로드 API가 아직 없어서 고른 파일을 퍼널 context에 담아 두기만 한다 — 미리보기는 objectURL로 -// 띄우고, 연동할 때 이 자리에서 업로드 요청을 보내면 된다. +// 고른 파일은 퍼널 context에 담아 두고(미리보기는 objectURL), "다음"을 누르면 퍼널이 서버에 올린다. // K-스마트멘토를 여는 일은 퍼널(FeeTransferScreen)이 맡고 여기는 `onOpenGuide`만 부른다. function FeeTransferTranscriptStep({ isNextPending, @@ -23,7 +22,7 @@ function FeeTransferTranscriptStep({ onNext, onOpenGuide, }: { - /** "다음"을 누른 뒤 토스 단계로 넘어갈 준비(송금 링크 받기)를 하는 중인지 */ + /** "다음"을 누른 뒤 토스 단계로 넘어갈 준비(캡처 업로드·송금 링크 받기)를 하는 중인지 */ isNextPending: boolean; /** 고른 캡처 — 퍼널 context가 들고 있는 값이 유일한 출처다 */ transcript: File | undefined; From 6277c1b044efae7b922c3da39f1ee37bea5b7484 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:06:31 +0900 Subject: [PATCH 06/22] =?UTF-8?q?feat:=20=EC=86=A1=EA=B8=88=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=EC=97=90=EC=84=9C=20=ED=95=99=EC=83=9D=ED=9A=8C?= =?UTF-8?q?=EB=B9=84=20=EB=82=A9=EB=B6=80=20=EC=9A=94=EC=B2=AD=EC=9D=84=20?= =?UTF-8?q?=EB=B3=B4=EB=82=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/fee/feeApi.ts | 41 ++++++++++++++- src/entities/fee/types.ts | 7 +++ src/features/fee/FeeTransferScreen.tsx | 50 +++++++++++++++++-- .../fee/steps/FeeTransferConfirmStep.tsx | 10 +++- 4 files changed, 101 insertions(+), 7 deletions(-) diff --git a/src/entities/fee/feeApi.ts b/src/entities/fee/feeApi.ts index 5ece5c9c..bffaca1c 100644 --- a/src/entities/fee/feeApi.ts +++ b/src/entities/fee/feeApi.ts @@ -1,7 +1,12 @@ -import { queryOptions } from "@tanstack/react-query"; +import { + queryOptions, + useMutation, + useQueryClient, +} from "@tanstack/react-query"; -import type { FeeResponse } from "@/entities/fee/types"; +import type { FeeResponse, FeeSubmitRequest } from "@/entities/fee/types"; import { api } from "@/lib/api/client"; +import { isApiError } from "@/lib/api/errors"; const feeKeys = { root: () => ["fee"] as const, @@ -31,3 +36,35 @@ export const feeQueries = { }), }), }; + +// 납부 요청에서 화면이 갈라 처리하는 서버 에러 코드(FeeErrorCode) +export const FEE_ERROR_CODES = { + // 이미 납부 확인을 요청했거나 납부가 끝난 학생이 또 냈다 + alreadyRequested: "FEE_REQUEST_ALREADY_REQUESTED", + // 캡처 키가 없거나, 본인이 학점이수현황 캡처로 올린 파일이 아니다 + invalidProofImage: "INVALID_PROOF_IMAGE", +} as const; + +// 학생회비 납부 요청(POST /v1/app/fee). 성공하면 납부확인중이 된다. +// 납부 상태와 학기 수별 송금 링크가 모두 바뀌어서 회비 캐시를 통째로 무효화한다 — 이미 요청이 들어가 있다는 +// 응답(409)도 지금 캐시가 낡았다는 뜻이라 같이 무효화한다. +export function useSubmitFeeMutation() { + const queryClient = useQueryClient(); + const invalidateFee = () => + queryClient.invalidateQueries({ queryKey: feeKeys.root() }); + + return useMutation({ + mutationFn: (request: FeeSubmitRequest) => + api.post("/v1/app/fee", request), + // Promise를 돌려줘서 다시 받은 상태가 들어온 뒤에 mutation이 끝나게 한다 + onSuccess: invalidateFee, + onError: (error) => { + if ( + isApiError(error) && + error.code === FEE_ERROR_CODES.alreadyRequested + ) { + return invalidateFee(); + } + }, + }); +} diff --git a/src/entities/fee/types.ts b/src/entities/fee/types.ts index 59944ef1..ccef3525 100644 --- a/src/entities/fee/types.ts +++ b/src/entities/fee/types.ts @@ -24,3 +24,10 @@ export interface FeeResponse { /** 확인필요(REJECTED)일 때만 있다 — 그때는 서버가 항상 채운다 */ rejectReason: string | null; } + +// POST /v1/app/fee 요청 — 송금을 마친 뒤 남은 학기 수와 그 근거인 학점이수현황 캡처를 함께 낸다 +export interface FeeSubmitRequest { + remainingSemesters: number; + /** 업로드 때 받은 캡처의 파일 키 */ + proofImageKey: string; +} diff --git a/src/features/fee/FeeTransferScreen.tsx b/src/features/fee/FeeTransferScreen.tsx index 75d57b44..04d34954 100644 --- a/src/features/fee/FeeTransferScreen.tsx +++ b/src/features/fee/FeeTransferScreen.tsx @@ -2,7 +2,11 @@ import { useQueryClient } from "@tanstack/react-query"; import { useRef, useState } from "react"; import ScreenToast from "@/components/ui/ScreenToast"; -import { feeQueries } from "@/entities/fee/feeApi"; +import { + FEE_ERROR_CODES, + feeQueries, + useSubmitFeeMutation, +} from "@/entities/fee/feeApi"; import { useUploadFileMutation } from "@/entities/files/filesApi"; import { FEE_EXTERNAL_LINKS, @@ -19,6 +23,7 @@ import FeeTransferTossStep from "@/features/fee/steps/FeeTransferTossStep"; import FeeTransferTranscriptStep from "@/features/fee/steps/FeeTransferTranscriptStep"; import { HOME_PATH } from "@/features/home/constants/home"; import { useBackNavigation } from "@/hooks/useBackNavigation"; +import { isApiError } from "@/lib/api/errors"; import { openExternalLink } from "@/lib/openExternalLink"; import { useFunnel, useFunnelExit } from "@/lib/useFunnel"; @@ -39,9 +44,12 @@ const SLIDE = { viewTransition: true }; const INITIAL_STATE = { context: {}, step: "semester" } as const; // 단계를 넘기지 못했을 때 띄우는 토스트 문구 +// 송금 확인 단계의 실패는 이미 송금한 뒤라 "다시 송금하라"로 읽히지 않게 요청 실패로만 안내한다 const TOAST_MESSAGES = { + invalidProofImage: "캡처를 확인하지 못했어요. 다시 올려 주세요.", invalidTranscript: "10MB 이하의 PNG·JPG·WEBP 이미지만 올릴 수 있어요.", prepareTossFailed: "다음 단계로 넘어가지 못했어요. 다시 시도해 주세요.", + submitFailed: "납부 확인 요청에 실패했어요. 다시 시도해 주세요.", } as const; // history.state에서 되살아난 File은 고를 때와 다른 객체라 이름·크기·수정 시각으로 같은 파일인지 본다 @@ -65,6 +73,7 @@ function FeeTransferScreen() { }); const queryClient = useQueryClient(); const { mutateAsync: uploadFile } = useUploadFileMutation(); + const { mutate: submitFee, isPending: isSubmitting } = useSubmitFeeMutation(); // 캡처 단계의 "다음" — 캡처를 올리고 송금 링크를 받는 동안 버튼을 막는다 const [isPreparingToss, setPreparingToss] = useState(false); // 마지막으로 올린 캡처와 그 키 — 토스에서 돌아와 같은 파일로 다시 "다음"을 누르면 또 올리지 않는다 @@ -95,11 +104,44 @@ function FeeTransferScreen() { complete={({ index }) => ( exitFunnel(index)} /> )} - confirm={({ history, index }) => ( + confirm={({ context, history, index }) => ( history.back()} - // 확인 요청을 보내면 송금 확인으로 돌아올 일이 없어서 히스토리를 대체한다 - onConfirm={() => history.replace("complete", {}, SLIDE)} + onConfirm={() => + submitFee( + { + proofImageKey: context.proofImageKey, + remainingSemesters: context.semester, + }, + { + // 확인 요청을 보내면 송금 확인으로 돌아올 일이 없어서 히스토리를 대체한다 + onSuccess: () => history.replace("complete", {}, SLIDE), + onError: (error) => { + // 이미 요청이 들어가 있다 — 응답을 못 받고 다시 누른 경우라 요청은 된 것으로 본다 + if ( + isApiError(error) && + error.code === FEE_ERROR_CODES.alreadyRequested + ) { + history.replace("complete", {}, SLIDE); + return; + } + console.error(error); + // 올린 캡처를 서버가 못 찾았다 — 같은 키를 다시 쓰지 않게 비우고 캡처 단계로 돌려보낸다 + if ( + isApiError(error) && + error.code === FEE_ERROR_CODES.invalidProofImage + ) { + lastUploadRef.current = null; + showToast(TOAST_MESSAGES.invalidProofImage); + history.go(-2); + return; + } + showToast(TOAST_MESSAGES.submitFailed); + }, + }, + ) + } // 송금을 미루고 나가는 길 — 다음에 들어오면 플로우를 처음부터 다시 탄다 onPostpone={() => exitFunnel(index)} /> diff --git a/src/features/fee/steps/FeeTransferConfirmStep.tsx b/src/features/fee/steps/FeeTransferConfirmStep.tsx index 581526ca..58d3fe6c 100644 --- a/src/features/fee/steps/FeeTransferConfirmStep.tsx +++ b/src/features/fee/steps/FeeTransferConfirmStep.tsx @@ -12,10 +12,13 @@ import FeeTransferStepLayout from "@/features/fee/components/FeeTransferStepLayo // Lottie로 받아 재생한다. 플러그인이 이징을 매 프레임으로 구워서 키프레임만 보면 끝을 알 수 // 없는데, 실제 값은 49프레임(0.82초)부터 고정이다 — rotation 6° → -2.5° → 1° → 0°로 잦아든다. function FeeTransferConfirmStep({ + isSubmitting, onBack, onConfirm, onPostpone, }: { + /** 납부 요청을 보내는 중인지 — 그동안 두 버튼을 모두 막는다 */ + isSubmitting: boolean; onBack: () => void; onConfirm: () => void; /** 송금을 미루고 플로우를 빠져나간다 */ @@ -25,13 +28,18 @@ function FeeTransferConfirmStep({ - + 네, 송금했어요 {/* 송금을 미루고 빠져나가는 길 — 플로우를 처음부터 다시 타도록 홈으로 보낸다. Figma Sub Action은 44px인데 WDS 텍스트 버튼은 28px이라 세로 padding을 보정하고, WDS가 따로 주는 세로 margin 8px은 지운다(안 지우면 Figma의 8px 간격이 16px이 된다). */} Date: Tue, 3 Nov 2026 17:08:00 +0900 Subject: [PATCH 07/22] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=20=EB=82=A9?= =?UTF-8?q?=EB=B6=80=EB=A5=BC=20=EC=9A=94=EC=B2=AD=ED=95=9C=20=ED=95=99?= =?UTF-8?q?=EC=83=9D=EC=9D=80=20=EC=86=A1=EA=B8=88=20=ED=8D=BC=EB=84=90=20?= =?UTF-8?q?=EB=8C=80=EC=8B=A0=20=EB=82=A9=EB=B6=80=EB=82=B4=EC=97=AD?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/fee/FeeTransferScreen.tsx | 32 ++++++++++-- .../fee/components/FeeTransferSkeleton.tsx | 50 +++++++++++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 src/features/fee/components/FeeTransferSkeleton.tsx diff --git a/src/features/fee/FeeTransferScreen.tsx b/src/features/fee/FeeTransferScreen.tsx index 04d34954..4aa16ada 100644 --- a/src/features/fee/FeeTransferScreen.tsx +++ b/src/features/fee/FeeTransferScreen.tsx @@ -1,5 +1,6 @@ -import { useQueryClient } from "@tanstack/react-query"; -import { useRef, useState } from "react"; +import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; +import { Suspense, useRef, useState } from "react"; +import { Navigate } from "react-router-dom"; import ScreenToast from "@/components/ui/ScreenToast"; import { @@ -8,8 +9,10 @@ import { useSubmitFeeMutation, } from "@/entities/fee/feeApi"; import { useUploadFileMutation } from "@/entities/files/filesApi"; +import FeeTransferSkeleton from "@/features/fee/components/FeeTransferSkeleton"; import { FEE_EXTERNAL_LINKS, + FEE_PAYMENTS_PATH, isAcceptableTranscript, } from "@/features/fee/constants/fee"; import { @@ -59,7 +62,16 @@ function isSameFile(a: File, b: File) { ); } -function FeeTransferScreen() { +function FeeTransferFunnel() { + const { data: entryFeeStatus } = useSuspenseQuery({ + ...feeQueries.me(), + select: (fee) => fee.feeStatus, + }); + // 들어올 때의 상태로만 판단한다 — 납부 요청을 보내면 납부확인중으로 바뀌는데, 그때 완료 단계에서 + // 쫓겨나면 안 된다. 미납·확인필요만 낼 수 있다(서버도 그 외에는 409로 거절한다). + const [canPay] = useState( + () => entryFeeStatus === "UNPAID" || entryFeeStatus === "REJECTED", + ); // 첫 단계의 뒤로가기 — 타고 들어왔으면 한 단계 뒤로, 플로우로 바로 들어왔으면(딥링크·앱 // WebView 진입·주소 직접 입력) 홈으로 보낸다. `navigate(-1)`만 쓰면 먹통이 된다(#107 리뷰). const goBackFromFlow = useBackNavigation(HOME_PATH); @@ -98,6 +110,11 @@ function FeeTransferScreen() { return fileKey; }; + // 이미 납부 확인을 요청했거나 납부가 끝났으면 송금까지 다 하고 409를 받기 전에 납부내역으로 보낸다 + if (!canPay) { + return ; + } + return ( <> }> + + + ); +} + export default FeeTransferScreen; diff --git a/src/features/fee/components/FeeTransferSkeleton.tsx b/src/features/fee/components/FeeTransferSkeleton.tsx new file mode 100644 index 00000000..576c3338 --- /dev/null +++ b/src/features/fee/components/FeeTransferSkeleton.tsx @@ -0,0 +1,50 @@ +import { Skeleton } from "@wanteddev/wds"; + +import { + useScreenHeaderSkeleton, + useSkeletonAnimation, +} from "@/components/ui/ScreenSkeleton"; + +// 송금 퍼널에 들어올 때 내 납부 상태를 받는 동안 보이는 자리 — 첫 단계(남은 학기 수)의 +// 두 줄 제목, 설명, 입력칸 배치를 따른다. 간격은 FeeTransferStepLayout(헤더 아래 12px, 본문 16px)과 같다. +function FeeTransferSkeleton() { + const animation = useSkeletonAnimation(); + useScreenHeaderSkeleton(); + + return ( +
+
+ + + +
+ +
+ ); +} + +export default FeeTransferSkeleton; From 0585f8d261e12122e2923bf27127d13e1330de0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:09:03 +0900 Subject: [PATCH 08/22] =?UTF-8?q?feat:=20=ED=99=95=EC=9D=B8=ED=95=84?= =?UTF-8?q?=EC=9A=94=20=EB=82=A9=EB=B6=80=EB=82=B4=EC=97=AD=EC=97=90=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EB=82=A9=EB=B6=80=ED=95=98=EA=B8=B0=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/fee/FeePaymentsScreen.tsx | 12 ++++++++++-- src/features/fee/components/FeePaymentCard.tsx | 14 +++++++++++++- src/features/fee/components/FeePaymentList.tsx | 9 +++++++-- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/features/fee/FeePaymentsScreen.tsx b/src/features/fee/FeePaymentsScreen.tsx index 27d4fe9b..cf85b1c9 100644 --- a/src/features/fee/FeePaymentsScreen.tsx +++ b/src/features/fee/FeePaymentsScreen.tsx @@ -1,12 +1,16 @@ 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 FeePaymentList from "@/features/fee/components/FeePaymentList"; import FeePaymentsSkeleton from "@/features/fee/components/FeePaymentsSkeleton"; -import { FEE_EXTERNAL_LINKS } from "@/features/fee/constants/fee"; +import { + FEE_EXTERNAL_LINKS, + FEE_TRANSFER_PATH, +} from "@/features/fee/constants/fee"; import { HOME_PATH } from "@/features/home/constants/home"; import { useBackNavigation } from "@/hooks/useBackNavigation"; import { openExternalLink } from "@/lib/openExternalLink"; @@ -15,6 +19,7 @@ import { openExternalLink } from "@/lib/openExternalLink"; function FeePaymentsScreen() { // 바로 들어와 뒤로 갈 히스토리가 없으면 홈으로 보낸다 const goBack = useBackNavigation(HOME_PATH); + const navigate = useNavigate(); useScreenHeader( }> - + navigate(FEE_TRANSFER_PATH, { viewTransition: true })} + />
); diff --git a/src/features/fee/components/FeePaymentCard.tsx b/src/features/fee/components/FeePaymentCard.tsx index 2297ce16..47af782a 100644 --- a/src/features/fee/components/FeePaymentCard.tsx +++ b/src/features/fee/components/FeePaymentCard.tsx @@ -10,6 +10,8 @@ interface FeePaymentCardProps { payment: FeeResponse & { feeStatus: FeePaymentStatus }; /** 없으면 문의하기 버튼을 그리지 않는다(채널 주소가 아직 없을 때) */ onContact?: () => void; + /** 확인필요일 때 "다시 납부하기" — 송금 플로우를 처음(남은 학기 수)부터 다시 탄다 */ + onRepay: () => void; } function formatAmount(value: number | null) { @@ -19,7 +21,8 @@ function formatAmount(value: number | null) { // Figma: 학생회비 납부내역 납부확인중 (nodeId 3147:147860), 확인필요 (3147:147879), 납부완료 (3147:147841) // 납부 요약은 공용 StatusSummaryCard이고, 그 아래에 확인필요일 때만 학생회 확인 내용 카드(학생회비 로컬)가 붙는다. // 행 간격은 Figma(8px)가 아니라 다른 요약 카드와 같은 6px다 — Figma 쪽 실수로 보고 맞췄다(#120). -function FeePaymentCard({ payment, onContact }: FeePaymentCardProps) { +// "다시 납부하기"는 Figma에 없는 버튼이라 같은 카드의 "문의하기"와 같은 모양으로 그 아래에 둔다. +function FeePaymentCard({ payment, onContact, onRepay }: FeePaymentCardProps) { // 확인필요면 서버가 금액을 비워서 준다(다시 낼 때 학기 수부터 새로 받기 때문) — 그때는 "-"다 const rows = [ { label: "납부금액", value: formatAmount(payment.transferAmount) }, @@ -72,6 +75,15 @@ function FeePaymentCard({ payment, onContact }: FeePaymentCardProps) { 문의하기 )} +
)}
diff --git a/src/features/fee/components/FeePaymentList.tsx b/src/features/fee/components/FeePaymentList.tsx index 695e2277..0d6dfbc0 100644 --- a/src/features/fee/components/FeePaymentList.tsx +++ b/src/features/fee/components/FeePaymentList.tsx @@ -8,10 +8,11 @@ import FeePaymentCard from "@/features/fee/components/FeePaymentCard"; interface FeePaymentListProps { /** 없으면 문의하기 버튼을 그리지 않는다(채널 주소가 아직 없을 때) */ onContact?: () => void; + onRepay: () => void; } // 서버가 학생의 최신 납부 요청 한 건만 주므로 카드도 한 장만 그린다. -function FeePaymentList({ onContact }: FeePaymentListProps) { +function FeePaymentList({ onContact, onRepay }: FeePaymentListProps) { const { data: payment } = useSuspenseQuery({ ...feeQueries.me(), // 한 번도 요청하지 않았으면(UNPAID) 그릴 내역이 없다 @@ -35,7 +36,11 @@ function FeePaymentList({ onContact }: FeePaymentListProps) { return (
- +
); } From b40368392e5b0d160c37c66fa3d35b39527ed070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:10:16 +0900 Subject: [PATCH 09/22] =?UTF-8?q?docs:=20=ED=95=99=EC=83=9D=ED=9A=8C?= =?UTF-8?q?=EB=B9=84=20API=20=EC=97=B0=EB=8F=99=EA=B3=BC=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=B0=A9=EC=8B=9D?= =?UTF-8?q?=EC=9D=84=20=EC=BB=A8=EB=B2=A4=EC=85=98=EC=97=90=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/conventions/coding-style.md | 3 ++- docs/conventions/wds-component-usage.md | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index 2cc1c873..93abc0d8 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -52,9 +52,10 @@ - 서버 데이터는 TanStack Query로 받는다. 조회 정의와 타입은 `entities/<도메인>/`에 둔다(`<도메인>Api.ts`·`types.ts`). API 파일은 `<도메인>Queries` 객체에 조회마다 `queryOptions`를 돌려주는 함수를 두고(`eventsQueries.detail(id)`), 화면은 데이터 컴포넌트에서 `const { data } = useSuspenseQuery(eventsQueries.detail(id))`로 읽는다. 화면 모양으로 바꿔야 하면 화면 쪽에서 `select`를 더한다 — 캐시에는 응답 그대로 둔다. - 조회 key는 API 파일 안 key 함수 하나(`eventsKeys`)로 만든다. 도메인 이름으로 시작하는 배열을 쌓아서(`root()` → `["events"]`, `application(id)` → `["events", "applications", id]`) 앞부분 key 하나로 아래 조회를 함께 무효화할 수 있게 한다. 조회와 무효화가 같은 key 함수를 써야 한쪽만 바뀌어 무효화가 조용히 빗나가지 않는다. - 요청은 `lib/api/client.ts`의 `api.get(url, { signal })`·`api.post(url, body)` 등으로 보낸다. 서버 응답 봉투(`{ success, code, message, data }`)를 벗겨 `data`만 돌려주고, 실패는 `ApiError`(`status`·`code`·`message`)·`NetworkError`·`TimeoutError`로 던진다(`lib/api/errors.ts`). 화면은 `api`를 직접 부르지 않고 entities의 `queryOptions`·mutation 훅만 쓴다. +- 파일은 `entities/files/filesApi.ts`의 `useUploadFileMutation`으로 올린다. 서버에서 업로드 URL을 받고(`POST /v1/files/presigned-url`, 용도는 `category`) 그 주소로 파일을 바로 PUT한 뒤, 다른 API에 넘길 파일 키를 돌려준다. 이 PUT만 `apiClient`를 거치지 않는다 — 스토리지 주소라 baseURL·인증 헤더가 붙으면 안 되고 응답도 서버 봉투가 아니다. 서버는 형식을 MIME이 아니라 파일 이름의 확장자로, 크기는 용도별 상한으로 거절하므로 고를 때 먼저 걸러 둔다(학생회비 캡처 `isAcceptableTranscript`). - 인증 토큰은 `lib/api/token.ts`로만 읽고 쓴다. 요청마다 `Authorization: Bearer`가 붙고, 서버가 401을 주면 토큰을 지운 뒤 `subscribeTokens`로 알린다 — 로그인 화면 이동은 알림을 구독하는 가드가 맡는다(요청 코드가 라우터를 import하면 router → 화면 → entities → client 순환이 생긴다). 가드는 로그인 실연결 때 붙인다. - 조회 기본값은 `lib/queryClient.ts`에 있다: 1분 안에 다시 들어온 화면은 요청 없이 캐시를 쓰고(`staleTime`), 실패는 네트워크·5xx만 한 번 더 시도한다(4xx는 다시 보내도 같다). 도메인마다 다르게 해야 하면 그 `queryOptions`에 덮어쓴다. -- 실 API 전까지 `queryFn`은 `<도메인>Mock.ts`의 목데이터를 `mockupResponse(() => data)`(`lib/mockupApi.ts`)로 돌려준다. 개발 서버에서는 스켈레톤을 확인할 수 있게 500ms 늦게 응답하고, 배포 빌드에서는 바로 응답한다. API가 붙으면 `queryFn` 안쪽만 `api.get(...)`으로 바꾼다. +- 실 API 전까지 `queryFn`은 `<도메인>Mock.ts`의 목데이터를 `mockupResponse(() => data)`(`lib/mockupApi.ts`)로 돌려준다. 개발 서버에서는 스켈레톤을 확인할 수 있게 500ms 늦게 응답하고, 배포 빌드에서는 바로 응답한다. API가 붙으면 `queryFn` 안쪽만 `api.get(...)`으로 바꾼다 — 회비(`entities/fee`)가 처음 붙인 도메인이다. - 데이터를 바꾸는 요청(신청 취소 등)은 API 파일에 `use<동작>Mutation` 훅(`useCancelEventApplicationMutation`)으로 두고, `onSuccess`에서 바뀐 조회 key를 `invalidateQueries`로 무효화한다(화면은 무효화를 모른다). 무효화 Promise를 돌려줘서 다시 받은 데이터가 들어온 뒤에 mutation이 끝나게 한다. 지금 화면에 없는데 돌아갔을 때 바로 보여야 하는 조회(신청내역 목록 등)는 `refetchQueries({ queryKey, exact: true })`로 그것만 같이 받는다 — `refetchType: "all"`은 열어 봤던 상세까지 전부 다시 받는다. 데이터가 이미 있는 조회는 다시 받는 동안 서스펜드되지 않고 지금 화면을 유지한다. - 화면(`<화면>Screen.tsx`)은 헤더 등록, UI 상태, 이동 같은 동작을 맡고, 데이터를 받는 영역만 `}>`으로 감싼다. `useSuspenseQuery`로 데이터를 읽고 그리는 부분은 `features/<기능>/components/`의 컴포넌트(`EventsList`, `EventsDetailContent` 등)로 분리하고, 이동 같은 동작은 콜백 prop으로 받는다. - 데이터와 무관한 헤더·탭·필터는 Suspense 밖에서 바로 그리고, 스켈레톤은 데이터 영역의 배치만 따라 그린다. 전용 스켈레톤은 `features/<기능>/components/<화면>Skeleton.tsx`에 두고 WDS `Skeleton`으로 그린다. 헤더가 데이터에 따라 달라지는 화면(공지 상세)은 헤더도 데이터 컴포넌트가 등록하고, 스켈레톤이 `useScreenHeaderSkeleton`으로 헤더 자리를 채운다. diff --git a/docs/conventions/wds-component-usage.md b/docs/conventions/wds-component-usage.md index f86dbd89..ae84cf73 100644 --- a/docs/conventions/wds-component-usage.md +++ b/docs/conventions/wds-component-usage.md @@ -654,7 +654,7 @@ Figma는 헤더 배경이 없어서 배너 그림자(`Shadow/Spread/Small`, 60px |---|---|---| | `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="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`로 간다 | @@ -827,6 +827,8 @@ Figma는 같은 알림 설정 화면을 **설정 섹션(`3524:152367`)과 알림 처음에는 단계마다 라우트를 따로 뒀는데(`/fee/transfer/semester` 등 5개), 그러면 단계 사이에 값을 navigate state로 넘겨야 해서 남은 학기 수가 캡처 단계에서 읽히지 않고 고른 파일도 다음 단계로 넘어가지 않았다. 퍼널 context로 바꾸면서 두 값이 플로우 끝까지 이어지고, 토스 단계에서 뒤로 돌아와도 입력이 남는다. 학기 수 입력·파일 선택은 단계를 바꾸지 않고 `history.replace`로 context만 갈아 끼운다(히스토리가 늘지 않음). 앞 단계 값 없이 중간 단계로 들어오면(새로고침·주소 직접 입력) `guard`가 걸러서 첫 단계로 돌려보낸다. -캡처 이미지는 `File`을 context에 그대로 담는다 — `File`은 구조화 복제가 되는 값이라 history.state에 실린다. 다만 history.state는 브라우저마다 크기 상한이 있어서(Firefox 16MiB) 업로드 API가 붙으면 파일 대신 서버가 준 식별자를 담는 쪽으로 바꾸는 게 안전하다. `File`이 history.state에서 되살아나지 못하는 경우도 있어 `guard`가 `instanceof File`로 확인하고, 아니면 첫 단계로 돌려보낸다. +캡처 이미지는 `File`을 context에 그대로 담는다 — `File`은 구조화 복제가 되는 값이라 history.state에 실린다(브라우저 상한 Firefox 16MiB가 서버 업로드 상한 10MB보다 크다). 캡처 단계에서 "다음"을 누르면 퍼널이 캡처를 올리고(`useUploadFileMutation`) 송금 링크를 받아(`feeQueries.transferGuide`) 파일 키(`proofImageKey`)와 링크(`transferLinkUrl`)를 다음 단계 context에 함께 싣는다 — 업로드 실패를 송금하기 **전에** 알리기 위해서다. 토스에서 돌아와 같은 파일로 다시 누르면 또 올리지 않는다. `File`이 history.state에서 되살아나지 못하는 경우도 있어 `guard`가 `instanceof File`로 확인하고, 아니면 첫 단계로 돌려보낸다. + +퍼널은 들어올 때 내 납부 상태(`feeQueries.me`)를 한 번만 보고, 미납·확인필요가 아니면 납부내역으로 보낸다. 들어올 때의 값만 보는 이유는 납부 요청을 보내면 상태가 납부확인중으로 바뀌는데, 그때 완료 단계에서 쫓겨나면 안 되기 때문이다. 단계를 넘기지 못한 실패(업로드·송금 링크·납부 요청)는 퍼널이 토스트 하나로 알린다 — 송금 확인 단계의 실패는 이미 송금한 뒤라 "다시 송금하라"로 읽히지 않게 요청 실패로만 안내한다. 토스 이동 안내/실패(`3562:163025` / `3562:163046`)와 캡처 업로드 전/후(`3562:162845` / `3562:162891`)는 각각 **한 단계 안의 state**다. Figma가 프레임을 나눠 뒀지만 퍼널 단계로는 올리지 않았다 — 뒤로가기로 돌아갈 지점이 아니기 때문이다. From af2504e351ea66e27d3040d4e32bbdcd3aef2d17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:04:34 +0900 Subject: [PATCH 10/22] =?UTF-8?q?fix:=20=EC=86=A1=EA=B8=88=20=ED=8D=BC?= =?UTF-8?q?=EB=84=90=EC=97=90=EC=84=9C=20=EB=92=A4=EB=A1=9C=EA=B0=80?= =?UTF-8?q?=EA=B8=B0=EB=A5=BC=20=EB=88=84=EB=A5=B4=EB=A9=B4=20=EC=B2=AB=20?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=EB=A1=9C=20=EC=B4=88=EA=B8=B0=ED=99=94?= =?UTF-8?q?=EB=90=98=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chromium은 File이 든 history.state를 뒤로가기로 되살리지 못하고 null로 돌려준다. 캡처 파일은 퍼널 화면이 메모리에 들고, context에는 파일 키와 송금 링크만 싣는다. --- src/features/fee/FeeTransferScreen.tsx | 36 ++++++++----------- .../fee/constants/feeTransferFunnel.ts | 21 +++++------ .../fee/steps/FeeTransferTranscriptStep.tsx | 8 ++--- 3 files changed, 26 insertions(+), 39 deletions(-) diff --git a/src/features/fee/FeeTransferScreen.tsx b/src/features/fee/FeeTransferScreen.tsx index 4aa16ada..608bddaf 100644 --- a/src/features/fee/FeeTransferScreen.tsx +++ b/src/features/fee/FeeTransferScreen.tsx @@ -34,10 +34,11 @@ import { useFunnel, useFunnelExit } from "@/lib/useFunnel"; // useFunnel 하나로 묶은 화면이다. 단계 화면은 `steps/`에 하나씩 나눠 두고, 이 파일은 단계 전환 // (어느 단계에서 어디로, 무슨 값을 넘기는지)만 맡는다 — 로그인·온보딩 퍼널(AuthSignupScreen)과 같은 구성이다. // 단계는 `/fee/transfer?fee.step=...`으로 나뉘고 단계마다 브라우저 뒤로가기가 한 단계씩 돌아간다. -// 단계 사이에 넘기는 값(남은 학기 수·캡처 이미지와 그 파일 키·송금 링크)은 퍼널 context가 들고 있어서 -// 뒤로 돌아와도 유지된다. +// 단계 사이에 넘기는 값(남은 학기 수·올린 캡처의 파일 키·송금 링크)은 퍼널 context가 들고 있어서 +// 뒤로 돌아와도 유지된다. 고른 캡처 파일만은 context(history.state)에 담지 못해서 이 화면이 메모리에 들고 +// 있는다(feeTransferFunnel.ts 참고) — 새로고침하면 캡처만 다시 고르면 된다. -// 단계가 바뀌는 이동에만 슬라이드를 켠다 — 학기 수 입력·파일 선택처럼 같은 단계의 context만 +// 단계가 바뀌는 이동에만 슬라이드를 켠다 — 학기 수 입력처럼 같은 단계의 context만 // 갈아 끼우는 replace에는 켜지 않는다. 뒤로가기는 React Router가 앞선 이동을 기억해 반대 방향으로 // 자동 적용한다(coding-style.md 라우팅). const SLIDE = { viewTransition: true }; @@ -55,13 +56,6 @@ const TOAST_MESSAGES = { submitFailed: "납부 확인 요청에 실패했어요. 다시 시도해 주세요.", } as const; -// history.state에서 되살아난 File은 고를 때와 다른 객체라 이름·크기·수정 시각으로 같은 파일인지 본다 -function isSameFile(a: File, b: File) { - return ( - a.name === b.name && a.size === b.size && a.lastModified === b.lastModified - ); -} - function FeeTransferFunnel() { const { data: entryFeeStatus } = useSuspenseQuery({ ...feeQueries.me(), @@ -86,6 +80,8 @@ function FeeTransferFunnel() { const queryClient = useQueryClient(); const { mutateAsync: uploadFile } = useUploadFileMutation(); const { mutate: submitFee, isPending: isSubmitting } = useSubmitFeeMutation(); + // 고른 캡처 — 단계를 오가도 이 화면은 그대로 떠 있어서 토스에서 돌아와도 남는다 + const [transcript, setTranscript] = useState(); // 캡처 단계의 "다음" — 캡처를 올리고 송금 링크를 받는 동안 버튼을 막는다 const [isPreparingToss, setPreparingToss] = useState(false); // 마지막으로 올린 캡처와 그 키 — 토스에서 돌아와 같은 파일로 다시 "다음"을 누르면 또 올리지 않는다 @@ -99,7 +95,7 @@ function FeeTransferFunnel() { const uploadTranscript = async (file: File) => { const lastUpload = lastUploadRef.current; - if (lastUpload && isSameFile(lastUpload.file, file)) { + if (lastUpload?.file === file) { return lastUpload.fileKey; } const fileKey = await uploadFile({ @@ -195,25 +191,21 @@ function FeeTransferFunnel() { transcript={({ context, history }) => ( history.back()} - // 단계를 바꾸지 않고 context만 갈아 끼운다(replace) — 토스를 다녀와도 고른 파일이 남는다 - onChangeTranscript={(transcript) => { - if (transcript && !isAcceptableTranscript(transcript)) { + onChangeTranscript={(nextTranscript) => { + if (nextTranscript && !isAcceptableTranscript(nextTranscript)) { showToast(TOAST_MESSAGES.invalidTranscript); return; } - history.replace("transcript", (prev) => ({ - ...prev, - transcript, - })); + setTranscript(nextTranscript); }} // 송금하러 가기 전에 캡처를 올려 둔다 — 업로드가 실패하면 송금 전에 알 수 있다. // 송금 링크는 남은 학기 수마다 금액이 달라서 이때 같이 받는다 - onNext={(transcript) => { + onNext={(file) => { setPreparingToss(true); Promise.all([ - uploadTranscript(transcript), + uploadTranscript(file), queryClient.fetchQuery( feeQueries.transferGuide(context.semester), ), @@ -221,7 +213,7 @@ function FeeTransferFunnel() { .then(([proofImageKey, { transferLinkUrl }]) => history.push( "toss", - { proofImageKey, transcript, transferLinkUrl }, + { proofImageKey, transferLinkUrl }, SLIDE, ), ) diff --git a/src/features/fee/constants/feeTransferFunnel.ts b/src/features/fee/constants/feeTransferFunnel.ts index f19d7955..77d381e2 100644 --- a/src/features/fee/constants/feeTransferFunnel.ts +++ b/src/features/fee/constants/feeTransferFunnel.ts @@ -3,23 +3,23 @@ // Figma: 남은 학기 수(nodeId 3562:162833) / 학점이수현황 업로드(3562:162845) / // 토스 이동 안내(3562:163025) / 송금 확인(3562:162973) / 확인요청 완료(3562:163013). // -// 캡처 이미지는 `File`을 context에 그대로 담는다 — `File`은 구조화 복제가 되는 값이라 -// history.state에 실려서, 토스 단계에서 뒤로 돌아와도 고른 파일이 남는다(별도 저장소 불필요). -// history.state는 브라우저마다 크기 상한이 있지만(Firefox 16MiB) 서버 업로드 상한(10MB)보다 커서 넘지 않는다. +// context는 history.state에 실려서 문자열·숫자만 담는다. 캡처 이미지(`File`)는 담지 않는다 — +// Chromium(Android WebView 포함)은 `File`이 든 history.state를 뒤로가기로 되살리지 못하고 state를 +// 통째로 null로 돌려줘서, 토스 단계에서 뒤로 가면 퍼널이 첫 단계로 초기화됐다. 고른 파일은 퍼널 화면 +// (FeeTransferScreen)이 메모리에 들고 있는다. // // 캡처 단계에서 "다음"을 누르면 캡처를 서버에 올리고 토스 송금 링크를 받아 함께 싣는다. // - `proofImageKey`: 올린 캡처의 파일 키 — 송금 확인에서 납부 요청에 넘긴다 // - `transferLinkUrl`: 서버가 링크를 주지 않으면(지금 낼 수 없는 상태) null이고, 토스 단계가 이동 실패 화면을 띄운다 export type FeeTransferSteps = { semester: { semester?: number }; - transcript: { semester: number; transcript?: File }; + transcript: { semester: number }; toss: { semester: number; - transcript: File; proofImageKey: string; transferLinkUrl: string | null; }; - confirm: { semester: number; transcript: File; proofImageKey: string }; + confirm: { semester: number; proofImageKey: string }; complete: Record; }; @@ -39,8 +39,7 @@ function isTransferLinkUrl(value: unknown): value is string | null { } // 새로고침이나 주소 직접 입력처럼 앞 단계의 값이 없는 채로 중간 단계에 들어오면 guard가 걸러서 -// 첫 단계(남은 학기 수)로 돌려보낸다 — 학기 수나 캡처 없이 토스 이동 화면이 열리는 일이 없다. -// `File`은 history.state에서 되살아나지 못하는 경우가 있어 `instanceof`로 확인한다. +// 첫 단계(남은 학기 수)로 돌려보낸다 — 학기 수나 올린 캡처 없이 토스 이동 화면이 열리는 일이 없다. export const FEE_TRANSFER_STEP_OPTIONS = { complete: { guard: (data: unknown): data is FeeTransferSteps["complete"] => @@ -50,7 +49,6 @@ export const FEE_TRANSFER_STEP_OPTIONS = { guard: (data: unknown): data is FeeTransferSteps["confirm"] => isRecord(data) && isSemester(data.semester) && - data.transcript instanceof File && typeof data.proofImageKey === "string", }, semester: { @@ -62,14 +60,11 @@ export const FEE_TRANSFER_STEP_OPTIONS = { guard: (data: unknown): data is FeeTransferSteps["toss"] => isRecord(data) && isSemester(data.semester) && - data.transcript instanceof File && typeof data.proofImageKey === "string" && isTransferLinkUrl(data.transferLinkUrl), }, transcript: { guard: (data: unknown): data is FeeTransferSteps["transcript"] => - isRecord(data) && - isSemester(data.semester) && - (data.transcript === undefined || data.transcript instanceof File), + isRecord(data) && isSemester(data.semester), }, }; diff --git a/src/features/fee/steps/FeeTransferTranscriptStep.tsx b/src/features/fee/steps/FeeTransferTranscriptStep.tsx index 408f348c..d296ef4f 100644 --- a/src/features/fee/steps/FeeTransferTranscriptStep.tsx +++ b/src/features/fee/steps/FeeTransferTranscriptStep.tsx @@ -12,7 +12,7 @@ import FeeUploadedFileCard from "@/features/fee/components/FeeUploadedFileCard"; // K-스마트멘토 이동 확인 모달 (3562:162940) / 업로드한 이미지 미리보기 (3595:70574) // // 네 프레임이 한 단계의 상태 변화라 퍼널 단계를 나누지 않고 업로드 여부·모달 열림으로 토글한다. -// 고른 파일은 퍼널 context에 담아 두고(미리보기는 objectURL), "다음"을 누르면 퍼널이 서버에 올린다. +// 고른 파일은 퍼널(FeeTransferScreen)이 메모리에 들고 있고(미리보기는 objectURL), "다음"을 누르면 퍼널이 서버에 올린다. // K-스마트멘토를 여는 일은 퍼널(FeeTransferScreen)이 맡고 여기는 `onOpenGuide`만 부른다. function FeeTransferTranscriptStep({ isNextPending, @@ -24,12 +24,12 @@ function FeeTransferTranscriptStep({ }: { /** "다음"을 누른 뒤 토스 단계로 넘어갈 준비(캡처 업로드·송금 링크 받기)를 하는 중인지 */ isNextPending: boolean; - /** 고른 캡처 — 퍼널 context가 들고 있는 값이 유일한 출처다 */ + /** 고른 캡처 — 퍼널이 들고 있는 값이 유일한 출처다 */ transcript: File | undefined; onBack: () => void; /** - * 고르거나 지울 때마다 퍼널 context를 갈아 끼운다 — 로컬 state에 같은 값을 또 두지 않는다. - * `history.replace`가 새 context로 다시 그려 주므로 이 화면은 context만 읽으면 된다. + * 고르거나 지울 때마다 퍼널에 알린다 — 로컬 state에 같은 값을 또 두지 않는다. + * 퍼널이 바뀐 파일로 다시 그려 주므로 이 화면은 `transcript`만 읽으면 된다. */ onChangeTranscript: (transcript: File | undefined) => void; onNext: (transcript: File) => void; From b31f1623095b1f80fab8f7c84b81d60caff0be92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:04:54 +0900 Subject: [PATCH 11/22] =?UTF-8?q?docs:=20=EC=86=A1=EA=B8=88=20=ED=8D=BC?= =?UTF-8?q?=EB=84=90=20=EC=BA=A1=EC=B2=98=20=ED=8C=8C=EC=9D=BC=20=EB=B3=B4?= =?UTF-8?q?=EA=B4=80=20=EB=B0=A9=EC=8B=9D=EC=9D=84=20=EC=BB=A8=EB=B2=A4?= =?UTF-8?q?=EC=85=98=EC=97=90=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/conventions/wds-component-usage.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/conventions/wds-component-usage.md b/docs/conventions/wds-component-usage.md index ae84cf73..4930db18 100644 --- a/docs/conventions/wds-component-usage.md +++ b/docs/conventions/wds-component-usage.md @@ -825,9 +825,9 @@ Figma는 같은 알림 설정 화면을 **설정 섹션(`3524:152367`)과 알림 남은 학기 수 → 학점이수현황 캡처 → 토스 이동 → 송금 확인 → 확인요청 완료를 `@use-funnel/react-router-dom`의 `useFunnel`로 묶었다(라우트 `/fee/transfer`, 단계는 `?fee.step=`). 로그인·가입 퍼널(`AuthSignupScreen`)과 같은 구성이다 — 퍼널 파일(`FeeTransferScreen.tsx`)은 단계 전환만 맡고, 단계 화면은 `features/fee/steps/`에 파일 하나씩(`FeeTransferSemesterStep`, `FeeTransferTranscriptStep`, `FeeTransferTossStep`, `FeeTransferConfirmStep`, `FeeTransferCompleteStep`) 두며 전환은 콜백 prop으로 받는다. 단계·context 타입과 `guard`는 `constants/feeTransferFunnel.ts`에 있고, 단계 화면이 공유하는 뼈대는 `components/FeeTransferStepLayout.tsx`다(뒤로가기는 `onBack` prop으로 받아서 첫 단계는 플로우 밖으로, 나머지는 이전 단계로 간다). -처음에는 단계마다 라우트를 따로 뒀는데(`/fee/transfer/semester` 등 5개), 그러면 단계 사이에 값을 navigate state로 넘겨야 해서 남은 학기 수가 캡처 단계에서 읽히지 않고 고른 파일도 다음 단계로 넘어가지 않았다. 퍼널 context로 바꾸면서 두 값이 플로우 끝까지 이어지고, 토스 단계에서 뒤로 돌아와도 입력이 남는다. 학기 수 입력·파일 선택은 단계를 바꾸지 않고 `history.replace`로 context만 갈아 끼운다(히스토리가 늘지 않음). 앞 단계 값 없이 중간 단계로 들어오면(새로고침·주소 직접 입력) `guard`가 걸러서 첫 단계로 돌려보낸다. +처음에는 단계마다 라우트를 따로 뒀는데(`/fee/transfer/semester` 등 5개), 그러면 단계 사이에 값을 navigate state로 넘겨야 해서 남은 학기 수가 캡처 단계에서 읽히지 않고 고른 파일도 다음 단계로 넘어가지 않았다. 퍼널 context로 바꾸면서 두 값이 플로우 끝까지 이어지고, 토스 단계에서 뒤로 돌아와도 입력이 남는다. 학기 수 입력은 단계를 바꾸지 않고 `history.replace`로 context만 갈아 끼운다(히스토리가 늘지 않음). 앞 단계 값 없이 중간 단계로 들어오면(새로고침·주소 직접 입력) `guard`가 걸러서 첫 단계로 돌려보낸다. -캡처 이미지는 `File`을 context에 그대로 담는다 — `File`은 구조화 복제가 되는 값이라 history.state에 실린다(브라우저 상한 Firefox 16MiB가 서버 업로드 상한 10MB보다 크다). 캡처 단계에서 "다음"을 누르면 퍼널이 캡처를 올리고(`useUploadFileMutation`) 송금 링크를 받아(`feeQueries.transferGuide`) 파일 키(`proofImageKey`)와 링크(`transferLinkUrl`)를 다음 단계 context에 함께 싣는다 — 업로드 실패를 송금하기 **전에** 알리기 위해서다. 토스에서 돌아와 같은 파일로 다시 누르면 또 올리지 않는다. `File`이 history.state에서 되살아나지 못하는 경우도 있어 `guard`가 `instanceof File`로 확인하고, 아니면 첫 단계로 돌려보낸다. +**캡처 이미지(`File`)는 context에 담지 않는다.** context는 history.state에 실리는데, Chromium(Android WebView 포함)은 `File`이 든 history.state를 뒤로가기로 되살리지 못하고 state를 통째로 `null`로 돌려준다 — 처음엔 `File`을 context에 담았다가 토스 단계에서 뒤로 가면 퍼널이 첫 단계로 초기화되는 걸 확인하고 바꿨다. 고른 파일은 퍼널 화면(`FeeTransferScreen`)의 state가 들고 있고(단계를 오가도 화면은 그대로라 남는다), context에는 문자열·숫자만 싣는다. 새로고침하면 캡처만 다시 고르면 된다. 캡처 단계에서 "다음"을 누르면 퍼널이 캡처를 올리고(`useUploadFileMutation`) 송금 링크를 받아(`feeQueries.transferGuide`) 파일 키(`proofImageKey`)와 링크(`transferLinkUrl`)를 다음 단계 context에 싣는다 — 업로드 실패를 송금하기 **전에** 알리기 위해서다. 토스에서 돌아와 같은 파일로 다시 누르면 또 올리지 않는다. 퍼널은 들어올 때 내 납부 상태(`feeQueries.me`)를 한 번만 보고, 미납·확인필요가 아니면 납부내역으로 보낸다. 들어올 때의 값만 보는 이유는 납부 요청을 보내면 상태가 납부확인중으로 바뀌는데, 그때 완료 단계에서 쫓겨나면 안 되기 때문이다. 단계를 넘기지 못한 실패(업로드·송금 링크·납부 요청)는 퍼널이 토스트 하나로 알린다 — 송금 확인 단계의 실패는 이미 송금한 뒤라 "다시 송금하라"로 읽히지 않게 요청 실패로만 안내한다. From 8ce942d281f85ad94dbc5c4c94e401e1fc86952f Mon Sep 17 00:00:00 2001 From: sangrae Date: Tue, 3 Nov 2026 22:35:45 +0900 Subject: [PATCH 12/22] =?UTF-8?q?refactor:=20API=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EB=A5=BC=20shared/api=EB=A1=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/queryClient.ts | 2 +- .../api/types.ts => shared/api/api.types.ts} | 0 src/{lib => shared}/api/client.ts | 6 +++--- src/{lib => shared}/api/errors.ts | 2 +- src/shared/api/index.ts | 20 +++++++++++++++++++ src/{lib => shared}/api/token.ts | 0 6 files changed, 25 insertions(+), 5 deletions(-) rename src/{lib/api/types.ts => shared/api/api.types.ts} (100%) rename src/{lib => shared}/api/client.ts (93%) rename src/{lib => shared}/api/errors.ts (95%) create mode 100644 src/shared/api/index.ts rename src/{lib => shared}/api/token.ts (100%) diff --git a/src/lib/queryClient.ts b/src/lib/queryClient.ts index 1ebf381b..a91a8760 100644 --- a/src/lib/queryClient.ts +++ b/src/lib/queryClient.ts @@ -1,6 +1,6 @@ import { QueryClient } from "@tanstack/react-query"; -import { isApiError } from "@/lib/api/errors"; +import { isApiError } from "@/shared/api"; // 서버 데이터 캐시. 화면은 entities의 queryOptions를 useSuspenseQuery로 읽고, 같은 key의 응답을 여기서 나눠 쓴다. export const queryClient = new QueryClient({ diff --git a/src/lib/api/types.ts b/src/shared/api/api.types.ts similarity index 100% rename from src/lib/api/types.ts rename to src/shared/api/api.types.ts diff --git a/src/lib/api/client.ts b/src/shared/api/client.ts similarity index 93% rename from src/lib/api/client.ts rename to src/shared/api/client.ts index 21959ccc..b003c266 100644 --- a/src/lib/api/client.ts +++ b/src/shared/api/client.ts @@ -1,8 +1,8 @@ import axios, { type AxiosRequestConfig, type AxiosResponse } from "axios"; -import { ApiError, NetworkError, TimeoutError } from "@/lib/api/errors"; -import { clearAccessToken, getAccessToken } from "@/lib/api/token"; -import type { ApiResponse } from "@/lib/api/types"; +import type { ApiResponse } from "@/shared/api/api.types"; +import { ApiError, NetworkError, TimeoutError } from "@/shared/api/errors"; +import { clearAccessToken, getAccessToken } from "@/shared/api/token"; // 서버 주소는 로컬·배포 모두 환경변수로 넣는다(.env.example). 비어 있으면 `/v1/...`가 웹 주소로 나가 // 웹의 index.html이 돌아오고, 아래 unwrap이 에러로 만든다. diff --git a/src/lib/api/errors.ts b/src/shared/api/errors.ts similarity index 95% rename from src/lib/api/errors.ts rename to src/shared/api/errors.ts index 3a902551..47e8291d 100644 --- a/src/lib/api/errors.ts +++ b/src/shared/api/errors.ts @@ -1,4 +1,4 @@ -import type { ApiResponse } from "@/lib/api/types"; +import type { ApiResponse } from "@/shared/api/api.types"; // 서버까지 닿지 못한 요청(오프라인·DNS·CORS 차단 등) export class NetworkError extends Error { diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts new file mode 100644 index 00000000..5c820ed6 --- /dev/null +++ b/src/shared/api/index.ts @@ -0,0 +1,20 @@ +// 요청 코드 바깥(entities·queryClient)은 이 파일로만 가져온다 — 안쪽 파일 구성이 바뀌어도 import가 그대로다. +export type { + ApiResponse, + CursorSliceResponse, + PageResponse, +} from "@/shared/api/api.types"; +export { api } from "@/shared/api/client"; +export { + ApiError, + isApiError, + isUnauthorizedError, + NetworkError, + TimeoutError, +} from "@/shared/api/errors"; +export { + clearAccessToken, + getAccessToken, + setAccessToken, + subscribeTokens, +} from "@/shared/api/token"; diff --git a/src/lib/api/token.ts b/src/shared/api/token.ts similarity index 100% rename from src/lib/api/token.ts rename to src/shared/api/token.ts From b338d3fcd5c0532f3ffde6adf628da2fb524d437 Mon Sep 17 00:00:00 2001 From: sangrae Date: Tue, 3 Nov 2026 22:39:20 +0900 Subject: [PATCH 13/22] =?UTF-8?q?refactor:=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EC=BD=94=EB=93=9C=EB=A5=BC=20shared/en?= =?UTF-8?q?tities=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/search/types.ts | 14 -------------- .../archives/components/ArchivesDetailContent.tsx | 2 +- src/features/archives/components/ArchivesList.tsx | 4 ++-- .../archives/components/ArchivesPhotoColumn.tsx | 2 +- .../components/ArchivesPhotoViewerContent.tsx | 2 +- .../archives/components/ArchivesPhotosContent.tsx | 2 +- src/features/auth/AuthEntryGate.tsx | 2 +- src/features/auth/AuthSignupScreen.tsx | 4 ++-- src/features/auth/KConnectCallbackScreen.tsx | 4 ++-- src/features/auth/steps/AuthLoginStep.tsx | 10 +++++----- src/features/bililge/BililgeListScreen.tsx | 2 +- .../bililge/components/BililgeItemList.tsx | 4 ++-- .../bililge/components/BililgeRentalSheet.tsx | 2 +- src/features/events/EventsApplicationScreen.tsx | 2 +- .../events/components/EventsApplicationCard.tsx | 2 +- .../components/EventsApplicationDetailContent.tsx | 2 +- .../events/components/EventsApplicationList.tsx | 2 +- .../events/components/EventsApplicationSummary.tsx | 2 +- src/features/events/components/EventsCard.tsx | 2 +- .../events/components/EventsDetailContent.tsx | 4 ++-- src/features/events/components/EventsList.tsx | 2 +- .../events/components/EventsQuestionField.tsx | 10 +++++----- .../events/components/EventsStatusBadge.tsx | 2 +- src/features/events/constants/events.ts | 2 +- src/features/events/constants/eventsApplication.ts | 2 +- .../events/hooks/useEventsApplicationCancel.ts | 2 +- src/features/fee/components/FeePaymentCard.tsx | 2 +- src/features/fee/components/FeePaymentList.tsx | 2 +- src/features/fee/constants/fee.ts | 2 +- src/features/feedbacks/FeedbacksDetailScreen.tsx | 2 +- src/features/feedbacks/FeedbacksListScreen.tsx | 2 +- .../feedbacks/components/FeedbacksHistoryCard.tsx | 2 +- .../components/FeedbacksMineDetailContent.tsx | 2 +- .../feedbacks/components/FeedbacksMineList.tsx | 2 +- src/features/feedbacks/constants/feedbacks.ts | 5 ++++- src/features/home/constants/homeMock.ts | 4 ++-- .../components/LockersAssignmentContent.tsx | 2 +- .../lockers/components/LockersMineContent.tsx | 2 +- src/features/lockers/constants/lockers.ts | 2 +- src/features/notices/NoticesListScreen.tsx | 2 +- .../notices/components/NoticesDetailContent.tsx | 4 ++-- src/features/notices/components/NoticesList.tsx | 4 ++-- .../notifications/components/NotificationsItem.tsx | 2 +- .../notifications/components/NotificationsList.tsx | 2 +- .../notifications/constants/notifications.ts | 2 +- .../search/components/SearchCategoryItems.tsx | 2 +- .../search/components/SearchResultContent.tsx | 2 +- .../search/components/SearchResultView.tsx | 2 +- src/features/search/constants/search.ts | 2 +- .../settings/components/SettingsAccountRows.tsx | 2 +- .../settings/components/SettingsAppVersionRow.tsx | 2 +- src/features/settings/hooks/useAccountExit.ts | 2 +- src/{ => shared}/entities/archives/archivesApi.ts | 5 ++++- src/{ => shared}/entities/archives/archivesMock.ts | 5 ++++- src/{ => shared}/entities/archives/types.ts | 0 src/{ => shared}/entities/auth/authApi.ts | 7 +++++-- src/{ => shared}/entities/auth/authMock.ts | 2 +- src/{ => shared}/entities/auth/session.ts | 0 src/{ => shared}/entities/auth/types.ts | 0 src/{ => shared}/entities/bililge/bililgeApi.ts | 2 +- src/{ => shared}/entities/bililge/bililgeMock.ts | 2 +- src/{ => shared}/entities/bililge/types.ts | 0 src/{ => shared}/entities/events/eventsApi.ts | 6 +++--- src/{ => shared}/entities/events/eventsMock.ts | 2 +- src/{ => shared}/entities/events/types.ts | 0 src/{ => shared}/entities/fee/feeApi.ts | 5 ++++- src/{ => shared}/entities/fee/feeMock.ts | 2 +- src/{ => shared}/entities/fee/types.ts | 0 .../entities/feedbacks/feedbacksApi.ts | 2 +- .../entities/feedbacks/feedbacksMock.ts | 2 +- src/{ => shared}/entities/feedbacks/types.ts | 0 src/{ => shared}/entities/lockers/lockersApi.ts | 2 +- src/{ => shared}/entities/lockers/lockersMock.ts | 2 +- src/{ => shared}/entities/lockers/types.ts | 0 src/{ => shared}/entities/notices/noticesApi.ts | 2 +- src/{ => shared}/entities/notices/noticesMock.ts | 2 +- src/{ => shared}/entities/notices/types.ts | 0 .../entities/notifications/notificationsApi.ts | 2 +- .../entities/notifications/notificationsMock.ts | 2 +- src/{ => shared}/entities/notifications/types.ts | 0 src/{ => shared}/entities/search/searchApi.ts | 12 ++++++------ src/shared/entities/search/types.ts | 14 ++++++++++++++ src/{ => shared}/entities/users/types.ts | 0 src/{ => shared}/entities/users/usersApi.ts | 2 +- src/{ => shared}/entities/users/usersMock.ts | 2 +- 85 files changed, 125 insertions(+), 110 deletions(-) delete mode 100644 src/entities/search/types.ts rename src/{ => shared}/entities/archives/archivesApi.ts (90%) rename src/{ => shared}/entities/archives/archivesMock.ts (96%) rename src/{ => shared}/entities/archives/types.ts (100%) rename src/{ => shared}/entities/auth/authApi.ts (91%) rename src/{ => shared}/entities/auth/authMock.ts (97%) rename src/{ => shared}/entities/auth/session.ts (100%) rename src/{ => shared}/entities/auth/types.ts (100%) rename src/{ => shared}/entities/bililge/bililgeApi.ts (88%) rename src/{ => shared}/entities/bililge/bililgeMock.ts (98%) rename src/{ => shared}/entities/bililge/types.ts (100%) rename src/{ => shared}/entities/events/eventsApi.ts (96%) rename src/{ => shared}/entities/events/eventsMock.ts (99%) rename src/{ => shared}/entities/events/types.ts (100%) rename src/{ => shared}/entities/fee/feeApi.ts (90%) rename src/{ => shared}/entities/fee/feeMock.ts (95%) rename src/{ => shared}/entities/fee/types.ts (100%) rename src/{ => shared}/entities/feedbacks/feedbacksApi.ts (92%) rename src/{ => shared}/entities/feedbacks/feedbacksMock.ts (98%) rename src/{ => shared}/entities/feedbacks/types.ts (100%) rename src/{ => shared}/entities/lockers/lockersApi.ts (87%) rename src/{ => shared}/entities/lockers/lockersMock.ts (90%) rename src/{ => shared}/entities/lockers/types.ts (100%) rename src/{ => shared}/entities/notices/noticesApi.ts (93%) rename src/{ => shared}/entities/notices/noticesMock.ts (97%) rename src/{ => shared}/entities/notices/types.ts (100%) rename src/{ => shared}/entities/notifications/notificationsApi.ts (86%) rename src/{ => shared}/entities/notifications/notificationsMock.ts (94%) rename src/{ => shared}/entities/notifications/types.ts (100%) rename src/{ => shared}/entities/search/searchApi.ts (81%) create mode 100644 src/shared/entities/search/types.ts rename src/{ => shared}/entities/users/types.ts (100%) rename src/{ => shared}/entities/users/usersApi.ts (93%) rename src/{ => shared}/entities/users/usersMock.ts (80%) diff --git a/src/entities/search/types.ts b/src/entities/search/types.ts deleted file mode 100644 index 9d37c5b2..00000000 --- a/src/entities/search/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -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"; - -// 검색 결과는 카테고리별 배열이다 — 키는 용어 사전(terminology.md)의 코드 용어를 그대로 쓴다. -export interface SearchResults { - events: EventItem[]; - notices: Notice[]; - feedbacks: Feedback[]; - bililge: BililgeItem[]; - archives: ArchiveItem[]; -} diff --git a/src/features/archives/components/ArchivesDetailContent.tsx b/src/features/archives/components/ArchivesDetailContent.tsx index d2ac3cee..c831aba8 100644 --- a/src/features/archives/components/ArchivesDetailContent.tsx +++ b/src/features/archives/components/ArchivesDetailContent.tsx @@ -4,9 +4,9 @@ import { IconChevronLeft, IconShare } from "@wanteddev/wds-icon"; import ScreenBottomSafeArea from "@/components/ui/ScreenBottomSafeArea"; import ScreenHeader from "@/components/ui/ScreenHeader"; -import { archivesQueries } from "@/entities/archives/archivesApi"; import ArchivesDetailSection from "@/features/archives/components/ArchivesDetailSection"; import ArchivesPhotoGrid from "@/features/archives/components/ArchivesPhotoGrid"; +import { archivesQueries } from "@/shared/entities/archives/archivesApi"; interface ArchivesDetailContentProps { archiveId: string; diff --git a/src/features/archives/components/ArchivesList.tsx b/src/features/archives/components/ArchivesList.tsx index 81572794..c5ee755c 100644 --- a/src/features/archives/components/ArchivesList.tsx +++ b/src/features/archives/components/ArchivesList.tsx @@ -1,10 +1,10 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { Typography } from "@wanteddev/wds"; -import { archivesQueries } from "@/entities/archives/archivesApi"; -import type { ArchiveItem } from "@/entities/archives/types"; import type { ArchivesPhotoCardSize } from "@/features/archives/components/ArchivesPhotoCard"; import ArchivesPhotoColumn from "@/features/archives/components/ArchivesPhotoColumn"; +import { archivesQueries } from "@/shared/entities/archives/archivesApi"; +import type { ArchiveItem } from "@/shared/entities/archives/types"; // 2열 매스너리 — 왼쪽 열은 Large/Medium, 오른쪽 열은 Small/Large를 번갈아 써서 두 열의 카드 높이가 엇갈린다. const LEFT_COLUMN_SIZES: ArchivesPhotoCardSize[] = ["large", "medium"]; diff --git a/src/features/archives/components/ArchivesPhotoColumn.tsx b/src/features/archives/components/ArchivesPhotoColumn.tsx index abe84034..2d4eb1ec 100644 --- a/src/features/archives/components/ArchivesPhotoColumn.tsx +++ b/src/features/archives/components/ArchivesPhotoColumn.tsx @@ -1,7 +1,7 @@ -import type { ArchiveItem } from "@/entities/archives/types"; import ArchivesPhotoCard, { type ArchivesPhotoCardSize, } from "@/features/archives/components/ArchivesPhotoCard"; +import type { ArchiveItem } from "@/shared/entities/archives/types"; interface ArchivesPhotoColumnProps { items: ArchiveItem[]; diff --git a/src/features/archives/components/ArchivesPhotoViewerContent.tsx b/src/features/archives/components/ArchivesPhotoViewerContent.tsx index d4fd6f7c..a75bf8fe 100644 --- a/src/features/archives/components/ArchivesPhotoViewerContent.tsx +++ b/src/features/archives/components/ArchivesPhotoViewerContent.tsx @@ -4,7 +4,7 @@ import { IconClose } from "@wanteddev/wds-icon"; import { useEffect, useRef, useState } from "react"; import ScreenHeader from "@/components/ui/ScreenHeader"; -import { archivesQueries } from "@/entities/archives/archivesApi"; +import { archivesQueries } from "@/shared/entities/archives/archivesApi"; interface ArchivesPhotoViewerContentProps { archiveId: string; diff --git a/src/features/archives/components/ArchivesPhotosContent.tsx b/src/features/archives/components/ArchivesPhotosContent.tsx index 7422a081..5d218959 100644 --- a/src/features/archives/components/ArchivesPhotosContent.tsx +++ b/src/features/archives/components/ArchivesPhotosContent.tsx @@ -2,7 +2,7 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { Typography } from "@wanteddev/wds"; import ScreenBottomSafeArea from "@/components/ui/ScreenBottomSafeArea"; -import { archivesQueries } from "@/entities/archives/archivesApi"; +import { archivesQueries } from "@/shared/entities/archives/archivesApi"; interface ArchivesPhotosContentProps { archiveId: string; diff --git a/src/features/auth/AuthEntryGate.tsx b/src/features/auth/AuthEntryGate.tsx index 57ba56b7..f8fd96f8 100644 --- a/src/features/auth/AuthEntryGate.tsx +++ b/src/features/auth/AuthEntryGate.tsx @@ -1,8 +1,8 @@ import type { ReactNode } from "react"; import { Navigate, useMatches } from "react-router-dom"; -import { isLoggedIn } from "@/entities/auth/session"; import { LOGIN_PATH } from "@/features/auth/constants/auth"; +import { isLoggedIn } from "@/shared/entities/auth/session"; // 로그인 없이 열 수 있는 라우트의 표시 — router.tsx에서 `handle: { isPublic: true } satisfies AuthRouteHandle`로 지정한다 // (레이아웃 옵션 handle과 한 객체에 같이 쓰면 `ScreenRouteHandle & AuthRouteHandle`로 검사한다). diff --git a/src/features/auth/AuthSignupScreen.tsx b/src/features/auth/AuthSignupScreen.tsx index acbf131f..1a4be621 100644 --- a/src/features/auth/AuthSignupScreen.tsx +++ b/src/features/auth/AuthSignupScreen.tsx @@ -1,8 +1,6 @@ import { useEffect, useMemo } from "react"; import { Navigate, useLocation, useNavigate } from "react-router-dom"; -import { submitOnboarding } from "@/entities/auth/authApi"; -import { isLoggedIn, markLoggedIn } from "@/entities/auth/session"; import { INITIAL_TERMS_AGREEMENTS } from "@/features/auth/constants/auth"; import { AUTH_SIGNUP_STEP_OPTIONS, @@ -16,6 +14,8 @@ import AuthTermsStep from "@/features/auth/steps/AuthTermsStep"; import { readAuthLocationState } from "@/features/auth/utils/authLocationState"; import { HOME_PATH } from "@/features/home/constants/home"; import { useFunnel } from "@/lib/useFunnel"; +import { submitOnboarding } from "@/shared/entities/auth/authApi"; +import { isLoggedIn, markLoggedIn } from "@/shared/entities/auth/session"; // 로그인과 가입(연락처 → 약관 동의 → 약관 상세 → 가입 완료)을 useFunnel 하나로 묶은 화면이다. 단계 화면은 `steps/`에 // 하나씩 나눠 두고, 이 파일은 단계 전환(어느 단계에서 어디로, 무슨 값을 넘기는지)만 맡는다. diff --git a/src/features/auth/KConnectCallbackScreen.tsx b/src/features/auth/KConnectCallbackScreen.tsx index af96398f..7260d804 100644 --- a/src/features/auth/KConnectCallbackScreen.tsx +++ b/src/features/auth/KConnectCallbackScreen.tsx @@ -2,14 +2,14 @@ import { useKConnectCallback } from "@billilge/oauth-kconnect/react"; import { useEffect, useRef } from "react"; import { useNavigate } from "react-router-dom"; -import { exchangeKConnectCode } from "@/entities/auth/authApi"; -import { markLoggedIn } from "@/entities/auth/session"; import { LOGIN_FAILURE_MESSAGE, LOGIN_PATH, } from "@/features/auth/constants/auth"; import type { AuthLocationState } from "@/features/auth/utils/authLocationState"; import { HOME_PATH } from "@/features/home/constants/home"; +import { exchangeKConnectCode } from "@/shared/entities/auth/authApi"; +import { markLoggedIn } from "@/shared/entities/auth/session"; // K-CONNECT가 인증을 마치고 되돌려 보내는 redirect URI 라우트(`/oauth/kconnect`) — 경로는 K-CONNECT에 등록한 // 값이자 @billilge/oauth-kconnect가 정한 값이라 바꾸지 않는다. 보여 줄 UI는 없고 결과를 받아 처리한 뒤 이동한다: diff --git a/src/features/auth/steps/AuthLoginStep.tsx b/src/features/auth/steps/AuthLoginStep.tsx index 962dccc9..439614ba 100644 --- a/src/features/auth/steps/AuthLoginStep.tsx +++ b/src/features/auth/steps/AuthLoginStep.tsx @@ -2,11 +2,6 @@ import { useKConnectLogin } from "@billilge/oauth-kconnect/react"; import { useEffect, useState } from "react"; import ScreenToast from "@/components/ui/ScreenToast"; -import { - isMockKConnectKind, - mockKConnectLogin, -} from "@/entities/auth/authMock"; -import type { KConnectUser } from "@/entities/auth/types"; import AuthBrandHeader from "@/features/auth/components/AuthBrandHeader"; import AuthKConnectButton from "@/features/auth/components/AuthKConnectButton"; import AuthLoginCarousel from "@/features/auth/components/AuthLoginCarousel"; @@ -15,6 +10,11 @@ import { LOGIN_FAILURE_MESSAGE, } from "@/features/auth/constants/auth"; import { isInAppShell } from "@/lib/bridge/bridge"; +import { + isMockKConnectKind, + mockKConnectLogin, +} from "@/shared/entities/auth/authMock"; +import type { KConnectUser } from "@/shared/entities/auth/types"; // 로그인 — 화면설계서 2·3번. `K-CONNECT로 시작하기`를 누르면 K-CONNECT 인증 사이트로 페이지가 이동하고(@billilge/oauth-kconnect), // 인증을 마치면 `/oauth/kconnect`(KConnectCallbackScreen)가 결과를 받아 신규 사용자는 연락처 단계로, 기존 사용자는 홈으로 보낸다. diff --git a/src/features/bililge/BililgeListScreen.tsx b/src/features/bililge/BililgeListScreen.tsx index b0b09cc3..4057620f 100644 --- a/src/features/bililge/BililgeListScreen.tsx +++ b/src/features/bililge/BililgeListScreen.tsx @@ -9,7 +9,6 @@ import { useNavigate } from "react-router-dom"; import ScreenHeader from "@/components/ui/ScreenHeader"; import { useScreenHeader } from "@/components/ui/useScreenHeader"; -import type { BililgeItem } from "@/entities/bililge/types"; import BililgeCategoryFilter from "@/features/bililge/components/BililgeCategoryFilter"; import BililgeItemList from "@/features/bililge/components/BililgeItemList"; import BililgeListSkeleton from "@/features/bililge/components/BililgeListSkeleton"; @@ -21,6 +20,7 @@ import { } from "@/features/bililge/constants/bililge"; import { NOTIFICATIONS_PATH } from "@/features/notifications/constants/notifications"; import { getSearchPath } from "@/features/search/constants/search"; +import type { BililgeItem } from "@/shared/entities/bililge/types"; interface BililgeListScreenProps { /** 대여/반납 탭 — 탭마다 경로가 달라서 라우터가 정해 넘긴다 */ diff --git a/src/features/bililge/components/BililgeItemList.tsx b/src/features/bililge/components/BililgeItemList.tsx index 6c74f53e..5d26f1f2 100644 --- a/src/features/bililge/components/BililgeItemList.tsx +++ b/src/features/bililge/components/BililgeItemList.tsx @@ -1,8 +1,8 @@ import { useSuspenseQuery } from "@tanstack/react-query"; -import { bililgeQueries } from "@/entities/bililge/bililgeApi"; -import type { BililgeItem } from "@/entities/bililge/types"; import BililgeItemCard from "@/features/bililge/components/BililgeItemCard"; +import { bililgeQueries } from "@/shared/entities/bililge/bililgeApi"; +import type { BililgeItem } from "@/shared/entities/bililge/types"; interface BililgeItemListProps { // "전체"는 필터 없음, 그 외는 BililgeCategory 값(BililgeCategoryFilter 칩 값) diff --git a/src/features/bililge/components/BililgeRentalSheet.tsx b/src/features/bililge/components/BililgeRentalSheet.tsx index f862ff50..d4634d15 100644 --- a/src/features/bililge/components/BililgeRentalSheet.tsx +++ b/src/features/bililge/components/BililgeRentalSheet.tsx @@ -10,9 +10,9 @@ import { useEffect, useState } from "react"; import BottomSheet from "@/components/ui/BottomSheet"; import ScreenToast from "@/components/ui/ScreenToast"; -import type { BililgeItem } from "@/entities/bililge/types"; import BililgeItemCard from "@/features/bililge/components/BililgeItemCard"; import BililgeRentalConfirmModal from "@/features/bililge/components/BililgeRentalConfirmModal"; +import type { BililgeItem } from "@/shared/entities/bililge/types"; interface BililgeRentalSheetProps { item: BililgeItem | null; diff --git a/src/features/events/EventsApplicationScreen.tsx b/src/features/events/EventsApplicationScreen.tsx index 6f36b45e..aa8151f0 100644 --- a/src/features/events/EventsApplicationScreen.tsx +++ b/src/features/events/EventsApplicationScreen.tsx @@ -11,7 +11,6 @@ import ScreenHeader from "@/components/ui/ScreenHeader"; import ScreenToast from "@/components/ui/ScreenToast"; import SubmittingOverlay from "@/components/ui/SubmittingOverlay"; import { useScreenHeader } from "@/components/ui/useScreenHeader"; -import type { EventsAnswer } from "@/entities/events/types"; import EventsQuestionField from "@/features/events/components/EventsQuestionField"; import EventsSummaryCard from "@/features/events/components/EventsSummaryCard"; import { @@ -20,6 +19,7 @@ import { } from "@/features/events/constants/eventsApplication"; import { submitEventsApplication } from "@/features/events/constants/eventsApplicationSubmit"; import { useBackNavigation } from "@/hooks/useBackNavigation"; +import type { EventsAnswer } from "@/shared/entities/events/types"; // 복수 선택은 하나라도 골라야 하고, 기타를 골랐으면 입력 내용까지 있어야 충족으로 본다 function isAnswered(answer: EventsAnswer | undefined): boolean { diff --git a/src/features/events/components/EventsApplicationCard.tsx b/src/features/events/components/EventsApplicationCard.tsx index 0dbd7082..a4579cb9 100644 --- a/src/features/events/components/EventsApplicationCard.tsx +++ b/src/features/events/components/EventsApplicationCard.tsx @@ -1,8 +1,8 @@ import { Button, ContentBadge, Typography } from "@wanteddev/wds"; -import type { MyEventApplication } from "@/entities/events/types"; import { EVENTS_APPLICATION_STATUS_BADGES } from "@/features/events/constants/events"; import { formatDateTime } from "@/lib/date"; +import type { MyEventApplication } from "@/shared/entities/events/types"; interface EventsApplicationCardProps { application: MyEventApplication; diff --git a/src/features/events/components/EventsApplicationDetailContent.tsx b/src/features/events/components/EventsApplicationDetailContent.tsx index 9085f4e1..531c7d9c 100644 --- a/src/features/events/components/EventsApplicationDetailContent.tsx +++ b/src/features/events/components/EventsApplicationDetailContent.tsx @@ -3,9 +3,9 @@ import { Button } from "@wanteddev/wds"; import { Navigate } from "react-router-dom"; import { NEGATIVE_BUTTON_SX } from "@/components/ui/negativeButtonSx"; -import { eventsQueries } from "@/entities/events/eventsApi"; import EventsApplicationSummary from "@/features/events/components/EventsApplicationSummary"; import EventsQuestionField from "@/features/events/components/EventsQuestionField"; +import { eventsQueries } from "@/shared/entities/events/eventsApi"; interface EventsApplicationDetailContentProps { applicationId: string; diff --git a/src/features/events/components/EventsApplicationList.tsx b/src/features/events/components/EventsApplicationList.tsx index 1f8dd288..60e2078b 100644 --- a/src/features/events/components/EventsApplicationList.tsx +++ b/src/features/events/components/EventsApplicationList.tsx @@ -3,8 +3,8 @@ import { Divider } from "@wanteddev/wds"; import { Fragment } from "react"; import emptyEventsIllustration from "@/assets/icons/common/empty-events.svg"; import EmptyState from "@/components/ui/EmptyState"; -import { eventsQueries } from "@/entities/events/eventsApi"; import EventsApplicationCard from "@/features/events/components/EventsApplicationCard"; +import { eventsQueries } from "@/shared/entities/events/eventsApi"; interface EventsApplicationListProps { onSelectEvent: (eventId: string) => void; diff --git a/src/features/events/components/EventsApplicationSummary.tsx b/src/features/events/components/EventsApplicationSummary.tsx index dccfd56f..bd3c0bbf 100644 --- a/src/features/events/components/EventsApplicationSummary.tsx +++ b/src/features/events/components/EventsApplicationSummary.tsx @@ -1,7 +1,7 @@ import StatusSummaryCard from "@/components/ui/StatusSummaryCard"; -import type { MyEventApplication } from "@/entities/events/types"; import { EVENTS_APPLICATION_STATUS_BADGES } from "@/features/events/constants/events"; import { formatDateTime } from "@/lib/date"; +import type { MyEventApplication } from "@/shared/entities/events/types"; // Figma: 신청내역 상세 Event Summary (nodeId 1133:46188), 신청취소 상세 (1133:46223). // 신청내역에서 "행사·신청·(취소)" 행을 만들어 공용 StatusSummaryCard에 넘긴다 — 취소 행은 취소된 신청에만 있다. diff --git a/src/features/events/components/EventsCard.tsx b/src/features/events/components/EventsCard.tsx index 6ed0cd57..b1cf9c49 100644 --- a/src/features/events/components/EventsCard.tsx +++ b/src/features/events/components/EventsCard.tsx @@ -1,6 +1,6 @@ import { Button, Typography } from "@wanteddev/wds"; -import type { EventStatus } from "@/entities/events/types"; import EventsStatusBadge from "@/features/events/components/EventsStatusBadge"; +import type { EventStatus } from "@/shared/entities/events/types"; interface EventsCardProps { title: string; diff --git a/src/features/events/components/EventsDetailContent.tsx b/src/features/events/components/EventsDetailContent.tsx index 207d55e7..ef8b0494 100644 --- a/src/features/events/components/EventsDetailContent.tsx +++ b/src/features/events/components/EventsDetailContent.tsx @@ -11,13 +11,13 @@ import { IconChevronLeft } from "@wanteddev/wds-icon"; import emptyEventsIllustration from "@/assets/icons/common/empty-events.svg"; import EmptyState from "@/components/ui/EmptyState"; import PhotoGallery from "@/components/ui/PhotoGallery"; -import { eventsQueries } from "@/entities/events/eventsApi"; -import { feeQueries } from "@/entities/fee/feeApi"; import EventsStatusBadge from "@/features/events/components/EventsStatusBadge"; import { EVENTS_FEE_PAID_ONLY_LABEL, getEventAudience, } from "@/features/events/constants/events"; +import { eventsQueries } from "@/shared/entities/events/eventsApi"; +import { feeQueries } from "@/shared/entities/fee/feeApi"; interface EventsDetailContentProps { eventId: string; diff --git a/src/features/events/components/EventsList.tsx b/src/features/events/components/EventsList.tsx index 9d3cc6b6..50bc2860 100644 --- a/src/features/events/components/EventsList.tsx +++ b/src/features/events/components/EventsList.tsx @@ -3,8 +3,8 @@ import { Divider } from "@wanteddev/wds"; import { Fragment } from "react"; import emptyEventsIllustration from "@/assets/icons/common/empty-events.svg"; import EmptyState from "@/components/ui/EmptyState"; -import { eventsQueries } from "@/entities/events/eventsApi"; import EventsCard from "@/features/events/components/EventsCard"; +import { eventsQueries } from "@/shared/entities/events/eventsApi"; // Figma: 행사 모집중 empty (nodeId 1165:62713)는 "모집중" 필터 버전만 준다 — // 일러스트·레이아웃·"아카이빙 둘러보기" 버튼이 이 조합의 스펙이다. diff --git a/src/features/events/components/EventsQuestionField.tsx b/src/features/events/components/EventsQuestionField.tsx index 3b1a8f87..60990277 100644 --- a/src/features/events/components/EventsQuestionField.tsx +++ b/src/features/events/components/EventsQuestionField.tsx @@ -12,16 +12,16 @@ import { } from "@wanteddev/wds"; import { useId } from "react"; -import type { - EventsAnswer, - EventsChoiceAnswer, - EventsQuestion, -} from "@/entities/events/types"; import { EVENTS_OTHER_MAX_LENGTH, EVENTS_OTHER_OPTION_LABEL, EVENTS_TEXT_MAX_LENGTH, } from "@/features/events/constants/eventsApplication"; +import type { + EventsAnswer, + EventsChoiceAnswer, + EventsQuestion, +} from "@/shared/entities/events/types"; interface EventsQuestionFieldBaseProps { question: EventsQuestion; diff --git a/src/features/events/components/EventsStatusBadge.tsx b/src/features/events/components/EventsStatusBadge.tsx index c30cf9e1..618043e2 100644 --- a/src/features/events/components/EventsStatusBadge.tsx +++ b/src/features/events/components/EventsStatusBadge.tsx @@ -1,6 +1,6 @@ import { ContentBadge } from "@wanteddev/wds"; -import type { EventStatus } from "@/entities/events/types"; +import type { EventStatus } from "@/shared/entities/events/types"; interface EventsStatusBadgeProps { status: EventStatus; diff --git a/src/features/events/constants/events.ts b/src/features/events/constants/events.ts index 534555be..c0f1ccac 100644 --- a/src/features/events/constants/events.ts +++ b/src/features/events/constants/events.ts @@ -1,4 +1,4 @@ -import type { EventItem } from "@/entities/events/types"; +import type { EventItem } from "@/shared/entities/events/types"; // 모집 상태 필터. value는 EventStatus + "all"이고, 라벨은 Figma Filter Chips(1243:70861) 그대로. export const EVENT_STATUS_FILTERS = [ diff --git a/src/features/events/constants/eventsApplication.ts b/src/features/events/constants/eventsApplication.ts index 3b3b7aa8..d250e6ad 100644 --- a/src/features/events/constants/eventsApplication.ts +++ b/src/features/events/constants/eventsApplication.ts @@ -2,7 +2,7 @@ import filePencil from "@/assets/icons/events/file-pencil.svg"; import type { EventsQuestion, EventsTextQuestion, -} from "@/entities/events/types"; +} from "@/shared/entities/events/types"; export interface EventsApplication { eventName: string; diff --git a/src/features/events/hooks/useEventsApplicationCancel.ts b/src/features/events/hooks/useEventsApplicationCancel.ts index 0e71d5fb..2118841e 100644 --- a/src/features/events/hooks/useEventsApplicationCancel.ts +++ b/src/features/events/hooks/useEventsApplicationCancel.ts @@ -1,6 +1,6 @@ import { useState } from "react"; -import { useCancelEventApplicationMutation } from "@/entities/events/eventsApi"; +import { useCancelEventApplicationMutation } from "@/shared/entities/events/eventsApi"; // 취소 결과 토스트 — 문구와 색을 결과마다 한 번에 고른다 const CANCEL_TOASTS = { diff --git a/src/features/fee/components/FeePaymentCard.tsx b/src/features/fee/components/FeePaymentCard.tsx index c0482d75..3c6b95df 100644 --- a/src/features/fee/components/FeePaymentCard.tsx +++ b/src/features/fee/components/FeePaymentCard.tsx @@ -1,9 +1,9 @@ import { Button, Typography } from "@wanteddev/wds"; import StatusSummaryCard from "@/components/ui/StatusSummaryCard"; -import type { FeePayment } from "@/entities/fee/types"; import { FEE_STATUS_BADGES } from "@/features/fee/constants/fee"; import { formatDateTime } from "@/lib/date"; +import type { FeePayment } from "@/shared/entities/fee/types"; interface FeePaymentCardProps { payment: FeePayment; diff --git a/src/features/fee/components/FeePaymentList.tsx b/src/features/fee/components/FeePaymentList.tsx index dc99e960..28de050e 100644 --- a/src/features/fee/components/FeePaymentList.tsx +++ b/src/features/fee/components/FeePaymentList.tsx @@ -1,7 +1,7 @@ import { useSuspenseQuery } from "@tanstack/react-query"; -import { feeQueries } from "@/entities/fee/feeApi"; import FeePaymentCard from "@/features/fee/components/FeePaymentCard"; +import { feeQueries } from "@/shared/entities/fee/feeApi"; interface FeePaymentListProps { /** 없으면 문의하기 버튼을 그리지 않는다(채널 주소가 아직 없을 때) */ diff --git a/src/features/fee/constants/fee.ts b/src/features/fee/constants/fee.ts index dded961c..e7ab1d25 100644 --- a/src/features/fee/constants/fee.ts +++ b/src/features/fee/constants/fee.ts @@ -1,4 +1,4 @@ -import type { FeePaymentStatus } from "@/entities/fee/types"; +import type { FeePaymentStatus } from "@/shared/entities/fee/types"; // 학생회비 계좌 송금 플로우 경로. 화면설계서(687:20584)에는 경로 표기 컬럼이 아예 없어서 // 용어 사전(terminology.md)의 `회비 납부 → fee`와 "프론트 라우트 경로: /{코드 용어}" 규칙으로 정했다. diff --git a/src/features/feedbacks/FeedbacksDetailScreen.tsx b/src/features/feedbacks/FeedbacksDetailScreen.tsx index e64b429e..89228dfe 100644 --- a/src/features/feedbacks/FeedbacksDetailScreen.tsx +++ b/src/features/feedbacks/FeedbacksDetailScreen.tsx @@ -12,8 +12,8 @@ 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 "@/entities/feedbacks/feedbacksMock"; import FeedbacksAnswerText from "@/features/feedbacks/components/FeedbacksAnswerText"; +import { FEEDBACKS } from "@/shared/entities/feedbacks/feedbacksMock"; // Figma: 열린피드백 상세페이지 (nodeId 1410:49988) // 제목·진행 바는 같은 회차(round)의 답변된 피드백들을 한데 묶어 보여준다는 뜻에서 diff --git a/src/features/feedbacks/FeedbacksListScreen.tsx b/src/features/feedbacks/FeedbacksListScreen.tsx index a5467c26..4c9f4a0c 100644 --- a/src/features/feedbacks/FeedbacksListScreen.tsx +++ b/src/features/feedbacks/FeedbacksListScreen.tsx @@ -17,13 +17,13 @@ import { Link, useLocation, useNavigate } from "react-router-dom"; 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 FeedbacksFilter from "@/features/feedbacks/components/FeedbacksFilter"; import FeedbacksQaCard from "@/features/feedbacks/components/FeedbacksQaCard"; import { FEEDBACK_FILTER_ALL } from "@/features/feedbacks/constants/feedbacks"; import { NOTIFICATIONS_PATH } from "@/features/notifications/constants/notifications"; import { getSearchPath } from "@/features/search/constants/search"; +import { FEEDBACKS } from "@/shared/entities/feedbacks/feedbacksMock"; // 카드 한 장이 차지하는 가로 길이(카드끼리 gap이 없어서 카드 폭 그 자체다). 카드가 캐러셀 // 폭에 꽉 차는 크기라 뷰포트마다 실제 폭이 다르기 때문에 상수로 박아두면 한쪽에서 점이 diff --git a/src/features/feedbacks/components/FeedbacksHistoryCard.tsx b/src/features/feedbacks/components/FeedbacksHistoryCard.tsx index 0ea3993e..39ed60b5 100644 --- a/src/features/feedbacks/components/FeedbacksHistoryCard.tsx +++ b/src/features/feedbacks/components/FeedbacksHistoryCard.tsx @@ -1,9 +1,9 @@ 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"; +import type { MyFeedback } from "@/shared/entities/feedbacks/types"; interface FeedbacksHistoryCardProps { feedback: MyFeedback; diff --git a/src/features/feedbacks/components/FeedbacksMineDetailContent.tsx b/src/features/feedbacks/components/FeedbacksMineDetailContent.tsx index ee66ee39..dfe50e66 100644 --- a/src/features/feedbacks/components/FeedbacksMineDetailContent.tsx +++ b/src/features/feedbacks/components/FeedbacksMineDetailContent.tsx @@ -6,9 +6,9 @@ import answerIcon from "@/assets/icons/feedbacks/answer.svg"; import emptyFeedbackIllustration from "@/assets/icons/feedbacks/empty-feedback.svg"; import questionIcon from "@/assets/icons/feedbacks/question.svg"; import EmptyState from "@/components/ui/EmptyState"; -import { feedbacksQueries } from "@/entities/feedbacks/feedbacksApi"; import FeedbacksAnswerText from "@/features/feedbacks/components/FeedbacksAnswerText"; import { hasFeedbackAnswer } from "@/features/feedbacks/constants/feedbacks"; +import { feedbacksQueries } from "@/shared/entities/feedbacks/feedbacksApi"; interface FeedbacksMineDetailContentProps { feedbackId: string; diff --git a/src/features/feedbacks/components/FeedbacksMineList.tsx b/src/features/feedbacks/components/FeedbacksMineList.tsx index 28d28f35..c3593ea1 100644 --- a/src/features/feedbacks/components/FeedbacksMineList.tsx +++ b/src/features/feedbacks/components/FeedbacksMineList.tsx @@ -1,8 +1,8 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import emptyFeedbackIllustration from "@/assets/icons/feedbacks/empty-feedback.svg"; import EmptyState from "@/components/ui/EmptyState"; -import { feedbacksQueries } from "@/entities/feedbacks/feedbacksApi"; import FeedbacksHistoryCard from "@/features/feedbacks/components/FeedbacksHistoryCard"; +import { feedbacksQueries } from "@/shared/entities/feedbacks/feedbacksApi"; interface FeedbacksMineListProps { onSelect: (feedbackId: string) => void; diff --git a/src/features/feedbacks/constants/feedbacks.ts b/src/features/feedbacks/constants/feedbacks.ts index c0aff74b..17b0f0b7 100644 --- a/src/features/feedbacks/constants/feedbacks.ts +++ b/src/features/feedbacks/constants/feedbacks.ts @@ -1,4 +1,7 @@ -import type { Feedback, FeedbackRound } from "@/entities/feedbacks/types"; +import type { + Feedback, + FeedbackRound, +} from "@/shared/entities/feedbacks/types"; // "전체 피드백" 필터는 연도와 차수를 같이 건다. 이 값이면 그 조건으로는 거르지 않는다. export const FEEDBACK_FILTER_ALL = "all"; diff --git a/src/features/home/constants/homeMock.ts b/src/features/home/constants/homeMock.ts index 1e47c45c..a50b1fc4 100644 --- a/src/features/home/constants/homeMock.ts +++ b/src/features/home/constants/homeMock.ts @@ -1,8 +1,8 @@ import hairDryer from "@/assets/icons/bililge-items/hair-dryer.svg"; import powerBank from "@/assets/icons/bililge-items/power-bank.svg"; import umbrella from "@/assets/icons/bililge-items/umbrella.svg"; -import type { FeePaymentStatus } from "@/entities/fee/types"; -import type { LockerAssignmentStatus } from "@/entities/lockers/types"; +import type { FeePaymentStatus } from "@/shared/entities/fee/types"; +import type { LockerAssignmentStatus } from "@/shared/entities/lockers/types"; // 홈 API가 붙기 전까지 쓰는 목데이터다. export const HOME_HAS_UNREAD_NOTIFICATION = true; diff --git a/src/features/lockers/components/LockersAssignmentContent.tsx b/src/features/lockers/components/LockersAssignmentContent.tsx index 1122ea76..567928bc 100644 --- a/src/features/lockers/components/LockersAssignmentContent.tsx +++ b/src/features/lockers/components/LockersAssignmentContent.tsx @@ -5,9 +5,9 @@ import { IconChevronRight } from "@wanteddev/wds-icon"; import emptyEventsIllustration from "@/assets/icons/common/empty-events.svg"; import EmptyState from "@/components/ui/EmptyState"; import StatusSummaryCard from "@/components/ui/StatusSummaryCard"; -import { lockersQueries } from "@/entities/lockers/lockersApi"; import { LOCKER_STATUS_BADGES } from "@/features/lockers/constants/lockers"; import { formatDate, formatDateTime } from "@/lib/date"; +import { lockersQueries } from "@/shared/entities/lockers/lockersApi"; // 이 화면에 오는 배정은 항상 배정완료다(기간종료·미신청은 홈 배지에서만 쓴다) const ASSIGNED_BADGE = LOCKER_STATUS_BADGES.assigned; diff --git a/src/features/lockers/components/LockersMineContent.tsx b/src/features/lockers/components/LockersMineContent.tsx index 05a7fa92..f01b97ec 100644 --- a/src/features/lockers/components/LockersMineContent.tsx +++ b/src/features/lockers/components/LockersMineContent.tsx @@ -2,13 +2,13 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { useMemo } from "react"; import { Navigate } from "react-router-dom"; -import { lockersQueries } 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"; +import { lockersQueries } from "@/shared/entities/lockers/lockersApi"; interface LockersMineContentProps { /** 배정이 없거나 구역 배치가 없으면 보낼 곳 — 이동할 경로는 화면이 정한다 */ diff --git a/src/features/lockers/constants/lockers.ts b/src/features/lockers/constants/lockers.ts index 352fa563..005b063e 100644 --- a/src/features/lockers/constants/lockers.ts +++ b/src/features/lockers/constants/lockers.ts @@ -1,4 +1,4 @@ -import type { LockerAssignmentStatus } from "@/entities/lockers/types"; +import type { LockerAssignmentStatus } from "@/shared/entities/lockers/types"; // 사물함 배정 상태 배지 — 사물함 배정 상태 화면과 홈 내 정보 목록(HomeInfoList)이 같이 쓴다. // color가 없으면 회색(neutral) 배지다. diff --git a/src/features/notices/NoticesListScreen.tsx b/src/features/notices/NoticesListScreen.tsx index 214bb464..c06d8905 100644 --- a/src/features/notices/NoticesListScreen.tsx +++ b/src/features/notices/NoticesListScreen.tsx @@ -5,11 +5,11 @@ import { useNavigate } from "react-router-dom"; import ScreenHeader from "@/components/ui/ScreenHeader"; import { useScreenHeader } from "@/components/ui/useScreenHeader"; -import type { NoticeCategory } from "@/entities/notices/types"; import NoticesList from "@/features/notices/components/NoticesList"; import NoticesListSkeleton from "@/features/notices/components/NoticesListSkeleton"; import { NOTIFICATIONS_PATH } from "@/features/notifications/constants/notifications"; import { getSearchPath } from "@/features/search/constants/search"; +import type { NoticeCategory } from "@/shared/entities/notices/types"; type NoticeTab = "all" | "general" | "partnership"; diff --git a/src/features/notices/components/NoticesDetailContent.tsx b/src/features/notices/components/NoticesDetailContent.tsx index 3c18afb3..2517c866 100644 --- a/src/features/notices/components/NoticesDetailContent.tsx +++ b/src/features/notices/components/NoticesDetailContent.tsx @@ -10,8 +10,8 @@ import { IconChevronLeft } from "@wanteddev/wds-icon"; import PhotoGallery from "@/components/ui/PhotoGallery"; import ScreenHeader from "@/components/ui/ScreenHeader"; import { useScreenHeader } from "@/components/ui/useScreenHeader"; -import { noticesQueries } from "@/entities/notices/noticesApi"; -import type { NoticeCategory } from "@/entities/notices/types"; +import { noticesQueries } from "@/shared/entities/notices/noticesApi"; +import type { NoticeCategory } from "@/shared/entities/notices/types"; const CATEGORY_BADGE_COLOR: Record = { 일반: "semantic.accent.foreground.blue", diff --git a/src/features/notices/components/NoticesList.tsx b/src/features/notices/components/NoticesList.tsx index 6099a4c5..74063624 100644 --- a/src/features/notices/components/NoticesList.tsx +++ b/src/features/notices/components/NoticesList.tsx @@ -3,9 +3,9 @@ import { Divider } from "@wanteddev/wds"; import { Fragment } from "react"; import { Link } from "react-router-dom"; -import { noticesQueries } from "@/entities/notices/noticesApi"; -import type { NoticeCategory } from "@/entities/notices/types"; import NoticesCard from "@/features/notices/components/NoticesCard"; +import { noticesQueries } from "@/shared/entities/notices/noticesApi"; +import type { NoticeCategory } from "@/shared/entities/notices/types"; interface NoticesListProps { category: NoticeCategory | "all"; diff --git a/src/features/notifications/components/NotificationsItem.tsx b/src/features/notifications/components/NotificationsItem.tsx index a2ee52a3..47b326c2 100644 --- a/src/features/notifications/components/NotificationsItem.tsx +++ b/src/features/notifications/components/NotificationsItem.tsx @@ -1,7 +1,7 @@ import { Typography } from "@wanteddev/wds"; -import type { NotificationType } from "@/entities/notifications/types"; import { NOTIFICATION_TYPE_META } from "@/features/notifications/constants/notifications"; +import type { NotificationType } from "@/shared/entities/notifications/types"; interface NotificationsItemProps { type: NotificationType; diff --git a/src/features/notifications/components/NotificationsList.tsx b/src/features/notifications/components/NotificationsList.tsx index 28220a54..18668d7e 100644 --- a/src/features/notifications/components/NotificationsList.tsx +++ b/src/features/notifications/components/NotificationsList.tsx @@ -2,8 +2,8 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { useState } from "react"; import emptyNotificationsIllustration from "@/assets/icons/notifications/empty-notifications.svg"; import EmptyState from "@/components/ui/EmptyState"; -import { notificationsQueries } from "@/entities/notifications/notificationsApi"; import NotificationsItem from "@/features/notifications/components/NotificationsItem"; +import { notificationsQueries } from "@/shared/entities/notifications/notificationsApi"; // 알림 목록 데이터를 useSuspenseQuery로 읽는 데이터 컴포넌트 — 응답을 기다리는 동안은 바깥 Suspense가 스켈레톤을 보여준다. // 읽음 처리는 서버 API가 없어서 이 화면 state로만 들고 있다(새로고침하면 목데이터 값으로 돌아간다). diff --git a/src/features/notifications/constants/notifications.ts b/src/features/notifications/constants/notifications.ts index 6d4a683a..c74bbdcf 100644 --- a/src/features/notifications/constants/notifications.ts +++ b/src/features/notifications/constants/notifications.ts @@ -4,7 +4,7 @@ import eventIcon from "@/assets/icons/notifications/event.svg"; import feedbackIcon from "@/assets/icons/notifications/feedback.svg"; import lockerIcon from "@/assets/icons/notifications/locker.svg"; import noticeIcon from "@/assets/icons/notifications/notice.svg"; -import type { NotificationType } from "@/entities/notifications/types"; +import type { NotificationType } from "@/shared/entities/notifications/types"; // Figma Notification Category — 24px 아이콘 + 라벨. 빌릴게는 대여·반납이 같은 라벨에 아이콘만 다르다. export const NOTIFICATION_TYPE_META = { diff --git a/src/features/search/components/SearchCategoryItems.tsx b/src/features/search/components/SearchCategoryItems.tsx index 7e8cedd6..c91e5006 100644 --- a/src/features/search/components/SearchCategoryItems.tsx +++ b/src/features/search/components/SearchCategoryItems.tsx @@ -1,13 +1,13 @@ import { Divider } from "@wanteddev/wds"; import { Fragment, type ReactNode } from "react"; import { Link, useNavigate } from "react-router-dom"; -import type { SearchResults } from "@/entities/search/types"; import BililgeItemCard from "@/features/bililge/components/BililgeItemCard"; import EventsCard from "@/features/events/components/EventsCard"; import FeedbacksCard from "@/features/feedbacks/components/FeedbacksCard"; import NoticesCard from "@/features/notices/components/NoticesCard"; import SearchArchivingCard from "@/features/search/components/SearchArchivingCard"; import type { SearchCategory } from "@/features/search/constants/search"; +import type { SearchResults } from "@/shared/entities/search/types"; interface SearchCategoryItemsProps { category: SearchCategory; diff --git a/src/features/search/components/SearchResultContent.tsx b/src/features/search/components/SearchResultContent.tsx index bbce512c..5b7b28e3 100644 --- a/src/features/search/components/SearchResultContent.tsx +++ b/src/features/search/components/SearchResultContent.tsx @@ -1,12 +1,12 @@ import { useSuspenseQuery } from "@tanstack/react-query"; -import { searchQueries } from "@/entities/search/searchApi"; import SearchEmptyState from "@/features/search/components/SearchEmptyState"; import SearchResultView from "@/features/search/components/SearchResultView"; import { getSearchResultCount, type SearchTab, } from "@/features/search/constants/search"; +import { searchQueries } from "@/shared/entities/search/searchApi"; interface SearchResultContentProps { keyword: string; diff --git a/src/features/search/components/SearchResultView.tsx b/src/features/search/components/SearchResultView.tsx index 8cbc921b..40540e63 100644 --- a/src/features/search/components/SearchResultView.tsx +++ b/src/features/search/components/SearchResultView.tsx @@ -2,7 +2,6 @@ import { Button, Tab, TabList, TabListItem } from "@wanteddev/wds"; import { IconChevronRight } from "@wanteddev/wds-icon"; import { Fragment } from "react"; -import type { SearchResults } from "@/entities/search/types"; import SearchCategoryItems from "@/features/search/components/SearchCategoryItems"; import SearchEmptyState from "@/features/search/components/SearchEmptyState"; import SearchSectionHeader from "@/features/search/components/SearchSectionHeader"; @@ -16,6 +15,7 @@ import { SEARCH_TABS, type SearchTab, } from "@/features/search/constants/search"; +import type { SearchResults } from "@/shared/entities/search/types"; interface SearchResultViewProps { results: SearchResults; diff --git a/src/features/search/constants/search.ts b/src/features/search/constants/search.ts index cd8244a1..636a7dc9 100644 --- a/src/features/search/constants/search.ts +++ b/src/features/search/constants/search.ts @@ -1,4 +1,4 @@ -import type { SearchResults } from "@/entities/search/types"; +import type { SearchResults } from "@/shared/entities/search/types"; // 검색 결과 카테고리 — 키는 용어 사전(terminology.md)의 코드 용어를 그대로 쓴다. export type SearchCategory = diff --git a/src/features/settings/components/SettingsAccountRows.tsx b/src/features/settings/components/SettingsAccountRows.tsx index 759a2751..0e24e1b7 100644 --- a/src/features/settings/components/SettingsAccountRows.tsx +++ b/src/features/settings/components/SettingsAccountRows.tsx @@ -1,7 +1,7 @@ import { useSuspenseQuery } from "@tanstack/react-query"; -import { usersQueries } from "@/entities/users/usersApi"; import SettingsRow from "@/features/settings/components/SettingsRow"; +import { usersQueries } from "@/shared/entities/users/usersApi"; interface SettingsAccountRowsProps { onChangePhone: () => void; diff --git a/src/features/settings/components/SettingsAppVersionRow.tsx b/src/features/settings/components/SettingsAppVersionRow.tsx index 8ac07748..0ad354b8 100644 --- a/src/features/settings/components/SettingsAppVersionRow.tsx +++ b/src/features/settings/components/SettingsAppVersionRow.tsx @@ -1,7 +1,7 @@ import { useSuspenseQuery } from "@tanstack/react-query"; -import { usersQueries } from "@/entities/users/usersApi"; import SettingsRow from "@/features/settings/components/SettingsRow"; +import { usersQueries } from "@/shared/entities/users/usersApi"; // 앱 버전 행(Figma 3524:152356) — 서버는 버전과 최신 여부를 따로 주고, Figma 표기("26.0.1 · // 최신버전")는 화면이 조합한다. 최신이 아닐 때 문구는 디자인에 없어서 버전만 보여 준다. diff --git a/src/features/settings/hooks/useAccountExit.ts b/src/features/settings/hooks/useAccountExit.ts index 4f6eef7f..80674ddb 100644 --- a/src/features/settings/hooks/useAccountExit.ts +++ b/src/features/settings/hooks/useAccountExit.ts @@ -1,7 +1,7 @@ import { useNavigate } from "react-router-dom"; -import { clearLoggedIn } from "@/entities/auth/session"; import { ACCOUNT_EXIT_PATH } from "@/features/settings/constants/settings"; +import { clearLoggedIn } from "@/shared/entities/auth/session"; // 로그아웃 확정과 회원 탈퇴 완료는 "계정에서 나가기"라는 같은 동작이라 한곳에 둔다 — // 두 화면이 각자 navigate를 들고 있으면 세션 정리를 붙일 때 한쪽을 빠뜨리기 쉽다 diff --git a/src/entities/archives/archivesApi.ts b/src/shared/entities/archives/archivesApi.ts similarity index 90% rename from src/entities/archives/archivesApi.ts rename to src/shared/entities/archives/archivesApi.ts index cfad2c57..0c630d55 100644 --- a/src/entities/archives/archivesApi.ts +++ b/src/shared/entities/archives/archivesApi.ts @@ -1,7 +1,10 @@ import { queryOptions } from "@tanstack/react-query"; -import { ARCHIVES, findArchiveDetail } from "@/entities/archives/archivesMock"; import { mockupResponse } from "@/lib/mockupApi"; +import { + ARCHIVES, + findArchiveDetail, +} from "@/shared/entities/archives/archivesMock"; const archivesKeys = { root: () => ["archives"] as const, diff --git a/src/entities/archives/archivesMock.ts b/src/shared/entities/archives/archivesMock.ts similarity index 96% rename from src/entities/archives/archivesMock.ts rename to src/shared/entities/archives/archivesMock.ts index b2bbda09..2f05fede 100644 --- a/src/entities/archives/archivesMock.ts +++ b/src/shared/entities/archives/archivesMock.ts @@ -3,7 +3,10 @@ import freshmanOrientation from "@/assets/images/archives/freshman-orientation.j import haeoreumje from "@/assets/images/archives/haeoreumje.jpg"; import snackEvent from "@/assets/images/archives/snack-event.jpg"; import sportsDay from "@/assets/images/archives/sports-day.jpg"; -import type { ArchiveDetail, ArchiveItem } from "@/entities/archives/types"; +import type { + ArchiveDetail, + ArchiveItem, +} from "@/shared/entities/archives/types"; // Figma: 아카이빙 목록(nodeId 1276:95397) · 상세(nodeId 1526:171215) 목업 데이터. // 검색 결과(SearchArchivingCard)와 아카이빙 목록·상세가 같은 데이터를 본다 — 검색에서 누른 앨범이 diff --git a/src/entities/archives/types.ts b/src/shared/entities/archives/types.ts similarity index 100% rename from src/entities/archives/types.ts rename to src/shared/entities/archives/types.ts diff --git a/src/entities/auth/authApi.ts b/src/shared/entities/auth/authApi.ts similarity index 91% rename from src/entities/auth/authApi.ts rename to src/shared/entities/auth/authApi.ts index a4b9a1e3..ef83690a 100644 --- a/src/entities/auth/authApi.ts +++ b/src/shared/entities/auth/authApi.ts @@ -3,8 +3,11 @@ import { MOCK_NEW_USER, mockDelay, readMockLogin, -} from "@/entities/auth/authMock"; -import type { KConnectUser, OnboardingSubmission } from "@/entities/auth/types"; +} from "@/shared/entities/auth/authMock"; +import type { + KConnectUser, + OnboardingSubmission, +} from "@/shared/entities/auth/types"; // 서버로 보내는 K-CONNECT 인증 결과 — @billilge/oauth-kconnect가 돌려주는 값 중 서버가 쓰는 세 가지다. export interface KConnectCredentials { diff --git a/src/entities/auth/authMock.ts b/src/shared/entities/auth/authMock.ts similarity index 97% rename from src/entities/auth/authMock.ts rename to src/shared/entities/auth/authMock.ts index 6bef0e47..99ff99d0 100644 --- a/src/entities/auth/authMock.ts +++ b/src/shared/entities/auth/authMock.ts @@ -1,4 +1,4 @@ -import type { KConnectUser } from "@/entities/auth/types"; +import type { KConnectUser } from "@/shared/entities/auth/types"; // 실 API가 붙기 전까지 인증 흐름을 흉내 내는 목 전용 코드다. 실 API로 바꿀 때 이 파일째 지운다. diff --git a/src/entities/auth/session.ts b/src/shared/entities/auth/session.ts similarity index 100% rename from src/entities/auth/session.ts rename to src/shared/entities/auth/session.ts diff --git a/src/entities/auth/types.ts b/src/shared/entities/auth/types.ts similarity index 100% rename from src/entities/auth/types.ts rename to src/shared/entities/auth/types.ts diff --git a/src/entities/bililge/bililgeApi.ts b/src/shared/entities/bililge/bililgeApi.ts similarity index 88% rename from src/entities/bililge/bililgeApi.ts rename to src/shared/entities/bililge/bililgeApi.ts index 2f0c59a5..ba3bcf7c 100644 --- a/src/entities/bililge/bililgeApi.ts +++ b/src/shared/entities/bililge/bililgeApi.ts @@ -1,7 +1,7 @@ import { queryOptions } from "@tanstack/react-query"; -import { BILILGE_ITEMS } from "@/entities/bililge/bililgeMock"; import { mockupResponse } from "@/lib/mockupApi"; +import { BILILGE_ITEMS } from "@/shared/entities/bililge/bililgeMock"; const bililgeKeys = { root: () => ["bililge"] as const, diff --git a/src/entities/bililge/bililgeMock.ts b/src/shared/entities/bililge/bililgeMock.ts similarity index 98% rename from src/entities/bililge/bililgeMock.ts rename to src/shared/entities/bililge/bililgeMock.ts index a587567f..22776b0b 100644 --- a/src/entities/bililge/bililgeMock.ts +++ b/src/shared/entities/bililge/bililgeMock.ts @@ -13,7 +13,7 @@ import powerBank from "@/assets/icons/bililge-items/power-bank.svg"; import sanitaryPad from "@/assets/icons/bililge-items/sanitary-pad.svg"; import umbrella from "@/assets/icons/bililge-items/umbrella.svg"; import usbCCharger from "@/assets/icons/bililge-items/usb-c-charger.svg"; -import type { BililgeItem } from "@/entities/bililge/types"; +import type { BililgeItem } from "@/shared/entities/bililge/types"; // 목데이터는 실 서버가 없어 위 필드를 흉내내는 값이다 — 실행 시점 기준으로 오늘/내일/그 이후 // 라벨을 미리 계산해두되, 컴포넌트 쪽에는 이 계산 로직 자체를 노출하지 않는다(export하지 않음). diff --git a/src/entities/bililge/types.ts b/src/shared/entities/bililge/types.ts similarity index 100% rename from src/entities/bililge/types.ts rename to src/shared/entities/bililge/types.ts diff --git a/src/entities/events/eventsApi.ts b/src/shared/entities/events/eventsApi.ts similarity index 96% rename from src/entities/events/eventsApi.ts rename to src/shared/entities/events/eventsApi.ts index d7fd3279..b706938a 100644 --- a/src/entities/events/eventsApi.ts +++ b/src/shared/entities/events/eventsApi.ts @@ -4,13 +4,13 @@ import { useQueryClient, } from "@tanstack/react-query"; +import { mockupResponse } from "@/lib/mockupApi"; import { EVENTS, MY_EVENT_APPLICATIONS_MOCK, toMockDateTime, -} from "@/entities/events/eventsMock"; -import type { MyEventApplication } from "@/entities/events/types"; -import { mockupResponse } from "@/lib/mockupApi"; +} from "@/shared/entities/events/eventsMock"; +import type { MyEventApplication } from "@/shared/entities/events/types"; // 조회 캐시 key. 조회와 무효화가 같은 함수로 key를 만들어서, 한쪽만 바뀌어 무효화가 빗나가지 않게 한다. // 신청내역 상세는 목록 key 아래에 둬서, 목록 key 하나로 목록·상세를 같이 무효화한다. diff --git a/src/entities/events/eventsMock.ts b/src/shared/entities/events/eventsMock.ts similarity index 99% rename from src/entities/events/eventsMock.ts rename to src/shared/entities/events/eventsMock.ts index 2efb3ff6..766f182e 100644 --- a/src/entities/events/eventsMock.ts +++ b/src/shared/entities/events/eventsMock.ts @@ -2,7 +2,7 @@ import type { EventItem, EventsQuestion, MyEventApplication, -} from "@/entities/events/types"; +} from "@/shared/entities/events/types"; // Figma 상세(1133:42433 모집중 / 1156:53992 모집예정)의 메타데이터·본문은 목업이라 행사별로 다르지 않다. // 목록 카드가 같은 행사명 4개를 상태만 바꿔 보여주는 것과 같은 이유로, 상세 내용도 공통 상수로 두고 diff --git a/src/entities/events/types.ts b/src/shared/entities/events/types.ts similarity index 100% rename from src/entities/events/types.ts rename to src/shared/entities/events/types.ts diff --git a/src/entities/fee/feeApi.ts b/src/shared/entities/fee/feeApi.ts similarity index 90% rename from src/entities/fee/feeApi.ts rename to src/shared/entities/fee/feeApi.ts index 21c964c9..87a77ecb 100644 --- a/src/entities/fee/feeApi.ts +++ b/src/shared/entities/fee/feeApi.ts @@ -1,7 +1,10 @@ import { queryOptions } from "@tanstack/react-query"; -import { FEE_PAYMENTS_MOCK, MY_FEE_STATUS_MOCK } from "@/entities/fee/feeMock"; import { mockupResponse } from "@/lib/mockupApi"; +import { + FEE_PAYMENTS_MOCK, + MY_FEE_STATUS_MOCK, +} from "@/shared/entities/fee/feeMock"; const feeKeys = { root: () => ["fee"] as const, diff --git a/src/entities/fee/feeMock.ts b/src/shared/entities/fee/feeMock.ts similarity index 95% rename from src/entities/fee/feeMock.ts rename to src/shared/entities/fee/feeMock.ts index e2c824a1..efa869d2 100644 --- a/src/entities/fee/feeMock.ts +++ b/src/shared/entities/fee/feeMock.ts @@ -1,4 +1,4 @@ -import type { FeePayment, MyFeeStatus } from "@/entities/fee/types"; +import type { FeePayment, MyFeeStatus } from "@/shared/entities/fee/types"; const TITLE = "소프트웨어융합대학 학생회비"; diff --git a/src/entities/fee/types.ts b/src/shared/entities/fee/types.ts similarity index 100% rename from src/entities/fee/types.ts rename to src/shared/entities/fee/types.ts diff --git a/src/entities/feedbacks/feedbacksApi.ts b/src/shared/entities/feedbacks/feedbacksApi.ts similarity index 92% rename from src/entities/feedbacks/feedbacksApi.ts rename to src/shared/entities/feedbacks/feedbacksApi.ts index b17b7f5e..0b11dca3 100644 --- a/src/entities/feedbacks/feedbacksApi.ts +++ b/src/shared/entities/feedbacks/feedbacksApi.ts @@ -1,7 +1,7 @@ import { queryOptions } from "@tanstack/react-query"; -import { MY_FEEDBACKS_MOCK } from "@/entities/feedbacks/feedbacksMock"; import { mockupResponse } from "@/lib/mockupApi"; +import { MY_FEEDBACKS_MOCK } from "@/shared/entities/feedbacks/feedbacksMock"; const feedbacksKeys = { root: () => ["feedbacks"] as const, diff --git a/src/entities/feedbacks/feedbacksMock.ts b/src/shared/entities/feedbacks/feedbacksMock.ts similarity index 98% rename from src/entities/feedbacks/feedbacksMock.ts rename to src/shared/entities/feedbacks/feedbacksMock.ts index e01ac8cc..5ca5c306 100644 --- a/src/entities/feedbacks/feedbacksMock.ts +++ b/src/shared/entities/feedbacks/feedbacksMock.ts @@ -1,4 +1,4 @@ -import type { Feedback, MyFeedback } from "@/entities/feedbacks/types"; +import type { Feedback, MyFeedback } from "@/shared/entities/feedbacks/types"; // 문단은 빈 줄(\n\n)로 나누고, 굵게 강조할 구간은 **텍스트**로 표시한다 — 둘 다 Figma // 원본(nodeId 1410:50009)의 문단 구분·볼드 스팬을 그대로 옮긴 것이다. diff --git a/src/entities/feedbacks/types.ts b/src/shared/entities/feedbacks/types.ts similarity index 100% rename from src/entities/feedbacks/types.ts rename to src/shared/entities/feedbacks/types.ts diff --git a/src/entities/lockers/lockersApi.ts b/src/shared/entities/lockers/lockersApi.ts similarity index 87% rename from src/entities/lockers/lockersApi.ts rename to src/shared/entities/lockers/lockersApi.ts index 4c1483d7..ae2963b3 100644 --- a/src/entities/lockers/lockersApi.ts +++ b/src/shared/entities/lockers/lockersApi.ts @@ -1,7 +1,7 @@ import { queryOptions } from "@tanstack/react-query"; -import { LOCKER_ASSIGNMENT_MOCK } from "@/entities/lockers/lockersMock"; import { mockupResponse } from "@/lib/mockupApi"; +import { LOCKER_ASSIGNMENT_MOCK } from "@/shared/entities/lockers/lockersMock"; const lockersKeys = { root: () => ["lockers"] as const, diff --git a/src/entities/lockers/lockersMock.ts b/src/shared/entities/lockers/lockersMock.ts similarity index 90% rename from src/entities/lockers/lockersMock.ts rename to src/shared/entities/lockers/lockersMock.ts index bf7adc32..24a0dddd 100644 --- a/src/entities/lockers/lockersMock.ts +++ b/src/shared/entities/lockers/lockersMock.ts @@ -1,4 +1,4 @@ -import type { LockerAssignment } from "@/entities/lockers/types"; +import type { LockerAssignment } from "@/shared/entities/lockers/types"; // Figma: 사물함 배정 상태 배정완료 (nodeId 3147:148357), 빈 상태 (3147:148331) // Figma는 B-25지만 칸 배치 목데이터가 A-1·A-2만 있어서 위치 보기가 열리도록 A-1구역 칸으로 둔다. diff --git a/src/entities/lockers/types.ts b/src/shared/entities/lockers/types.ts similarity index 100% rename from src/entities/lockers/types.ts rename to src/shared/entities/lockers/types.ts diff --git a/src/entities/notices/noticesApi.ts b/src/shared/entities/notices/noticesApi.ts similarity index 93% rename from src/entities/notices/noticesApi.ts rename to src/shared/entities/notices/noticesApi.ts index d2870f37..196ff225 100644 --- a/src/entities/notices/noticesApi.ts +++ b/src/shared/entities/notices/noticesApi.ts @@ -1,7 +1,7 @@ import { queryOptions } from "@tanstack/react-query"; -import { NOTICES } from "@/entities/notices/noticesMock"; import { mockupResponse } from "@/lib/mockupApi"; +import { NOTICES } from "@/shared/entities/notices/noticesMock"; const noticesKeys = { root: () => ["notices"] as const, diff --git a/src/entities/notices/noticesMock.ts b/src/shared/entities/notices/noticesMock.ts similarity index 97% rename from src/entities/notices/noticesMock.ts rename to src/shared/entities/notices/noticesMock.ts index fa1ce563..a9d7b068 100644 --- a/src/entities/notices/noticesMock.ts +++ b/src/shared/entities/notices/noticesMock.ts @@ -1,4 +1,4 @@ -import type { Notice } from "@/entities/notices/types"; +import type { Notice } from "@/shared/entities/notices/types"; // Figma: 공지 상세 (nodeId 1256:81842) 목업 본문 — 목업 데이터라 공지 5건이 본문을 공유한다. const NOTICE_BODY = `안녕하십니까, 제10대 소프트웨어융합대학 학생회 '에코'입니다. diff --git a/src/entities/notices/types.ts b/src/shared/entities/notices/types.ts similarity index 100% rename from src/entities/notices/types.ts rename to src/shared/entities/notices/types.ts diff --git a/src/entities/notifications/notificationsApi.ts b/src/shared/entities/notifications/notificationsApi.ts similarity index 86% rename from src/entities/notifications/notificationsApi.ts rename to src/shared/entities/notifications/notificationsApi.ts index c947b598..7c49c1bd 100644 --- a/src/entities/notifications/notificationsApi.ts +++ b/src/shared/entities/notifications/notificationsApi.ts @@ -1,7 +1,7 @@ import { queryOptions } from "@tanstack/react-query"; -import { NOTIFICATIONS } from "@/entities/notifications/notificationsMock"; import { mockupResponse } from "@/lib/mockupApi"; +import { NOTIFICATIONS } from "@/shared/entities/notifications/notificationsMock"; const notificationsKeys = { root: () => ["notifications"] as const, diff --git a/src/entities/notifications/notificationsMock.ts b/src/shared/entities/notifications/notificationsMock.ts similarity index 94% rename from src/entities/notifications/notificationsMock.ts rename to src/shared/entities/notifications/notificationsMock.ts index 81fc5edf..ec4e20ff 100644 --- a/src/entities/notifications/notificationsMock.ts +++ b/src/shared/entities/notifications/notificationsMock.ts @@ -1,4 +1,4 @@ -import type { NotificationItem } from "@/entities/notifications/types"; +import type { NotificationItem } from "@/shared/entities/notifications/types"; // Figma: 알림 (nodeId 3595:107100) 목업 데이터 — 첫 항목만 안 읽은 알림이다. export const NOTIFICATIONS: NotificationItem[] = [ diff --git a/src/entities/notifications/types.ts b/src/shared/entities/notifications/types.ts similarity index 100% rename from src/entities/notifications/types.ts rename to src/shared/entities/notifications/types.ts diff --git a/src/entities/search/searchApi.ts b/src/shared/entities/search/searchApi.ts similarity index 81% rename from src/entities/search/searchApi.ts rename to src/shared/entities/search/searchApi.ts index eadb1130..8214cce6 100644 --- a/src/entities/search/searchApi.ts +++ b/src/shared/entities/search/searchApi.ts @@ -1,12 +1,12 @@ import { queryOptions } from "@tanstack/react-query"; -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 { mockupResponse } from "@/lib/mockupApi"; +import { ARCHIVES } from "@/shared/entities/archives/archivesMock"; +import { BILILGE_ITEMS } from "@/shared/entities/bililge/bililgeMock"; +import { EVENTS } from "@/shared/entities/events/eventsMock"; +import { FEEDBACKS } from "@/shared/entities/feedbacks/feedbacksMock"; +import { NOTICES } from "@/shared/entities/notices/noticesMock"; +import type { SearchResults } from "@/shared/entities/search/types"; function includesKeyword(text: string, keyword: string): boolean { return text.toLowerCase().includes(keyword); diff --git a/src/shared/entities/search/types.ts b/src/shared/entities/search/types.ts new file mode 100644 index 00000000..76706986 --- /dev/null +++ b/src/shared/entities/search/types.ts @@ -0,0 +1,14 @@ +import type { ArchiveItem } from "@/shared/entities/archives/types"; +import type { BililgeItem } from "@/shared/entities/bililge/types"; +import type { EventItem } from "@/shared/entities/events/types"; +import type { Feedback } from "@/shared/entities/feedbacks/types"; +import type { Notice } from "@/shared/entities/notices/types"; + +// 검색 결과는 카테고리별 배열이다 — 키는 용어 사전(terminology.md)의 코드 용어를 그대로 쓴다. +export interface SearchResults { + events: EventItem[]; + notices: Notice[]; + feedbacks: Feedback[]; + bililge: BililgeItem[]; + archives: ArchiveItem[]; +} diff --git a/src/entities/users/types.ts b/src/shared/entities/users/types.ts similarity index 100% rename from src/entities/users/types.ts rename to src/shared/entities/users/types.ts diff --git a/src/entities/users/usersApi.ts b/src/shared/entities/users/usersApi.ts similarity index 93% rename from src/entities/users/usersApi.ts rename to src/shared/entities/users/usersApi.ts index 0aa6d8a3..4d171a08 100644 --- a/src/entities/users/usersApi.ts +++ b/src/shared/entities/users/usersApi.ts @@ -1,7 +1,7 @@ import { queryOptions } from "@tanstack/react-query"; -import { APP_VERSION, MY_ACCOUNT } from "@/entities/users/usersMock"; import { mockupResponse } from "@/lib/mockupApi"; +import { APP_VERSION, MY_ACCOUNT } from "@/shared/entities/users/usersMock"; // 앱 버전은 계정과 다른 요청이라 key를 나눈다 — 설정 화면이 둘을 한 카드에 섞어 쓰지 않는다. const usersKeys = { diff --git a/src/entities/users/usersMock.ts b/src/shared/entities/users/usersMock.ts similarity index 80% rename from src/entities/users/usersMock.ts rename to src/shared/entities/users/usersMock.ts index 50a3a8cc..b573596e 100644 --- a/src/entities/users/usersMock.ts +++ b/src/shared/entities/users/usersMock.ts @@ -1,4 +1,4 @@ -import type { AppVersion, MyAccount } from "@/entities/users/types"; +import type { AppVersion, MyAccount } from "@/shared/entities/users/types"; // Figma 설정 화면(3524:152322)에 적힌 값 그대로. 실 API가 붙으면 이 파일은 사라진다. export const MY_ACCOUNT: MyAccount = { From 8adec023a54b8015832a78502dbbe43a2f413159 Mon Sep 17 00:00:00 2001 From: sangrae Date: Tue, 3 Nov 2026 22:54:26 +0900 Subject: [PATCH 14/22] =?UTF-8?q?refactor:=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=ED=8C=8C=EC=9D=BC=EC=9D=84=20keys?= =?UTF-8?q?=C2=B7queries=C2=B7types=C2=B7mock=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../archives/components/ArchivesDetailContent.tsx | 2 +- src/features/archives/components/ArchivesList.tsx | 4 ++-- .../archives/components/ArchivesPhotoColumn.tsx | 2 +- .../components/ArchivesPhotoViewerContent.tsx | 2 +- .../archives/components/ArchivesPhotosContent.tsx | 2 +- src/features/auth/AuthSignupScreen.tsx | 2 +- src/features/auth/KConnectCallbackScreen.tsx | 2 +- src/features/auth/steps/AuthLoginStep.tsx | 2 +- src/features/bililge/BililgeListScreen.tsx | 2 +- .../bililge/components/BililgeCategoryFilter.tsx | 2 +- .../bililge/components/BililgeItemList.tsx | 4 ++-- .../bililge/components/BililgeRentalSheet.tsx | 2 +- src/features/events/EventsApplicationScreen.tsx | 2 +- .../events/components/EventsApplicationCard.tsx | 2 +- .../components/EventsApplicationDetailContent.tsx | 2 +- .../events/components/EventsApplicationList.tsx | 2 +- .../components/EventsApplicationSummary.tsx | 2 +- src/features/events/components/EventsCard.tsx | 2 +- .../events/components/EventsDetailContent.tsx | 4 ++-- src/features/events/components/EventsList.tsx | 2 +- .../events/components/EventsQuestionField.tsx | 2 +- .../events/components/EventsStatusBadge.tsx | 2 +- src/features/events/constants/events.ts | 2 +- .../events/constants/eventsApplication.ts | 2 +- .../events/hooks/useEventsApplicationCancel.ts | 2 +- src/features/fee/components/FeePaymentCard.tsx | 2 +- src/features/fee/components/FeePaymentList.tsx | 2 +- src/features/fee/constants/fee.ts | 2 +- .../feedbacks/components/FeedbacksHistoryCard.tsx | 2 +- .../components/FeedbacksMineDetailContent.tsx | 2 +- .../feedbacks/components/FeedbacksMineList.tsx | 2 +- src/features/feedbacks/constants/feedbacks.ts | 2 +- src/features/home/constants/homeMock.ts | 4 ++-- .../components/LockersAssignmentContent.tsx | 2 +- .../lockers/components/LockersMineContent.tsx | 2 +- src/features/lockers/constants/lockers.ts | 2 +- src/features/notices/NoticesListScreen.tsx | 2 +- .../notices/components/NoticesDetailContent.tsx | 4 ++-- src/features/notices/components/NoticesList.tsx | 4 ++-- .../components/NotificationsItem.tsx | 2 +- .../components/NotificationsList.tsx | 2 +- .../notifications/constants/notifications.ts | 2 +- .../search/components/SearchCategoryItems.tsx | 2 +- .../search/components/SearchResultContent.tsx | 2 +- .../search/components/SearchResultView.tsx | 2 +- src/features/search/constants/search.ts | 2 +- .../settings/components/SettingsAccountRows.tsx | 2 +- .../settings/components/SettingsAppVersionRow.tsx | 2 +- src/shared/entities/archives/archivesKeys.ts | 6 ++++++ src/shared/entities/archives/archivesMock.ts | 2 +- .../{archivesApi.ts => archivesQueries.ts} | 8 +------- .../archives/{types.ts => archivesTypes.ts} | 0 src/shared/entities/auth/authMock.ts | 2 +- .../entities/auth/{authApi.ts => authQueries.ts} | 2 +- .../entities/auth/{types.ts => authTypes.ts} | 0 src/shared/entities/bililge/bililgeKeys.ts | 4 ++++ src/shared/entities/bililge/bililgeMock.ts | 2 +- .../bililge/{bililgeApi.ts => bililgeQueries.ts} | 6 +----- .../bililge/{types.ts => bililgeTypes.ts} | 0 src/shared/entities/events/eventsKeys.ts | 11 +++++++++++ src/shared/entities/events/eventsMock.ts | 4 ++-- .../events/{eventsApi.ts => eventsQueries.ts} | 15 ++------------- .../entities/events/{types.ts => eventsTypes.ts} | 0 src/shared/entities/fee/feeKeys.ts | 5 +++++ src/shared/entities/fee/feeMock.ts | 2 +- .../entities/fee/{feeApi.ts => feeQueries.ts} | 7 +------ src/shared/entities/fee/{types.ts => feeTypes.ts} | 0 src/shared/entities/feedbacks/feedbacksKeys.ts | 6 ++++++ src/shared/entities/feedbacks/feedbacksMock.ts | 5 ++++- .../{feedbacksApi.ts => feedbacksQueries.ts} | 8 +------- .../feedbacks/{types.ts => feedbacksTypes.ts} | 0 src/shared/entities/lockers/lockersKeys.ts | 4 ++++ src/shared/entities/lockers/lockersMock.ts | 2 +- .../lockers/{lockersApi.ts => lockersQueries.ts} | 6 +----- .../lockers/{types.ts => lockersTypes.ts} | 0 src/shared/entities/notices/noticesKeys.ts | 6 ++++++ src/shared/entities/notices/noticesMock.ts | 2 +- .../notices/{noticesApi.ts => noticesQueries.ts} | 8 +------- .../notices/{types.ts => noticesTypes.ts} | 0 .../entities/notifications/notificationsKeys.ts | 4 ++++ .../entities/notifications/notificationsMock.ts | 2 +- ...otificationsApi.ts => notificationsQueries.ts} | 6 +----- .../{types.ts => notificationsTypes.ts} | 0 src/shared/entities/search/searchKeys.ts | 4 ++++ .../search/{searchApi.ts => searchQueries.ts} | 8 ++------ src/shared/entities/search/searchTypes.ts | 14 ++++++++++++++ src/shared/entities/search/types.ts | 14 -------------- src/shared/entities/users/usersKeys.ts | 6 ++++++ src/shared/entities/users/usersMock.ts | 2 +- .../users/{usersApi.ts => usersQueries.ts} | 8 +------- .../entities/users/{types.ts => usersTypes.ts} | 0 91 files changed, 151 insertions(+), 148 deletions(-) create mode 100644 src/shared/entities/archives/archivesKeys.ts rename src/shared/entities/archives/{archivesApi.ts => archivesQueries.ts} (79%) rename src/shared/entities/archives/{types.ts => archivesTypes.ts} (100%) rename src/shared/entities/auth/{authApi.ts => authQueries.ts} (97%) rename src/shared/entities/auth/{types.ts => authTypes.ts} (100%) create mode 100644 src/shared/entities/bililge/bililgeKeys.ts rename src/shared/entities/bililge/{bililgeApi.ts => bililgeQueries.ts} (79%) rename src/shared/entities/bililge/{types.ts => bililgeTypes.ts} (100%) create mode 100644 src/shared/entities/events/eventsKeys.ts rename src/shared/entities/events/{eventsApi.ts => eventsQueries.ts} (83%) rename src/shared/entities/events/{types.ts => eventsTypes.ts} (100%) create mode 100644 src/shared/entities/fee/feeKeys.ts rename src/shared/entities/fee/{feeApi.ts => feeQueries.ts} (81%) rename src/shared/entities/fee/{types.ts => feeTypes.ts} (100%) create mode 100644 src/shared/entities/feedbacks/feedbacksKeys.ts rename src/shared/entities/feedbacks/{feedbacksApi.ts => feedbacksQueries.ts} (80%) rename src/shared/entities/feedbacks/{types.ts => feedbacksTypes.ts} (100%) create mode 100644 src/shared/entities/lockers/lockersKeys.ts rename src/shared/entities/lockers/{lockersApi.ts => lockersQueries.ts} (79%) rename src/shared/entities/lockers/{types.ts => lockersTypes.ts} (100%) create mode 100644 src/shared/entities/notices/noticesKeys.ts rename src/shared/entities/notices/{noticesApi.ts => noticesQueries.ts} (79%) rename src/shared/entities/notices/{types.ts => noticesTypes.ts} (100%) create mode 100644 src/shared/entities/notifications/notificationsKeys.ts rename src/shared/entities/notifications/{notificationsApi.ts => notificationsQueries.ts} (78%) rename src/shared/entities/notifications/{types.ts => notificationsTypes.ts} (100%) create mode 100644 src/shared/entities/search/searchKeys.ts rename src/shared/entities/search/{searchApi.ts => searchQueries.ts} (90%) create mode 100644 src/shared/entities/search/searchTypes.ts delete mode 100644 src/shared/entities/search/types.ts create mode 100644 src/shared/entities/users/usersKeys.ts rename src/shared/entities/users/{usersApi.ts => usersQueries.ts} (75%) rename src/shared/entities/users/{types.ts => usersTypes.ts} (100%) diff --git a/src/features/archives/components/ArchivesDetailContent.tsx b/src/features/archives/components/ArchivesDetailContent.tsx index c831aba8..5caef981 100644 --- a/src/features/archives/components/ArchivesDetailContent.tsx +++ b/src/features/archives/components/ArchivesDetailContent.tsx @@ -6,7 +6,7 @@ import ScreenBottomSafeArea from "@/components/ui/ScreenBottomSafeArea"; import ScreenHeader from "@/components/ui/ScreenHeader"; import ArchivesDetailSection from "@/features/archives/components/ArchivesDetailSection"; import ArchivesPhotoGrid from "@/features/archives/components/ArchivesPhotoGrid"; -import { archivesQueries } from "@/shared/entities/archives/archivesApi"; +import { archivesQueries } from "@/shared/entities/archives/archivesQueries"; interface ArchivesDetailContentProps { archiveId: string; diff --git a/src/features/archives/components/ArchivesList.tsx b/src/features/archives/components/ArchivesList.tsx index c5ee755c..21ed0c1b 100644 --- a/src/features/archives/components/ArchivesList.tsx +++ b/src/features/archives/components/ArchivesList.tsx @@ -3,8 +3,8 @@ import { Typography } from "@wanteddev/wds"; import type { ArchivesPhotoCardSize } from "@/features/archives/components/ArchivesPhotoCard"; import ArchivesPhotoColumn from "@/features/archives/components/ArchivesPhotoColumn"; -import { archivesQueries } from "@/shared/entities/archives/archivesApi"; -import type { ArchiveItem } from "@/shared/entities/archives/types"; +import { archivesQueries } from "@/shared/entities/archives/archivesQueries"; +import type { ArchiveItem } from "@/shared/entities/archives/archivesTypes"; // 2열 매스너리 — 왼쪽 열은 Large/Medium, 오른쪽 열은 Small/Large를 번갈아 써서 두 열의 카드 높이가 엇갈린다. const LEFT_COLUMN_SIZES: ArchivesPhotoCardSize[] = ["large", "medium"]; diff --git a/src/features/archives/components/ArchivesPhotoColumn.tsx b/src/features/archives/components/ArchivesPhotoColumn.tsx index 2d4eb1ec..4e254bc0 100644 --- a/src/features/archives/components/ArchivesPhotoColumn.tsx +++ b/src/features/archives/components/ArchivesPhotoColumn.tsx @@ -1,7 +1,7 @@ import ArchivesPhotoCard, { type ArchivesPhotoCardSize, } from "@/features/archives/components/ArchivesPhotoCard"; -import type { ArchiveItem } from "@/shared/entities/archives/types"; +import type { ArchiveItem } from "@/shared/entities/archives/archivesTypes"; interface ArchivesPhotoColumnProps { items: ArchiveItem[]; diff --git a/src/features/archives/components/ArchivesPhotoViewerContent.tsx b/src/features/archives/components/ArchivesPhotoViewerContent.tsx index a75bf8fe..1827f514 100644 --- a/src/features/archives/components/ArchivesPhotoViewerContent.tsx +++ b/src/features/archives/components/ArchivesPhotoViewerContent.tsx @@ -4,7 +4,7 @@ import { IconClose } from "@wanteddev/wds-icon"; import { useEffect, useRef, useState } from "react"; import ScreenHeader from "@/components/ui/ScreenHeader"; -import { archivesQueries } from "@/shared/entities/archives/archivesApi"; +import { archivesQueries } from "@/shared/entities/archives/archivesQueries"; interface ArchivesPhotoViewerContentProps { archiveId: string; diff --git a/src/features/archives/components/ArchivesPhotosContent.tsx b/src/features/archives/components/ArchivesPhotosContent.tsx index 5d218959..b5b1145c 100644 --- a/src/features/archives/components/ArchivesPhotosContent.tsx +++ b/src/features/archives/components/ArchivesPhotosContent.tsx @@ -2,7 +2,7 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { Typography } from "@wanteddev/wds"; import ScreenBottomSafeArea from "@/components/ui/ScreenBottomSafeArea"; -import { archivesQueries } from "@/shared/entities/archives/archivesApi"; +import { archivesQueries } from "@/shared/entities/archives/archivesQueries"; interface ArchivesPhotosContentProps { archiveId: string; diff --git a/src/features/auth/AuthSignupScreen.tsx b/src/features/auth/AuthSignupScreen.tsx index 1a4be621..4e5b03ae 100644 --- a/src/features/auth/AuthSignupScreen.tsx +++ b/src/features/auth/AuthSignupScreen.tsx @@ -14,7 +14,7 @@ import AuthTermsStep from "@/features/auth/steps/AuthTermsStep"; import { readAuthLocationState } from "@/features/auth/utils/authLocationState"; import { HOME_PATH } from "@/features/home/constants/home"; import { useFunnel } from "@/lib/useFunnel"; -import { submitOnboarding } from "@/shared/entities/auth/authApi"; +import { submitOnboarding } from "@/shared/entities/auth/authQueries"; import { isLoggedIn, markLoggedIn } from "@/shared/entities/auth/session"; // 로그인과 가입(연락처 → 약관 동의 → 약관 상세 → 가입 완료)을 useFunnel 하나로 묶은 화면이다. 단계 화면은 `steps/`에 diff --git a/src/features/auth/KConnectCallbackScreen.tsx b/src/features/auth/KConnectCallbackScreen.tsx index 7260d804..5c7b80e6 100644 --- a/src/features/auth/KConnectCallbackScreen.tsx +++ b/src/features/auth/KConnectCallbackScreen.tsx @@ -8,7 +8,7 @@ import { } from "@/features/auth/constants/auth"; import type { AuthLocationState } from "@/features/auth/utils/authLocationState"; import { HOME_PATH } from "@/features/home/constants/home"; -import { exchangeKConnectCode } from "@/shared/entities/auth/authApi"; +import { exchangeKConnectCode } from "@/shared/entities/auth/authQueries"; import { markLoggedIn } from "@/shared/entities/auth/session"; // K-CONNECT가 인증을 마치고 되돌려 보내는 redirect URI 라우트(`/oauth/kconnect`) — 경로는 K-CONNECT에 등록한 diff --git a/src/features/auth/steps/AuthLoginStep.tsx b/src/features/auth/steps/AuthLoginStep.tsx index 439614ba..615cecb0 100644 --- a/src/features/auth/steps/AuthLoginStep.tsx +++ b/src/features/auth/steps/AuthLoginStep.tsx @@ -14,7 +14,7 @@ import { isMockKConnectKind, mockKConnectLogin, } from "@/shared/entities/auth/authMock"; -import type { KConnectUser } from "@/shared/entities/auth/types"; +import type { KConnectUser } from "@/shared/entities/auth/authTypes"; // 로그인 — 화면설계서 2·3번. `K-CONNECT로 시작하기`를 누르면 K-CONNECT 인증 사이트로 페이지가 이동하고(@billilge/oauth-kconnect), // 인증을 마치면 `/oauth/kconnect`(KConnectCallbackScreen)가 결과를 받아 신규 사용자는 연락처 단계로, 기존 사용자는 홈으로 보낸다. diff --git a/src/features/bililge/BililgeListScreen.tsx b/src/features/bililge/BililgeListScreen.tsx index 4057620f..17646be1 100644 --- a/src/features/bililge/BililgeListScreen.tsx +++ b/src/features/bililge/BililgeListScreen.tsx @@ -20,7 +20,7 @@ import { } from "@/features/bililge/constants/bililge"; import { NOTIFICATIONS_PATH } from "@/features/notifications/constants/notifications"; import { getSearchPath } from "@/features/search/constants/search"; -import type { BililgeItem } from "@/shared/entities/bililge/types"; +import type { BililgeItem } from "@/shared/entities/bililge/bililgeTypes"; interface BililgeListScreenProps { /** 대여/반납 탭 — 탭마다 경로가 달라서 라우터가 정해 넘긴다 */ diff --git a/src/features/bililge/components/BililgeCategoryFilter.tsx b/src/features/bililge/components/BililgeCategoryFilter.tsx index 969c3113..42066a1f 100644 --- a/src/features/bililge/components/BililgeCategoryFilter.tsx +++ b/src/features/bililge/components/BililgeCategoryFilter.tsx @@ -17,7 +17,7 @@ interface BililgeCategoryFilterProps { // Figma: 빌릴게 Filter Row (nodeId 1243:73337) — 칩 자체는 행사 화면과 공유하는 FilterChipGroup을 쓴다. // 실제 목록 필터링은 BililgeItemList가 물품 데이터의 category 필드를 기준으로 한다 -// (entities/bililge/bililgeMock.ts 참고) — 이 컴포넌트는 선택 상태 표시·onChange 전달만 맡는다. +// (shared/entities/bililge/bililgeMock.ts 참고) — 이 컴포넌트는 선택 상태 표시·onChange 전달만 맡는다. function BililgeCategoryFilter({ value, onChange, diff --git a/src/features/bililge/components/BililgeItemList.tsx b/src/features/bililge/components/BililgeItemList.tsx index 5d26f1f2..3072f0fa 100644 --- a/src/features/bililge/components/BililgeItemList.tsx +++ b/src/features/bililge/components/BililgeItemList.tsx @@ -1,8 +1,8 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import BililgeItemCard from "@/features/bililge/components/BililgeItemCard"; -import { bililgeQueries } from "@/shared/entities/bililge/bililgeApi"; -import type { BililgeItem } from "@/shared/entities/bililge/types"; +import { bililgeQueries } from "@/shared/entities/bililge/bililgeQueries"; +import type { BililgeItem } from "@/shared/entities/bililge/bililgeTypes"; interface BililgeItemListProps { // "전체"는 필터 없음, 그 외는 BililgeCategory 값(BililgeCategoryFilter 칩 값) diff --git a/src/features/bililge/components/BililgeRentalSheet.tsx b/src/features/bililge/components/BililgeRentalSheet.tsx index d4634d15..99802fe9 100644 --- a/src/features/bililge/components/BililgeRentalSheet.tsx +++ b/src/features/bililge/components/BililgeRentalSheet.tsx @@ -12,7 +12,7 @@ import BottomSheet from "@/components/ui/BottomSheet"; import ScreenToast from "@/components/ui/ScreenToast"; import BililgeItemCard from "@/features/bililge/components/BililgeItemCard"; import BililgeRentalConfirmModal from "@/features/bililge/components/BililgeRentalConfirmModal"; -import type { BililgeItem } from "@/shared/entities/bililge/types"; +import type { BililgeItem } from "@/shared/entities/bililge/bililgeTypes"; interface BililgeRentalSheetProps { item: BililgeItem | null; diff --git a/src/features/events/EventsApplicationScreen.tsx b/src/features/events/EventsApplicationScreen.tsx index aa8151f0..3d9de7a4 100644 --- a/src/features/events/EventsApplicationScreen.tsx +++ b/src/features/events/EventsApplicationScreen.tsx @@ -19,7 +19,7 @@ import { } from "@/features/events/constants/eventsApplication"; import { submitEventsApplication } from "@/features/events/constants/eventsApplicationSubmit"; import { useBackNavigation } from "@/hooks/useBackNavigation"; -import type { EventsAnswer } from "@/shared/entities/events/types"; +import type { EventsAnswer } from "@/shared/entities/events/eventsTypes"; // 복수 선택은 하나라도 골라야 하고, 기타를 골랐으면 입력 내용까지 있어야 충족으로 본다 function isAnswered(answer: EventsAnswer | undefined): boolean { diff --git a/src/features/events/components/EventsApplicationCard.tsx b/src/features/events/components/EventsApplicationCard.tsx index a4579cb9..e30611a2 100644 --- a/src/features/events/components/EventsApplicationCard.tsx +++ b/src/features/events/components/EventsApplicationCard.tsx @@ -2,7 +2,7 @@ import { Button, ContentBadge, Typography } from "@wanteddev/wds"; import { EVENTS_APPLICATION_STATUS_BADGES } from "@/features/events/constants/events"; import { formatDateTime } from "@/lib/date"; -import type { MyEventApplication } from "@/shared/entities/events/types"; +import type { MyEventApplication } from "@/shared/entities/events/eventsTypes"; interface EventsApplicationCardProps { application: MyEventApplication; diff --git a/src/features/events/components/EventsApplicationDetailContent.tsx b/src/features/events/components/EventsApplicationDetailContent.tsx index 531c7d9c..0800170e 100644 --- a/src/features/events/components/EventsApplicationDetailContent.tsx +++ b/src/features/events/components/EventsApplicationDetailContent.tsx @@ -5,7 +5,7 @@ import { Navigate } from "react-router-dom"; import { NEGATIVE_BUTTON_SX } from "@/components/ui/negativeButtonSx"; import EventsApplicationSummary from "@/features/events/components/EventsApplicationSummary"; import EventsQuestionField from "@/features/events/components/EventsQuestionField"; -import { eventsQueries } from "@/shared/entities/events/eventsApi"; +import { eventsQueries } from "@/shared/entities/events/eventsQueries"; interface EventsApplicationDetailContentProps { applicationId: string; diff --git a/src/features/events/components/EventsApplicationList.tsx b/src/features/events/components/EventsApplicationList.tsx index 60e2078b..d82f75a6 100644 --- a/src/features/events/components/EventsApplicationList.tsx +++ b/src/features/events/components/EventsApplicationList.tsx @@ -4,7 +4,7 @@ import { Fragment } from "react"; import emptyEventsIllustration from "@/assets/icons/common/empty-events.svg"; import EmptyState from "@/components/ui/EmptyState"; import EventsApplicationCard from "@/features/events/components/EventsApplicationCard"; -import { eventsQueries } from "@/shared/entities/events/eventsApi"; +import { eventsQueries } from "@/shared/entities/events/eventsQueries"; interface EventsApplicationListProps { onSelectEvent: (eventId: string) => void; diff --git a/src/features/events/components/EventsApplicationSummary.tsx b/src/features/events/components/EventsApplicationSummary.tsx index bd3c0bbf..2cca2c9d 100644 --- a/src/features/events/components/EventsApplicationSummary.tsx +++ b/src/features/events/components/EventsApplicationSummary.tsx @@ -1,7 +1,7 @@ import StatusSummaryCard from "@/components/ui/StatusSummaryCard"; import { EVENTS_APPLICATION_STATUS_BADGES } from "@/features/events/constants/events"; import { formatDateTime } from "@/lib/date"; -import type { MyEventApplication } from "@/shared/entities/events/types"; +import type { MyEventApplication } from "@/shared/entities/events/eventsTypes"; // Figma: 신청내역 상세 Event Summary (nodeId 1133:46188), 신청취소 상세 (1133:46223). // 신청내역에서 "행사·신청·(취소)" 행을 만들어 공용 StatusSummaryCard에 넘긴다 — 취소 행은 취소된 신청에만 있다. diff --git a/src/features/events/components/EventsCard.tsx b/src/features/events/components/EventsCard.tsx index b1cf9c49..847451f4 100644 --- a/src/features/events/components/EventsCard.tsx +++ b/src/features/events/components/EventsCard.tsx @@ -1,6 +1,6 @@ import { Button, Typography } from "@wanteddev/wds"; import EventsStatusBadge from "@/features/events/components/EventsStatusBadge"; -import type { EventStatus } from "@/shared/entities/events/types"; +import type { EventStatus } from "@/shared/entities/events/eventsTypes"; interface EventsCardProps { title: string; diff --git a/src/features/events/components/EventsDetailContent.tsx b/src/features/events/components/EventsDetailContent.tsx index ef8b0494..59b2b3e9 100644 --- a/src/features/events/components/EventsDetailContent.tsx +++ b/src/features/events/components/EventsDetailContent.tsx @@ -16,8 +16,8 @@ import { EVENTS_FEE_PAID_ONLY_LABEL, getEventAudience, } from "@/features/events/constants/events"; -import { eventsQueries } from "@/shared/entities/events/eventsApi"; -import { feeQueries } from "@/shared/entities/fee/feeApi"; +import { eventsQueries } from "@/shared/entities/events/eventsQueries"; +import { feeQueries } from "@/shared/entities/fee/feeQueries"; interface EventsDetailContentProps { eventId: string; diff --git a/src/features/events/components/EventsList.tsx b/src/features/events/components/EventsList.tsx index 50bc2860..ad67b474 100644 --- a/src/features/events/components/EventsList.tsx +++ b/src/features/events/components/EventsList.tsx @@ -4,7 +4,7 @@ import { Fragment } from "react"; import emptyEventsIllustration from "@/assets/icons/common/empty-events.svg"; import EmptyState from "@/components/ui/EmptyState"; import EventsCard from "@/features/events/components/EventsCard"; -import { eventsQueries } from "@/shared/entities/events/eventsApi"; +import { eventsQueries } from "@/shared/entities/events/eventsQueries"; // Figma: 행사 모집중 empty (nodeId 1165:62713)는 "모집중" 필터 버전만 준다 — // 일러스트·레이아웃·"아카이빙 둘러보기" 버튼이 이 조합의 스펙이다. diff --git a/src/features/events/components/EventsQuestionField.tsx b/src/features/events/components/EventsQuestionField.tsx index 60990277..bac3cb80 100644 --- a/src/features/events/components/EventsQuestionField.tsx +++ b/src/features/events/components/EventsQuestionField.tsx @@ -21,7 +21,7 @@ import type { EventsAnswer, EventsChoiceAnswer, EventsQuestion, -} from "@/shared/entities/events/types"; +} from "@/shared/entities/events/eventsTypes"; interface EventsQuestionFieldBaseProps { question: EventsQuestion; diff --git a/src/features/events/components/EventsStatusBadge.tsx b/src/features/events/components/EventsStatusBadge.tsx index 618043e2..779b365f 100644 --- a/src/features/events/components/EventsStatusBadge.tsx +++ b/src/features/events/components/EventsStatusBadge.tsx @@ -1,6 +1,6 @@ import { ContentBadge } from "@wanteddev/wds"; -import type { EventStatus } from "@/shared/entities/events/types"; +import type { EventStatus } from "@/shared/entities/events/eventsTypes"; interface EventsStatusBadgeProps { status: EventStatus; diff --git a/src/features/events/constants/events.ts b/src/features/events/constants/events.ts index c0f1ccac..8ec3bc27 100644 --- a/src/features/events/constants/events.ts +++ b/src/features/events/constants/events.ts @@ -1,4 +1,4 @@ -import type { EventItem } from "@/shared/entities/events/types"; +import type { EventItem } from "@/shared/entities/events/eventsTypes"; // 모집 상태 필터. value는 EventStatus + "all"이고, 라벨은 Figma Filter Chips(1243:70861) 그대로. export const EVENT_STATUS_FILTERS = [ diff --git a/src/features/events/constants/eventsApplication.ts b/src/features/events/constants/eventsApplication.ts index d250e6ad..89e804fd 100644 --- a/src/features/events/constants/eventsApplication.ts +++ b/src/features/events/constants/eventsApplication.ts @@ -2,7 +2,7 @@ import filePencil from "@/assets/icons/events/file-pencil.svg"; import type { EventsQuestion, EventsTextQuestion, -} from "@/shared/entities/events/types"; +} from "@/shared/entities/events/eventsTypes"; export interface EventsApplication { eventName: string; diff --git a/src/features/events/hooks/useEventsApplicationCancel.ts b/src/features/events/hooks/useEventsApplicationCancel.ts index 2118841e..3177a87a 100644 --- a/src/features/events/hooks/useEventsApplicationCancel.ts +++ b/src/features/events/hooks/useEventsApplicationCancel.ts @@ -1,6 +1,6 @@ import { useState } from "react"; -import { useCancelEventApplicationMutation } from "@/shared/entities/events/eventsApi"; +import { useCancelEventApplicationMutation } from "@/shared/entities/events/eventsQueries"; // 취소 결과 토스트 — 문구와 색을 결과마다 한 번에 고른다 const CANCEL_TOASTS = { diff --git a/src/features/fee/components/FeePaymentCard.tsx b/src/features/fee/components/FeePaymentCard.tsx index 3c6b95df..3888e756 100644 --- a/src/features/fee/components/FeePaymentCard.tsx +++ b/src/features/fee/components/FeePaymentCard.tsx @@ -3,7 +3,7 @@ import { Button, Typography } from "@wanteddev/wds"; import StatusSummaryCard from "@/components/ui/StatusSummaryCard"; import { FEE_STATUS_BADGES } from "@/features/fee/constants/fee"; import { formatDateTime } from "@/lib/date"; -import type { FeePayment } from "@/shared/entities/fee/types"; +import type { FeePayment } from "@/shared/entities/fee/feeTypes"; interface FeePaymentCardProps { payment: FeePayment; diff --git a/src/features/fee/components/FeePaymentList.tsx b/src/features/fee/components/FeePaymentList.tsx index 28de050e..696a940d 100644 --- a/src/features/fee/components/FeePaymentList.tsx +++ b/src/features/fee/components/FeePaymentList.tsx @@ -1,7 +1,7 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import FeePaymentCard from "@/features/fee/components/FeePaymentCard"; -import { feeQueries } from "@/shared/entities/fee/feeApi"; +import { feeQueries } from "@/shared/entities/fee/feeQueries"; interface FeePaymentListProps { /** 없으면 문의하기 버튼을 그리지 않는다(채널 주소가 아직 없을 때) */ diff --git a/src/features/fee/constants/fee.ts b/src/features/fee/constants/fee.ts index e7ab1d25..ed59ca39 100644 --- a/src/features/fee/constants/fee.ts +++ b/src/features/fee/constants/fee.ts @@ -1,4 +1,4 @@ -import type { FeePaymentStatus } from "@/shared/entities/fee/types"; +import type { FeePaymentStatus } from "@/shared/entities/fee/feeTypes"; // 학생회비 계좌 송금 플로우 경로. 화면설계서(687:20584)에는 경로 표기 컬럼이 아예 없어서 // 용어 사전(terminology.md)의 `회비 납부 → fee`와 "프론트 라우트 경로: /{코드 용어}" 규칙으로 정했다. diff --git a/src/features/feedbacks/components/FeedbacksHistoryCard.tsx b/src/features/feedbacks/components/FeedbacksHistoryCard.tsx index 39ed60b5..e3764568 100644 --- a/src/features/feedbacks/components/FeedbacksHistoryCard.tsx +++ b/src/features/feedbacks/components/FeedbacksHistoryCard.tsx @@ -3,7 +3,7 @@ import { ContentBadge, Typography } from "@wanteddev/wds"; import arrowRight from "@/assets/icons/common/arrow-right.svg"; import { hasFeedbackAnswer } from "@/features/feedbacks/constants/feedbacks"; import { formatDate } from "@/lib/date"; -import type { MyFeedback } from "@/shared/entities/feedbacks/types"; +import type { MyFeedback } from "@/shared/entities/feedbacks/feedbacksTypes"; interface FeedbacksHistoryCardProps { feedback: MyFeedback; diff --git a/src/features/feedbacks/components/FeedbacksMineDetailContent.tsx b/src/features/feedbacks/components/FeedbacksMineDetailContent.tsx index dfe50e66..11a3a40c 100644 --- a/src/features/feedbacks/components/FeedbacksMineDetailContent.tsx +++ b/src/features/feedbacks/components/FeedbacksMineDetailContent.tsx @@ -8,7 +8,7 @@ import questionIcon from "@/assets/icons/feedbacks/question.svg"; import EmptyState from "@/components/ui/EmptyState"; import FeedbacksAnswerText from "@/features/feedbacks/components/FeedbacksAnswerText"; import { hasFeedbackAnswer } from "@/features/feedbacks/constants/feedbacks"; -import { feedbacksQueries } from "@/shared/entities/feedbacks/feedbacksApi"; +import { feedbacksQueries } from "@/shared/entities/feedbacks/feedbacksQueries"; interface FeedbacksMineDetailContentProps { feedbackId: string; diff --git a/src/features/feedbacks/components/FeedbacksMineList.tsx b/src/features/feedbacks/components/FeedbacksMineList.tsx index c3593ea1..f2830381 100644 --- a/src/features/feedbacks/components/FeedbacksMineList.tsx +++ b/src/features/feedbacks/components/FeedbacksMineList.tsx @@ -2,7 +2,7 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import emptyFeedbackIllustration from "@/assets/icons/feedbacks/empty-feedback.svg"; import EmptyState from "@/components/ui/EmptyState"; import FeedbacksHistoryCard from "@/features/feedbacks/components/FeedbacksHistoryCard"; -import { feedbacksQueries } from "@/shared/entities/feedbacks/feedbacksApi"; +import { feedbacksQueries } from "@/shared/entities/feedbacks/feedbacksQueries"; interface FeedbacksMineListProps { onSelect: (feedbackId: string) => void; diff --git a/src/features/feedbacks/constants/feedbacks.ts b/src/features/feedbacks/constants/feedbacks.ts index 17b0f0b7..700e9fda 100644 --- a/src/features/feedbacks/constants/feedbacks.ts +++ b/src/features/feedbacks/constants/feedbacks.ts @@ -1,7 +1,7 @@ import type { Feedback, FeedbackRound, -} from "@/shared/entities/feedbacks/types"; +} from "@/shared/entities/feedbacks/feedbacksTypes"; // "전체 피드백" 필터는 연도와 차수를 같이 건다. 이 값이면 그 조건으로는 거르지 않는다. export const FEEDBACK_FILTER_ALL = "all"; diff --git a/src/features/home/constants/homeMock.ts b/src/features/home/constants/homeMock.ts index a50b1fc4..90bbc372 100644 --- a/src/features/home/constants/homeMock.ts +++ b/src/features/home/constants/homeMock.ts @@ -1,8 +1,8 @@ import hairDryer from "@/assets/icons/bililge-items/hair-dryer.svg"; import powerBank from "@/assets/icons/bililge-items/power-bank.svg"; import umbrella from "@/assets/icons/bililge-items/umbrella.svg"; -import type { FeePaymentStatus } from "@/shared/entities/fee/types"; -import type { LockerAssignmentStatus } from "@/shared/entities/lockers/types"; +import type { FeePaymentStatus } from "@/shared/entities/fee/feeTypes"; +import type { LockerAssignmentStatus } from "@/shared/entities/lockers/lockersTypes"; // 홈 API가 붙기 전까지 쓰는 목데이터다. export const HOME_HAS_UNREAD_NOTIFICATION = true; diff --git a/src/features/lockers/components/LockersAssignmentContent.tsx b/src/features/lockers/components/LockersAssignmentContent.tsx index 567928bc..2b630875 100644 --- a/src/features/lockers/components/LockersAssignmentContent.tsx +++ b/src/features/lockers/components/LockersAssignmentContent.tsx @@ -7,7 +7,7 @@ import EmptyState from "@/components/ui/EmptyState"; import StatusSummaryCard from "@/components/ui/StatusSummaryCard"; import { LOCKER_STATUS_BADGES } from "@/features/lockers/constants/lockers"; import { formatDate, formatDateTime } from "@/lib/date"; -import { lockersQueries } from "@/shared/entities/lockers/lockersApi"; +import { lockersQueries } from "@/shared/entities/lockers/lockersQueries"; // 이 화면에 오는 배정은 항상 배정완료다(기간종료·미신청은 홈 배지에서만 쓴다) const ASSIGNED_BADGE = LOCKER_STATUS_BADGES.assigned; diff --git a/src/features/lockers/components/LockersMineContent.tsx b/src/features/lockers/components/LockersMineContent.tsx index f01b97ec..c86d066f 100644 --- a/src/features/lockers/components/LockersMineContent.tsx +++ b/src/features/lockers/components/LockersMineContent.tsx @@ -8,7 +8,7 @@ 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"; -import { lockersQueries } from "@/shared/entities/lockers/lockersApi"; +import { lockersQueries } from "@/shared/entities/lockers/lockersQueries"; interface LockersMineContentProps { /** 배정이 없거나 구역 배치가 없으면 보낼 곳 — 이동할 경로는 화면이 정한다 */ diff --git a/src/features/lockers/constants/lockers.ts b/src/features/lockers/constants/lockers.ts index 005b063e..4e9ecfae 100644 --- a/src/features/lockers/constants/lockers.ts +++ b/src/features/lockers/constants/lockers.ts @@ -1,4 +1,4 @@ -import type { LockerAssignmentStatus } from "@/shared/entities/lockers/types"; +import type { LockerAssignmentStatus } from "@/shared/entities/lockers/lockersTypes"; // 사물함 배정 상태 배지 — 사물함 배정 상태 화면과 홈 내 정보 목록(HomeInfoList)이 같이 쓴다. // color가 없으면 회색(neutral) 배지다. diff --git a/src/features/notices/NoticesListScreen.tsx b/src/features/notices/NoticesListScreen.tsx index c06d8905..5d239416 100644 --- a/src/features/notices/NoticesListScreen.tsx +++ b/src/features/notices/NoticesListScreen.tsx @@ -9,7 +9,7 @@ import NoticesList from "@/features/notices/components/NoticesList"; import NoticesListSkeleton from "@/features/notices/components/NoticesListSkeleton"; import { NOTIFICATIONS_PATH } from "@/features/notifications/constants/notifications"; import { getSearchPath } from "@/features/search/constants/search"; -import type { NoticeCategory } from "@/shared/entities/notices/types"; +import type { NoticeCategory } from "@/shared/entities/notices/noticesTypes"; type NoticeTab = "all" | "general" | "partnership"; diff --git a/src/features/notices/components/NoticesDetailContent.tsx b/src/features/notices/components/NoticesDetailContent.tsx index 2517c866..ffa9caa7 100644 --- a/src/features/notices/components/NoticesDetailContent.tsx +++ b/src/features/notices/components/NoticesDetailContent.tsx @@ -10,8 +10,8 @@ import { IconChevronLeft } from "@wanteddev/wds-icon"; import PhotoGallery from "@/components/ui/PhotoGallery"; import ScreenHeader from "@/components/ui/ScreenHeader"; import { useScreenHeader } from "@/components/ui/useScreenHeader"; -import { noticesQueries } from "@/shared/entities/notices/noticesApi"; -import type { NoticeCategory } from "@/shared/entities/notices/types"; +import { noticesQueries } from "@/shared/entities/notices/noticesQueries"; +import type { NoticeCategory } from "@/shared/entities/notices/noticesTypes"; const CATEGORY_BADGE_COLOR: Record = { 일반: "semantic.accent.foreground.blue", diff --git a/src/features/notices/components/NoticesList.tsx b/src/features/notices/components/NoticesList.tsx index 74063624..cfed3fe0 100644 --- a/src/features/notices/components/NoticesList.tsx +++ b/src/features/notices/components/NoticesList.tsx @@ -4,8 +4,8 @@ import { Fragment } from "react"; import { Link } from "react-router-dom"; import NoticesCard from "@/features/notices/components/NoticesCard"; -import { noticesQueries } from "@/shared/entities/notices/noticesApi"; -import type { NoticeCategory } from "@/shared/entities/notices/types"; +import { noticesQueries } from "@/shared/entities/notices/noticesQueries"; +import type { NoticeCategory } from "@/shared/entities/notices/noticesTypes"; interface NoticesListProps { category: NoticeCategory | "all"; diff --git a/src/features/notifications/components/NotificationsItem.tsx b/src/features/notifications/components/NotificationsItem.tsx index 47b326c2..5937e122 100644 --- a/src/features/notifications/components/NotificationsItem.tsx +++ b/src/features/notifications/components/NotificationsItem.tsx @@ -1,7 +1,7 @@ import { Typography } from "@wanteddev/wds"; import { NOTIFICATION_TYPE_META } from "@/features/notifications/constants/notifications"; -import type { NotificationType } from "@/shared/entities/notifications/types"; +import type { NotificationType } from "@/shared/entities/notifications/notificationsTypes"; interface NotificationsItemProps { type: NotificationType; diff --git a/src/features/notifications/components/NotificationsList.tsx b/src/features/notifications/components/NotificationsList.tsx index 18668d7e..998a0bfc 100644 --- a/src/features/notifications/components/NotificationsList.tsx +++ b/src/features/notifications/components/NotificationsList.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import emptyNotificationsIllustration from "@/assets/icons/notifications/empty-notifications.svg"; import EmptyState from "@/components/ui/EmptyState"; import NotificationsItem from "@/features/notifications/components/NotificationsItem"; -import { notificationsQueries } from "@/shared/entities/notifications/notificationsApi"; +import { notificationsQueries } from "@/shared/entities/notifications/notificationsQueries"; // 알림 목록 데이터를 useSuspenseQuery로 읽는 데이터 컴포넌트 — 응답을 기다리는 동안은 바깥 Suspense가 스켈레톤을 보여준다. // 읽음 처리는 서버 API가 없어서 이 화면 state로만 들고 있다(새로고침하면 목데이터 값으로 돌아간다). diff --git a/src/features/notifications/constants/notifications.ts b/src/features/notifications/constants/notifications.ts index c74bbdcf..95d53515 100644 --- a/src/features/notifications/constants/notifications.ts +++ b/src/features/notifications/constants/notifications.ts @@ -4,7 +4,7 @@ import eventIcon from "@/assets/icons/notifications/event.svg"; import feedbackIcon from "@/assets/icons/notifications/feedback.svg"; import lockerIcon from "@/assets/icons/notifications/locker.svg"; import noticeIcon from "@/assets/icons/notifications/notice.svg"; -import type { NotificationType } from "@/shared/entities/notifications/types"; +import type { NotificationType } from "@/shared/entities/notifications/notificationsTypes"; // Figma Notification Category — 24px 아이콘 + 라벨. 빌릴게는 대여·반납이 같은 라벨에 아이콘만 다르다. export const NOTIFICATION_TYPE_META = { diff --git a/src/features/search/components/SearchCategoryItems.tsx b/src/features/search/components/SearchCategoryItems.tsx index c91e5006..3386d0a6 100644 --- a/src/features/search/components/SearchCategoryItems.tsx +++ b/src/features/search/components/SearchCategoryItems.tsx @@ -7,7 +7,7 @@ import FeedbacksCard from "@/features/feedbacks/components/FeedbacksCard"; import NoticesCard from "@/features/notices/components/NoticesCard"; import SearchArchivingCard from "@/features/search/components/SearchArchivingCard"; import type { SearchCategory } from "@/features/search/constants/search"; -import type { SearchResults } from "@/shared/entities/search/types"; +import type { SearchResults } from "@/shared/entities/search/searchTypes"; interface SearchCategoryItemsProps { category: SearchCategory; diff --git a/src/features/search/components/SearchResultContent.tsx b/src/features/search/components/SearchResultContent.tsx index 5b7b28e3..02a12e57 100644 --- a/src/features/search/components/SearchResultContent.tsx +++ b/src/features/search/components/SearchResultContent.tsx @@ -6,7 +6,7 @@ import { getSearchResultCount, type SearchTab, } from "@/features/search/constants/search"; -import { searchQueries } from "@/shared/entities/search/searchApi"; +import { searchQueries } from "@/shared/entities/search/searchQueries"; interface SearchResultContentProps { keyword: string; diff --git a/src/features/search/components/SearchResultView.tsx b/src/features/search/components/SearchResultView.tsx index 40540e63..a28ec44a 100644 --- a/src/features/search/components/SearchResultView.tsx +++ b/src/features/search/components/SearchResultView.tsx @@ -15,7 +15,7 @@ import { SEARCH_TABS, type SearchTab, } from "@/features/search/constants/search"; -import type { SearchResults } from "@/shared/entities/search/types"; +import type { SearchResults } from "@/shared/entities/search/searchTypes"; interface SearchResultViewProps { results: SearchResults; diff --git a/src/features/search/constants/search.ts b/src/features/search/constants/search.ts index 636a7dc9..8221495e 100644 --- a/src/features/search/constants/search.ts +++ b/src/features/search/constants/search.ts @@ -1,4 +1,4 @@ -import type { SearchResults } from "@/shared/entities/search/types"; +import type { SearchResults } from "@/shared/entities/search/searchTypes"; // 검색 결과 카테고리 — 키는 용어 사전(terminology.md)의 코드 용어를 그대로 쓴다. export type SearchCategory = diff --git a/src/features/settings/components/SettingsAccountRows.tsx b/src/features/settings/components/SettingsAccountRows.tsx index 0e24e1b7..3fcfba29 100644 --- a/src/features/settings/components/SettingsAccountRows.tsx +++ b/src/features/settings/components/SettingsAccountRows.tsx @@ -1,7 +1,7 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import SettingsRow from "@/features/settings/components/SettingsRow"; -import { usersQueries } from "@/shared/entities/users/usersApi"; +import { usersQueries } from "@/shared/entities/users/usersQueries"; interface SettingsAccountRowsProps { onChangePhone: () => void; diff --git a/src/features/settings/components/SettingsAppVersionRow.tsx b/src/features/settings/components/SettingsAppVersionRow.tsx index 0ad354b8..6fac71b4 100644 --- a/src/features/settings/components/SettingsAppVersionRow.tsx +++ b/src/features/settings/components/SettingsAppVersionRow.tsx @@ -1,7 +1,7 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import SettingsRow from "@/features/settings/components/SettingsRow"; -import { usersQueries } from "@/shared/entities/users/usersApi"; +import { usersQueries } from "@/shared/entities/users/usersQueries"; // 앱 버전 행(Figma 3524:152356) — 서버는 버전과 최신 여부를 따로 주고, Figma 표기("26.0.1 · // 최신버전")는 화면이 조합한다. 최신이 아닐 때 문구는 디자인에 없어서 버전만 보여 준다. diff --git a/src/shared/entities/archives/archivesKeys.ts b/src/shared/entities/archives/archivesKeys.ts new file mode 100644 index 00000000..c344048a --- /dev/null +++ b/src/shared/entities/archives/archivesKeys.ts @@ -0,0 +1,6 @@ +export const archivesKeys = { + root: () => ["archives"] as const, + list: () => [...archivesKeys.root(), "list"] as const, + detail: (archiveId: string) => + [...archivesKeys.root(), "detail", archiveId] as const, +}; diff --git a/src/shared/entities/archives/archivesMock.ts b/src/shared/entities/archives/archivesMock.ts index 2f05fede..bcd43b80 100644 --- a/src/shared/entities/archives/archivesMock.ts +++ b/src/shared/entities/archives/archivesMock.ts @@ -6,7 +6,7 @@ import sportsDay from "@/assets/images/archives/sports-day.jpg"; import type { ArchiveDetail, ArchiveItem, -} from "@/shared/entities/archives/types"; +} from "@/shared/entities/archives/archivesTypes"; // Figma: 아카이빙 목록(nodeId 1276:95397) · 상세(nodeId 1526:171215) 목업 데이터. // 검색 결과(SearchArchivingCard)와 아카이빙 목록·상세가 같은 데이터를 본다 — 검색에서 누른 앨범이 diff --git a/src/shared/entities/archives/archivesApi.ts b/src/shared/entities/archives/archivesQueries.ts similarity index 79% rename from src/shared/entities/archives/archivesApi.ts rename to src/shared/entities/archives/archivesQueries.ts index 0c630d55..f9eb35ff 100644 --- a/src/shared/entities/archives/archivesApi.ts +++ b/src/shared/entities/archives/archivesQueries.ts @@ -1,18 +1,12 @@ import { queryOptions } from "@tanstack/react-query"; import { mockupResponse } from "@/lib/mockupApi"; +import { archivesKeys } from "@/shared/entities/archives/archivesKeys"; import { ARCHIVES, findArchiveDetail, } from "@/shared/entities/archives/archivesMock"; -const archivesKeys = { - root: () => ["archives"] as const, - list: () => [...archivesKeys.root(), "list"] as const, - detail: (archiveId: string) => - [...archivesKeys.root(), "detail", archiveId] as const, -}; - // 실 API가 붙으면 queryFn 안쪽만 요청 코드로 바꾼다 — 화면은 이 queryOptions로만 데이터를 받는다. export const archivesQueries = { list: () => diff --git a/src/shared/entities/archives/types.ts b/src/shared/entities/archives/archivesTypes.ts similarity index 100% rename from src/shared/entities/archives/types.ts rename to src/shared/entities/archives/archivesTypes.ts diff --git a/src/shared/entities/auth/authMock.ts b/src/shared/entities/auth/authMock.ts index 99ff99d0..4c80889c 100644 --- a/src/shared/entities/auth/authMock.ts +++ b/src/shared/entities/auth/authMock.ts @@ -1,4 +1,4 @@ -import type { KConnectUser } from "@/shared/entities/auth/types"; +import type { KConnectUser } from "@/shared/entities/auth/authTypes"; // 실 API가 붙기 전까지 인증 흐름을 흉내 내는 목 전용 코드다. 실 API로 바꿀 때 이 파일째 지운다. diff --git a/src/shared/entities/auth/authApi.ts b/src/shared/entities/auth/authQueries.ts similarity index 97% rename from src/shared/entities/auth/authApi.ts rename to src/shared/entities/auth/authQueries.ts index ef83690a..93f758b2 100644 --- a/src/shared/entities/auth/authApi.ts +++ b/src/shared/entities/auth/authQueries.ts @@ -7,7 +7,7 @@ import { import type { KConnectUser, OnboardingSubmission, -} from "@/shared/entities/auth/types"; +} from "@/shared/entities/auth/authTypes"; // 서버로 보내는 K-CONNECT 인증 결과 — @billilge/oauth-kconnect가 돌려주는 값 중 서버가 쓰는 세 가지다. export interface KConnectCredentials { diff --git a/src/shared/entities/auth/types.ts b/src/shared/entities/auth/authTypes.ts similarity index 100% rename from src/shared/entities/auth/types.ts rename to src/shared/entities/auth/authTypes.ts diff --git a/src/shared/entities/bililge/bililgeKeys.ts b/src/shared/entities/bililge/bililgeKeys.ts new file mode 100644 index 00000000..f543cb33 --- /dev/null +++ b/src/shared/entities/bililge/bililgeKeys.ts @@ -0,0 +1,4 @@ +export const bililgeKeys = { + root: () => ["bililge"] as const, + items: () => [...bililgeKeys.root(), "items"] as const, +}; diff --git a/src/shared/entities/bililge/bililgeMock.ts b/src/shared/entities/bililge/bililgeMock.ts index 22776b0b..86d33638 100644 --- a/src/shared/entities/bililge/bililgeMock.ts +++ b/src/shared/entities/bililge/bililgeMock.ts @@ -13,7 +13,7 @@ import powerBank from "@/assets/icons/bililge-items/power-bank.svg"; import sanitaryPad from "@/assets/icons/bililge-items/sanitary-pad.svg"; import umbrella from "@/assets/icons/bililge-items/umbrella.svg"; import usbCCharger from "@/assets/icons/bililge-items/usb-c-charger.svg"; -import type { BililgeItem } from "@/shared/entities/bililge/types"; +import type { BililgeItem } from "@/shared/entities/bililge/bililgeTypes"; // 목데이터는 실 서버가 없어 위 필드를 흉내내는 값이다 — 실행 시점 기준으로 오늘/내일/그 이후 // 라벨을 미리 계산해두되, 컴포넌트 쪽에는 이 계산 로직 자체를 노출하지 않는다(export하지 않음). diff --git a/src/shared/entities/bililge/bililgeApi.ts b/src/shared/entities/bililge/bililgeQueries.ts similarity index 79% rename from src/shared/entities/bililge/bililgeApi.ts rename to src/shared/entities/bililge/bililgeQueries.ts index ba3bcf7c..73530aad 100644 --- a/src/shared/entities/bililge/bililgeApi.ts +++ b/src/shared/entities/bililge/bililgeQueries.ts @@ -1,13 +1,9 @@ import { queryOptions } from "@tanstack/react-query"; import { mockupResponse } from "@/lib/mockupApi"; +import { bililgeKeys } from "@/shared/entities/bililge/bililgeKeys"; import { BILILGE_ITEMS } from "@/shared/entities/bililge/bililgeMock"; -const bililgeKeys = { - root: () => ["bililge"] as const, - items: () => [...bililgeKeys.root(), "items"] as const, -}; - // 실 API가 붙으면 queryFn 안쪽만 요청 코드로 바꾼다 — 화면은 이 queryOptions로만 데이터를 받는다. export const bililgeQueries = { items: () => diff --git a/src/shared/entities/bililge/types.ts b/src/shared/entities/bililge/bililgeTypes.ts similarity index 100% rename from src/shared/entities/bililge/types.ts rename to src/shared/entities/bililge/bililgeTypes.ts diff --git a/src/shared/entities/events/eventsKeys.ts b/src/shared/entities/events/eventsKeys.ts new file mode 100644 index 00000000..e13a38e4 --- /dev/null +++ b/src/shared/entities/events/eventsKeys.ts @@ -0,0 +1,11 @@ +// 조회 캐시 key. 조회와 무효화가 같은 함수로 key를 만들어서, 한쪽만 바뀌어 무효화가 빗나가지 않게 한다. +// 신청내역 상세는 목록 key 아래에 둬서, 목록 key 하나로 목록·상세를 같이 무효화한다. +export const eventsKeys = { + root: () => ["events"] as const, + list: () => [...eventsKeys.root(), "list"] as const, + detail: (eventId: string) => + [...eventsKeys.root(), "detail", eventId] as const, + applications: () => [...eventsKeys.root(), "applications"] as const, + application: (applicationId: string) => + [...eventsKeys.applications(), applicationId] as const, +}; diff --git a/src/shared/entities/events/eventsMock.ts b/src/shared/entities/events/eventsMock.ts index 766f182e..d829bfb9 100644 --- a/src/shared/entities/events/eventsMock.ts +++ b/src/shared/entities/events/eventsMock.ts @@ -2,7 +2,7 @@ import type { EventItem, EventsQuestion, MyEventApplication, -} from "@/shared/entities/events/types"; +} from "@/shared/entities/events/eventsTypes"; // Figma 상세(1133:42433 모집중 / 1156:53992 모집예정)의 메타데이터·본문은 목업이라 행사별로 다르지 않다. // 목록 카드가 같은 행사명 4개를 상태만 바꿔 보여주는 것과 같은 이유로, 상세 내용도 공통 상수로 두고 @@ -174,7 +174,7 @@ const APPLICATIONS: MyEventApplication[] = [ type MyEventApplicationsMockState = "list" | "empty"; const MY_EVENT_APPLICATIONS_MOCK_STATE: MyEventApplicationsMockState = "list"; -// 신청 취소가 이 배열의 항목을 바꾼다(eventsApi의 useCancelEventApplicationMutation) — 목록·상세에 같이 반영된다. +// 신청 취소가 이 배열의 항목을 바꾼다(eventsQueries의 useCancelEventApplicationMutation) — 목록·상세에 같이 반영된다. export const MY_EVENT_APPLICATIONS_MOCK: MyEventApplication[] = MY_EVENT_APPLICATIONS_MOCK_STATE === "list" ? APPLICATIONS : []; diff --git a/src/shared/entities/events/eventsApi.ts b/src/shared/entities/events/eventsQueries.ts similarity index 83% rename from src/shared/entities/events/eventsApi.ts rename to src/shared/entities/events/eventsQueries.ts index b706938a..39ebb6f4 100644 --- a/src/shared/entities/events/eventsApi.ts +++ b/src/shared/entities/events/eventsQueries.ts @@ -5,24 +5,13 @@ import { } from "@tanstack/react-query"; import { mockupResponse } from "@/lib/mockupApi"; +import { eventsKeys } from "@/shared/entities/events/eventsKeys"; import { EVENTS, MY_EVENT_APPLICATIONS_MOCK, toMockDateTime, } from "@/shared/entities/events/eventsMock"; -import type { MyEventApplication } from "@/shared/entities/events/types"; - -// 조회 캐시 key. 조회와 무효화가 같은 함수로 key를 만들어서, 한쪽만 바뀌어 무효화가 빗나가지 않게 한다. -// 신청내역 상세는 목록 key 아래에 둬서, 목록 key 하나로 목록·상세를 같이 무효화한다. -const eventsKeys = { - root: () => ["events"] as const, - list: () => [...eventsKeys.root(), "list"] as const, - detail: (eventId: string) => - [...eventsKeys.root(), "detail", eventId] as const, - applications: () => [...eventsKeys.root(), "applications"] as const, - application: (applicationId: string) => - [...eventsKeys.applications(), applicationId] as const, -}; +import type { MyEventApplication } from "@/shared/entities/events/eventsTypes"; // 실 API가 붙으면 queryFn 안쪽만 요청 코드로 바꾼다 — 화면은 이 queryOptions로만 데이터를 받는다. export const eventsQueries = { diff --git a/src/shared/entities/events/types.ts b/src/shared/entities/events/eventsTypes.ts similarity index 100% rename from src/shared/entities/events/types.ts rename to src/shared/entities/events/eventsTypes.ts diff --git a/src/shared/entities/fee/feeKeys.ts b/src/shared/entities/fee/feeKeys.ts new file mode 100644 index 00000000..067b5e4b --- /dev/null +++ b/src/shared/entities/fee/feeKeys.ts @@ -0,0 +1,5 @@ +export const feeKeys = { + root: () => ["fee"] as const, + payments: () => [...feeKeys.root(), "payments"] as const, + myStatus: () => [...feeKeys.root(), "me"] as const, +}; diff --git a/src/shared/entities/fee/feeMock.ts b/src/shared/entities/fee/feeMock.ts index efa869d2..5a9e382d 100644 --- a/src/shared/entities/fee/feeMock.ts +++ b/src/shared/entities/fee/feeMock.ts @@ -1,4 +1,4 @@ -import type { FeePayment, MyFeeStatus } from "@/shared/entities/fee/types"; +import type { FeePayment, MyFeeStatus } from "@/shared/entities/fee/feeTypes"; const TITLE = "소프트웨어융합대학 학생회비"; diff --git a/src/shared/entities/fee/feeApi.ts b/src/shared/entities/fee/feeQueries.ts similarity index 81% rename from src/shared/entities/fee/feeApi.ts rename to src/shared/entities/fee/feeQueries.ts index 87a77ecb..3d763c05 100644 --- a/src/shared/entities/fee/feeApi.ts +++ b/src/shared/entities/fee/feeQueries.ts @@ -1,17 +1,12 @@ import { queryOptions } from "@tanstack/react-query"; import { mockupResponse } from "@/lib/mockupApi"; +import { feeKeys } from "@/shared/entities/fee/feeKeys"; import { FEE_PAYMENTS_MOCK, MY_FEE_STATUS_MOCK, } from "@/shared/entities/fee/feeMock"; -const feeKeys = { - root: () => ["fee"] as const, - payments: () => [...feeKeys.root(), "payments"] as const, - myStatus: () => [...feeKeys.root(), "me"] as const, -}; - // 실 API가 붙으면 queryFn 안쪽만 요청 코드로 바꾼다 — 화면은 이 queryOptions로만 데이터를 받는다. export const feeQueries = { payments: () => diff --git a/src/shared/entities/fee/types.ts b/src/shared/entities/fee/feeTypes.ts similarity index 100% rename from src/shared/entities/fee/types.ts rename to src/shared/entities/fee/feeTypes.ts diff --git a/src/shared/entities/feedbacks/feedbacksKeys.ts b/src/shared/entities/feedbacks/feedbacksKeys.ts new file mode 100644 index 00000000..f7aeaafc --- /dev/null +++ b/src/shared/entities/feedbacks/feedbacksKeys.ts @@ -0,0 +1,6 @@ +export const feedbacksKeys = { + root: () => ["feedbacks"] as const, + mine: () => [...feedbacksKeys.root(), "mine"] as const, + myDetail: (feedbackId: string) => + [...feedbacksKeys.mine(), feedbackId] as const, +}; diff --git a/src/shared/entities/feedbacks/feedbacksMock.ts b/src/shared/entities/feedbacks/feedbacksMock.ts index 5ca5c306..70c13301 100644 --- a/src/shared/entities/feedbacks/feedbacksMock.ts +++ b/src/shared/entities/feedbacks/feedbacksMock.ts @@ -1,4 +1,7 @@ -import type { Feedback, MyFeedback } from "@/shared/entities/feedbacks/types"; +import type { + Feedback, + MyFeedback, +} from "@/shared/entities/feedbacks/feedbacksTypes"; // 문단은 빈 줄(\n\n)로 나누고, 굵게 강조할 구간은 **텍스트**로 표시한다 — 둘 다 Figma // 원본(nodeId 1410:50009)의 문단 구분·볼드 스팬을 그대로 옮긴 것이다. diff --git a/src/shared/entities/feedbacks/feedbacksApi.ts b/src/shared/entities/feedbacks/feedbacksQueries.ts similarity index 80% rename from src/shared/entities/feedbacks/feedbacksApi.ts rename to src/shared/entities/feedbacks/feedbacksQueries.ts index 0b11dca3..d52a039a 100644 --- a/src/shared/entities/feedbacks/feedbacksApi.ts +++ b/src/shared/entities/feedbacks/feedbacksQueries.ts @@ -1,15 +1,9 @@ import { queryOptions } from "@tanstack/react-query"; import { mockupResponse } from "@/lib/mockupApi"; +import { feedbacksKeys } from "@/shared/entities/feedbacks/feedbacksKeys"; import { MY_FEEDBACKS_MOCK } from "@/shared/entities/feedbacks/feedbacksMock"; -const feedbacksKeys = { - root: () => ["feedbacks"] as const, - mine: () => [...feedbacksKeys.root(), "mine"] as const, - myDetail: (feedbackId: string) => - [...feedbacksKeys.mine(), feedbackId] as const, -}; - // 실 API가 붙으면 queryFn 안쪽만 요청 코드로 바꾼다 — 화면은 이 queryOptions로만 데이터를 받는다. export const feedbacksQueries = { mine: () => diff --git a/src/shared/entities/feedbacks/types.ts b/src/shared/entities/feedbacks/feedbacksTypes.ts similarity index 100% rename from src/shared/entities/feedbacks/types.ts rename to src/shared/entities/feedbacks/feedbacksTypes.ts diff --git a/src/shared/entities/lockers/lockersKeys.ts b/src/shared/entities/lockers/lockersKeys.ts new file mode 100644 index 00000000..f3180eff --- /dev/null +++ b/src/shared/entities/lockers/lockersKeys.ts @@ -0,0 +1,4 @@ +export const lockersKeys = { + root: () => ["lockers"] as const, + myAssignment: () => [...lockersKeys.root(), "assignment"] as const, +}; diff --git a/src/shared/entities/lockers/lockersMock.ts b/src/shared/entities/lockers/lockersMock.ts index 24a0dddd..0a2d22b6 100644 --- a/src/shared/entities/lockers/lockersMock.ts +++ b/src/shared/entities/lockers/lockersMock.ts @@ -1,4 +1,4 @@ -import type { LockerAssignment } from "@/shared/entities/lockers/types"; +import type { LockerAssignment } from "@/shared/entities/lockers/lockersTypes"; // Figma: 사물함 배정 상태 배정완료 (nodeId 3147:148357), 빈 상태 (3147:148331) // Figma는 B-25지만 칸 배치 목데이터가 A-1·A-2만 있어서 위치 보기가 열리도록 A-1구역 칸으로 둔다. diff --git a/src/shared/entities/lockers/lockersApi.ts b/src/shared/entities/lockers/lockersQueries.ts similarity index 79% rename from src/shared/entities/lockers/lockersApi.ts rename to src/shared/entities/lockers/lockersQueries.ts index ae2963b3..9b32da45 100644 --- a/src/shared/entities/lockers/lockersApi.ts +++ b/src/shared/entities/lockers/lockersQueries.ts @@ -1,13 +1,9 @@ import { queryOptions } from "@tanstack/react-query"; import { mockupResponse } from "@/lib/mockupApi"; +import { lockersKeys } from "@/shared/entities/lockers/lockersKeys"; import { LOCKER_ASSIGNMENT_MOCK } from "@/shared/entities/lockers/lockersMock"; -const lockersKeys = { - root: () => ["lockers"] as const, - myAssignment: () => [...lockersKeys.root(), "assignment"] as const, -}; - // 실 API가 붙으면 queryFn 안쪽만 요청 코드로 바꾼다 — 화면은 이 queryOptions로만 데이터를 받는다. export const lockersQueries = { myAssignment: () => diff --git a/src/shared/entities/lockers/types.ts b/src/shared/entities/lockers/lockersTypes.ts similarity index 100% rename from src/shared/entities/lockers/types.ts rename to src/shared/entities/lockers/lockersTypes.ts diff --git a/src/shared/entities/notices/noticesKeys.ts b/src/shared/entities/notices/noticesKeys.ts new file mode 100644 index 00000000..a36bfe2f --- /dev/null +++ b/src/shared/entities/notices/noticesKeys.ts @@ -0,0 +1,6 @@ +export const noticesKeys = { + root: () => ["notices"] as const, + list: () => [...noticesKeys.root(), "list"] as const, + detail: (noticeId: string) => + [...noticesKeys.root(), "detail", noticeId] as const, +}; diff --git a/src/shared/entities/notices/noticesMock.ts b/src/shared/entities/notices/noticesMock.ts index a9d7b068..bb22b2e3 100644 --- a/src/shared/entities/notices/noticesMock.ts +++ b/src/shared/entities/notices/noticesMock.ts @@ -1,4 +1,4 @@ -import type { Notice } from "@/shared/entities/notices/types"; +import type { Notice } from "@/shared/entities/notices/noticesTypes"; // Figma: 공지 상세 (nodeId 1256:81842) 목업 본문 — 목업 데이터라 공지 5건이 본문을 공유한다. const NOTICE_BODY = `안녕하십니까, 제10대 소프트웨어융합대학 학생회 '에코'입니다. diff --git a/src/shared/entities/notices/noticesApi.ts b/src/shared/entities/notices/noticesQueries.ts similarity index 79% rename from src/shared/entities/notices/noticesApi.ts rename to src/shared/entities/notices/noticesQueries.ts index 196ff225..e88528cf 100644 --- a/src/shared/entities/notices/noticesApi.ts +++ b/src/shared/entities/notices/noticesQueries.ts @@ -1,15 +1,9 @@ import { queryOptions } from "@tanstack/react-query"; import { mockupResponse } from "@/lib/mockupApi"; +import { noticesKeys } from "@/shared/entities/notices/noticesKeys"; import { NOTICES } from "@/shared/entities/notices/noticesMock"; -const noticesKeys = { - root: () => ["notices"] as const, - list: () => [...noticesKeys.root(), "list"] as const, - detail: (noticeId: string) => - [...noticesKeys.root(), "detail", noticeId] as const, -}; - // 실 API가 붙으면 queryFn 안쪽만 요청 코드로 바꾼다 — 화면은 이 queryOptions로만 데이터를 받는다. export const noticesQueries = { list: () => diff --git a/src/shared/entities/notices/types.ts b/src/shared/entities/notices/noticesTypes.ts similarity index 100% rename from src/shared/entities/notices/types.ts rename to src/shared/entities/notices/noticesTypes.ts diff --git a/src/shared/entities/notifications/notificationsKeys.ts b/src/shared/entities/notifications/notificationsKeys.ts new file mode 100644 index 00000000..527c1029 --- /dev/null +++ b/src/shared/entities/notifications/notificationsKeys.ts @@ -0,0 +1,4 @@ +export const notificationsKeys = { + root: () => ["notifications"] as const, + list: () => [...notificationsKeys.root(), "list"] as const, +}; diff --git a/src/shared/entities/notifications/notificationsMock.ts b/src/shared/entities/notifications/notificationsMock.ts index ec4e20ff..e38b35e4 100644 --- a/src/shared/entities/notifications/notificationsMock.ts +++ b/src/shared/entities/notifications/notificationsMock.ts @@ -1,4 +1,4 @@ -import type { NotificationItem } from "@/shared/entities/notifications/types"; +import type { NotificationItem } from "@/shared/entities/notifications/notificationsTypes"; // Figma: 알림 (nodeId 3595:107100) 목업 데이터 — 첫 항목만 안 읽은 알림이다. export const NOTIFICATIONS: NotificationItem[] = [ diff --git a/src/shared/entities/notifications/notificationsApi.ts b/src/shared/entities/notifications/notificationsQueries.ts similarity index 78% rename from src/shared/entities/notifications/notificationsApi.ts rename to src/shared/entities/notifications/notificationsQueries.ts index 7c49c1bd..3382eff9 100644 --- a/src/shared/entities/notifications/notificationsApi.ts +++ b/src/shared/entities/notifications/notificationsQueries.ts @@ -1,13 +1,9 @@ import { queryOptions } from "@tanstack/react-query"; import { mockupResponse } from "@/lib/mockupApi"; +import { notificationsKeys } from "@/shared/entities/notifications/notificationsKeys"; import { NOTIFICATIONS } from "@/shared/entities/notifications/notificationsMock"; -const notificationsKeys = { - root: () => ["notifications"] as const, - list: () => [...notificationsKeys.root(), "list"] as const, -}; - // 실 API가 붙으면 queryFn 안쪽만 요청 코드로 바꾼다 — 화면은 이 queryOptions로만 데이터를 받는다. export const notificationsQueries = { list: () => diff --git a/src/shared/entities/notifications/types.ts b/src/shared/entities/notifications/notificationsTypes.ts similarity index 100% rename from src/shared/entities/notifications/types.ts rename to src/shared/entities/notifications/notificationsTypes.ts diff --git a/src/shared/entities/search/searchKeys.ts b/src/shared/entities/search/searchKeys.ts new file mode 100644 index 00000000..acb8f586 --- /dev/null +++ b/src/shared/entities/search/searchKeys.ts @@ -0,0 +1,4 @@ +export const searchKeys = { + root: () => ["search"] as const, + results: (keyword: string) => [...searchKeys.root(), keyword] as const, +}; diff --git a/src/shared/entities/search/searchApi.ts b/src/shared/entities/search/searchQueries.ts similarity index 90% rename from src/shared/entities/search/searchApi.ts rename to src/shared/entities/search/searchQueries.ts index 8214cce6..1c11e317 100644 --- a/src/shared/entities/search/searchApi.ts +++ b/src/shared/entities/search/searchQueries.ts @@ -6,7 +6,8 @@ import { BILILGE_ITEMS } from "@/shared/entities/bililge/bililgeMock"; import { EVENTS } from "@/shared/entities/events/eventsMock"; import { FEEDBACKS } from "@/shared/entities/feedbacks/feedbacksMock"; import { NOTICES } from "@/shared/entities/notices/noticesMock"; -import type { SearchResults } from "@/shared/entities/search/types"; +import { searchKeys } from "@/shared/entities/search/searchKeys"; +import type { SearchResults } from "@/shared/entities/search/searchTypes"; function includesKeyword(text: string, keyword: string): boolean { return text.toLowerCase().includes(keyword); @@ -28,11 +29,6 @@ function searchMocks(keyword: string): SearchResults { }; } -const searchKeys = { - root: () => ["search"] as const, - results: (keyword: string) => [...searchKeys.root(), keyword] as const, -}; - // 실 API가 붙으면 queryFn 안쪽만 요청 코드로 바꾼다(`/api/search`) — 화면은 이 queryOptions로만 데이터를 받는다. // 열린피드백은 아직 entities로 옮겨지지 않아 features의 목데이터를 직접 읽는다. export const searchQueries = { diff --git a/src/shared/entities/search/searchTypes.ts b/src/shared/entities/search/searchTypes.ts new file mode 100644 index 00000000..f366a82d --- /dev/null +++ b/src/shared/entities/search/searchTypes.ts @@ -0,0 +1,14 @@ +import type { ArchiveItem } from "@/shared/entities/archives/archivesTypes"; +import type { BililgeItem } from "@/shared/entities/bililge/bililgeTypes"; +import type { EventItem } from "@/shared/entities/events/eventsTypes"; +import type { Feedback } from "@/shared/entities/feedbacks/feedbacksTypes"; +import type { Notice } from "@/shared/entities/notices/noticesTypes"; + +// 검색 결과는 카테고리별 배열이다 — 키는 용어 사전(terminology.md)의 코드 용어를 그대로 쓴다. +export interface SearchResults { + events: EventItem[]; + notices: Notice[]; + feedbacks: Feedback[]; + bililge: BililgeItem[]; + archives: ArchiveItem[]; +} diff --git a/src/shared/entities/search/types.ts b/src/shared/entities/search/types.ts deleted file mode 100644 index 76706986..00000000 --- a/src/shared/entities/search/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ArchiveItem } from "@/shared/entities/archives/types"; -import type { BililgeItem } from "@/shared/entities/bililge/types"; -import type { EventItem } from "@/shared/entities/events/types"; -import type { Feedback } from "@/shared/entities/feedbacks/types"; -import type { Notice } from "@/shared/entities/notices/types"; - -// 검색 결과는 카테고리별 배열이다 — 키는 용어 사전(terminology.md)의 코드 용어를 그대로 쓴다. -export interface SearchResults { - events: EventItem[]; - notices: Notice[]; - feedbacks: Feedback[]; - bililge: BililgeItem[]; - archives: ArchiveItem[]; -} diff --git a/src/shared/entities/users/usersKeys.ts b/src/shared/entities/users/usersKeys.ts new file mode 100644 index 00000000..caf31634 --- /dev/null +++ b/src/shared/entities/users/usersKeys.ts @@ -0,0 +1,6 @@ +// 앱 버전은 계정과 다른 요청이라 key를 나눈다 — 설정 화면이 둘을 한 카드에 섞어 쓰지 않는다. +export const usersKeys = { + root: () => ["users"] as const, + myAccount: () => [...usersKeys.root(), "me"] as const, + appVersion: () => [...usersKeys.root(), "app-version"] as const, +}; diff --git a/src/shared/entities/users/usersMock.ts b/src/shared/entities/users/usersMock.ts index b573596e..906be807 100644 --- a/src/shared/entities/users/usersMock.ts +++ b/src/shared/entities/users/usersMock.ts @@ -1,4 +1,4 @@ -import type { AppVersion, MyAccount } from "@/shared/entities/users/types"; +import type { AppVersion, MyAccount } from "@/shared/entities/users/usersTypes"; // Figma 설정 화면(3524:152322)에 적힌 값 그대로. 실 API가 붙으면 이 파일은 사라진다. export const MY_ACCOUNT: MyAccount = { diff --git a/src/shared/entities/users/usersApi.ts b/src/shared/entities/users/usersQueries.ts similarity index 75% rename from src/shared/entities/users/usersApi.ts rename to src/shared/entities/users/usersQueries.ts index 4d171a08..5b8541df 100644 --- a/src/shared/entities/users/usersApi.ts +++ b/src/shared/entities/users/usersQueries.ts @@ -1,15 +1,9 @@ import { queryOptions } from "@tanstack/react-query"; import { mockupResponse } from "@/lib/mockupApi"; +import { usersKeys } from "@/shared/entities/users/usersKeys"; import { APP_VERSION, MY_ACCOUNT } from "@/shared/entities/users/usersMock"; -// 앱 버전은 계정과 다른 요청이라 key를 나눈다 — 설정 화면이 둘을 한 카드에 섞어 쓰지 않는다. -const usersKeys = { - root: () => ["users"] as const, - myAccount: () => [...usersKeys.root(), "me"] as const, - appVersion: () => [...usersKeys.root(), "app-version"] as const, -}; - // 실 API가 붙으면 queryFn 안쪽만 요청 코드로 바꾼다 — 화면은 이 queryOptions로만 데이터를 받는다. // // 백엔드 연동 시 확인할 것: 설정 화면은 묶음 이름이고 데이터는 `users` 도메인이라고 보고 diff --git a/src/shared/entities/users/types.ts b/src/shared/entities/users/usersTypes.ts similarity index 100% rename from src/shared/entities/users/types.ts rename to src/shared/entities/users/usersTypes.ts From 7951425eb67476321104b15108003b1910a89b31 Mon Sep 17 00:00:00 2001 From: sangrae Date: Tue, 3 Nov 2026 22:59:34 +0900 Subject: [PATCH 15/22] =?UTF-8?q?docs:=20shared=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EC=99=80=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EA=B7=9C=EC=B9=99=EC=9D=84=20=EC=BB=A8?= =?UTF-8?q?=EB=B2=A4=EC=85=98=EC=97=90=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/conventions/coding-style.md | 14 ++++++++------ docs/conventions/wds-component-usage.md | 6 +++--- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index 2cc1c873..5d8bc8d3 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -21,7 +21,8 @@ ## 폴더 구조 - 기능 기반(feature-based) 구조를 쓴다. `app/`(라우팅 껍데기)·`components/ui/`(공용 UI)·`hooks/`·`lib/`·`utils/`·`constants/`는 실제로 내용이 생길 때 만든다 — 빈 폴더를 미리 만들지 않는다. -- 화면 단위 기능이 생기면 `features/<기능>/`, 여러 화면이 공유하는 서버 계약(API·쿼리·타입)이 생기면 `entities/<도메인>/`을 그때 도입한다. +- 화면 단위 기능이 생기면 `features/<기능>/`, 여러 화면이 공유하는 서버 계약(API·쿼리·타입)이 생기면 `shared/entities/<도메인>/`을 그때 도입한다. +- 서버와 주고받는 코드는 `shared/`에 둔다 — 요청 클라이언트는 `shared/api/`, 도메인 조회는 `shared/entities/<도메인>/`. `lib/`에는 서버와 상관없는 도구(`queryClient`·날짜 포맷·앱 브리지 등)만 둔다. - import는 절대경로 `@/`(`./src/*`)를 쓴다. 상대경로는 같은 기능 폴더 내부에서만. - 그룹 순서: ① 외부 라이브러리 → ② `@/` → ③ 상대경로. 저장 시 Biome의 `organizeImports`가 자동 정렬한다. @@ -49,13 +50,14 @@ ## 데이터 로딩 -- 서버 데이터는 TanStack Query로 받는다. 조회 정의와 타입은 `entities/<도메인>/`에 둔다(`<도메인>Api.ts`·`types.ts`). API 파일은 `<도메인>Queries` 객체에 조회마다 `queryOptions`를 돌려주는 함수를 두고(`eventsQueries.detail(id)`), 화면은 데이터 컴포넌트에서 `const { data } = useSuspenseQuery(eventsQueries.detail(id))`로 읽는다. 화면 모양으로 바꿔야 하면 화면 쪽에서 `select`를 더한다 — 캐시에는 응답 그대로 둔다. -- 조회 key는 API 파일 안 key 함수 하나(`eventsKeys`)로 만든다. 도메인 이름으로 시작하는 배열을 쌓아서(`root()` → `["events"]`, `application(id)` → `["events", "applications", id]`) 앞부분 key 하나로 아래 조회를 함께 무효화할 수 있게 한다. 조회와 무효화가 같은 key 함수를 써야 한쪽만 바뀌어 무효화가 조용히 빗나가지 않는다. -- 요청은 `lib/api/client.ts`의 `api.get(url, { signal })`·`api.post(url, body)` 등으로 보낸다. 서버 응답 봉투(`{ success, code, message, data }`)를 벗겨 `data`만 돌려주고, 실패는 `ApiError`(`status`·`code`·`message`)·`NetworkError`·`TimeoutError`로 던진다(`lib/api/errors.ts`). 화면은 `api`를 직접 부르지 않고 entities의 `queryOptions`·mutation 훅만 쓴다. -- 인증 토큰은 `lib/api/token.ts`로만 읽고 쓴다. 요청마다 `Authorization: Bearer`가 붙고, 서버가 401을 주면 토큰을 지운 뒤 `subscribeTokens`로 알린다 — 로그인 화면 이동은 알림을 구독하는 가드가 맡는다(요청 코드가 라우터를 import하면 router → 화면 → entities → client 순환이 생긴다). 가드는 로그인 실연결 때 붙인다. +- 서버 데이터는 TanStack Query로 받는다. 조회 정의와 타입은 `shared/entities/<도메인>/`에 역할별 파일로 둔다 — `<도메인>Keys.ts`(조회 key)·`<도메인>Queries.ts`(조회·mutation)·`<도메인>Types.ts`(타입)·`<도메인>Mock.ts`(목데이터). 파일 이름은 레포의 다른 파일처럼 camelCase다. 조회 파일은 `<도메인>Queries` 객체에 조회마다 `queryOptions`를 돌려주는 함수를 두고(`eventsQueries.detail(id)`), 화면은 데이터 컴포넌트에서 `const { data } = useSuspenseQuery(eventsQueries.detail(id))`로 읽는다. 서버 응답과 화면이 쓰는 모양(필드 이름·날짜 형식 등)이 다르면 `queryFn` 안에서 화면 모양으로 바꿔 돌려준다(`toLockersSection(response)` 같은 변환 함수) — 화면마다 `select`를 달지 않고 한 곳에서 바꾼다. +- 조회 key는 `<도메인>Keys.ts`의 key 함수 하나(`eventsKeys`)로 만든다. key만 따로 둬서 다른 도메인이 캐시를 무효화할 때 조회 파일 전체를 가져오지 않게 한다. 도메인 이름으로 시작하는 배열을 쌓아서(`root()` → `["events"]`, `application(id)` → `["events", "applications", id]`) 앞부분 key 하나로 아래 조회를 함께 무효화할 수 있게 한다. 조회와 무효화가 같은 key 함수를 써야 한쪽만 바뀌어 무효화가 조용히 빗나가지 않는다. +- 요청은 `@/shared/api`의 `api.get(url, { signal })`·`api.post(url, body)` 등으로 보낸다. 서버 응답 봉투(`{ success, code, message, data }`)를 벗겨 `data`만 돌려주고, 실패는 `ApiError`(`status`·`code`·`message`)·`NetworkError`·`TimeoutError`로 던진다(`shared/api/errors.ts`). `shared/api` 밖에서는 `@/shared/api`(`index.ts`)로만 가져온다. 화면은 `api`를 직접 부르지 않고 entities의 `queryOptions`·mutation 훅만 쓴다. +- 인증 토큰은 `shared/api/token.ts`로만 읽고 쓴다. 요청마다 `Authorization: Bearer`가 붙고, 서버가 401을 주면 토큰을 지운 뒤 `subscribeTokens`로 알린다 — 로그인 화면 이동은 알림을 구독하는 가드가 맡는다(요청 코드가 라우터를 import하면 router → 화면 → entities → client 순환이 생긴다). 가드는 로그인 실연결 때 붙인다. - 조회 기본값은 `lib/queryClient.ts`에 있다: 1분 안에 다시 들어온 화면은 요청 없이 캐시를 쓰고(`staleTime`), 실패는 네트워크·5xx만 한 번 더 시도한다(4xx는 다시 보내도 같다). 도메인마다 다르게 해야 하면 그 `queryOptions`에 덮어쓴다. - 실 API 전까지 `queryFn`은 `<도메인>Mock.ts`의 목데이터를 `mockupResponse(() => data)`(`lib/mockupApi.ts`)로 돌려준다. 개발 서버에서는 스켈레톤을 확인할 수 있게 500ms 늦게 응답하고, 배포 빌드에서는 바로 응답한다. API가 붙으면 `queryFn` 안쪽만 `api.get(...)`으로 바꾼다. -- 데이터를 바꾸는 요청(신청 취소 등)은 API 파일에 `use<동작>Mutation` 훅(`useCancelEventApplicationMutation`)으로 두고, `onSuccess`에서 바뀐 조회 key를 `invalidateQueries`로 무효화한다(화면은 무효화를 모른다). 무효화 Promise를 돌려줘서 다시 받은 데이터가 들어온 뒤에 mutation이 끝나게 한다. 지금 화면에 없는데 돌아갔을 때 바로 보여야 하는 조회(신청내역 목록 등)는 `refetchQueries({ queryKey, exact: true })`로 그것만 같이 받는다 — `refetchType: "all"`은 열어 봤던 상세까지 전부 다시 받는다. 데이터가 이미 있는 조회는 다시 받는 동안 서스펜드되지 않고 지금 화면을 유지한다. +- 서버 응답 검사(zod 스키마)는 실 API를 붙이는 도메인부터 `<도메인>Queries.ts`에 넣는다. 목데이터는 서버 응답이 아니라 화면 모양이라, 미리 만든 스키마는 서버와 다른 계약이 된다. +- 데이터를 바꾸는 요청(신청 취소 등)은 조회 파일에 `use<동작>Mutation` 훅(`useCancelEventApplicationMutation`)으로 두고, `onSuccess`에서 바뀐 조회 key를 `invalidateQueries`로 무효화한다(화면은 무효화를 모른다). 무효화 Promise를 돌려줘서 다시 받은 데이터가 들어온 뒤에 mutation이 끝나게 한다. 지금 화면에 없는데 돌아갔을 때 바로 보여야 하는 조회(신청내역 목록 등)는 `refetchQueries({ queryKey, exact: true })`로 그것만 같이 받는다 — `refetchType: "all"`은 열어 봤던 상세까지 전부 다시 받는다. 데이터가 이미 있는 조회는 다시 받는 동안 서스펜드되지 않고 지금 화면을 유지한다. - 화면(`<화면>Screen.tsx`)은 헤더 등록, UI 상태, 이동 같은 동작을 맡고, 데이터를 받는 영역만 `}>`으로 감싼다. `useSuspenseQuery`로 데이터를 읽고 그리는 부분은 `features/<기능>/components/`의 컴포넌트(`EventsList`, `EventsDetailContent` 등)로 분리하고, 이동 같은 동작은 콜백 prop으로 받는다. - 데이터와 무관한 헤더·탭·필터는 Suspense 밖에서 바로 그리고, 스켈레톤은 데이터 영역의 배치만 따라 그린다. 전용 스켈레톤은 `features/<기능>/components/<화면>Skeleton.tsx`에 두고 WDS `Skeleton`으로 그린다. 헤더가 데이터에 따라 달라지는 화면(공지 상세)은 헤더도 데이터 컴포넌트가 등록하고, 스켈레톤이 `useScreenHeaderSkeleton`으로 헤더 자리를 채운다. diff --git a/docs/conventions/wds-component-usage.md b/docs/conventions/wds-component-usage.md index f86dbd89..d63835cf 100644 --- a/docs/conventions/wds-component-usage.md +++ b/docs/conventions/wds-component-usage.md @@ -709,7 +709,7 @@ Figma 초기 상태가 전체 꺼짐 + 빌릴게만 켜짐이라 "전체"는 독 ## 스플래시·로그인·온보딩(`3658:112096` / `3738:73115`, 화면설계서 `3747:74283` / `3747:74298`) 구현 중 확정된 매핑 -코드는 `src/features/auth/`, `src/entities/auth/` 참고. +코드는 `src/features/auth/`, `src/shared/entities/auth/` 참고. | Figma | 코드 | 확인 내용 | |---|---|---| @@ -739,11 +739,11 @@ Figma 프레임의 상태 표시줄(54px)과 홈 바(34px)는 앱 셸 몫이라 ### K-CONNECT 로그인은 `@billilge/oauth-kconnect`(웹용 `/react`)로 한다 -`K-CONNECT로 시작하기`는 `useKConnectLogin({ clientId }).login()`으로 K-CONNECT 인증 사이트로 페이지를 이동시키고(PKCE, `state`는 패키지가 sessionStorage에 보관), 인증 후 K-CONNECT가 되돌려 보내는 `/oauth/kconnect`(`KConnectCallbackScreen`)가 `useKConnectCallback()`으로 `{ code, codeVerifier, redirectUri }`를 받는다. 이 경로는 K-CONNECT에 등록한 redirect URI이자 패키지가 정한 값이라 바꾸지 않는다. 받은 값은 서버(`POST /auth/kconnect`)로 보내 서비스 로그인을 마치는데, 서버 주소·응답 형식이 아직 없어서 `entities/auth/authApi.ts`의 `exchangeKConnectCode`가 응답만 흉내 낸다. 신규 사용자는 `/login`으로 이동하면서 이름을 navigate state로 넘겨 로그인 퍼널이 `initial`(주소에 단계가 없을 때만 쓰임)로 연락처 단계에서 시작하고, 기존 사용자는 홈으로 간다. 취소·오류(국민대 학생이 아니어서 거부된 경우 포함)나 서버 실패는 `/login`으로 돌아와 실패 토스트를 띄운다. 어느 경우든 주소창에 `code`가 남지 않도록 `replace`로 이동한다. +`K-CONNECT로 시작하기`는 `useKConnectLogin({ clientId }).login()`으로 K-CONNECT 인증 사이트로 페이지를 이동시키고(PKCE, `state`는 패키지가 sessionStorage에 보관), 인증 후 K-CONNECT가 되돌려 보내는 `/oauth/kconnect`(`KConnectCallbackScreen`)가 `useKConnectCallback()`으로 `{ code, codeVerifier, redirectUri }`를 받는다. 이 경로는 K-CONNECT에 등록한 redirect URI이자 패키지가 정한 값이라 바꾸지 않는다. 받은 값은 서버(`POST /auth/kconnect`)로 보내 서비스 로그인을 마치는데, 서버 주소·응답 형식이 아직 없어서 `shared/entities/auth/authQueries.ts`의 `exchangeKConnectCode`가 응답만 흉내 낸다. 신규 사용자는 `/login`으로 이동하면서 이름을 navigate state로 넘겨 로그인 퍼널이 `initial`(주소에 단계가 없을 때만 쓰임)로 연락처 단계에서 시작하고, 기존 사용자는 홈으로 간다. 취소·오류(국민대 학생이 아니어서 거부된 경우 포함)나 서버 실패는 `/login`으로 돌아와 실패 토스트를 띄운다. 어느 경우든 주소창에 `code`가 남지 않도록 `replace`로 이동한다. - **설정:** `client_id`는 환경 변수 `VITE_KCONNECT_CLIENT_ID`(`.env.example` 참고)로 받는다. K-CONNECT의 "허가된 Redirect URI"에 `http://localhost:5173/oauth/kconnect`(개발)와 배포 주소의 `/oauth/kconnect`가 등록돼 있어야 한다 — 폰으로 `192.168.x.x:5173`처럼 접속하면 등록되지 않은 주소라 로그인이 거부된다. - **설치:** 패키지는 GitHub Packages 비공개 배포라 `.npmrc`에 `@billilge:registry`가 필요하고 `read:packages` 토큰이 있어야 한다(토큰은 파일에 쓰지 않고 환경 변수·사용자 npmrc로 둔다). 패키지가 Expo용 `expo-auth-session`을 optional peer로 선언하는데 pnpm `autoInstallPeers`가 설치까지 해서 웹에 필요 없는 Expo 의존성 수백 개가 딸려 오므로, `pnpm-workspace.yaml`의 `overrides`로 뺐다. -- **로그인 표시 지우기:** 로그아웃·회원 탈퇴처럼 계정을 떠나는 흐름은 `entities/auth/session.ts`의 `clearLoggedIn()`을 불러야 한다. 지우지 않으면 `AuthEntryGate`를 그대로 통과해서 방금 떠난 계정으로 화면이 열린다. 설정 화면의 로그아웃·회원 탈퇴는 `useAccountExit` 훅이 이 함수를 부르고 로그인 화면으로 보낸다. +- **로그인 표시 지우기:** 로그아웃·회원 탈퇴처럼 계정을 떠나는 흐름은 `shared/entities/auth/session.ts`의 `clearLoggedIn()`을 불러야 한다. 지우지 않으면 `AuthEntryGate`를 그대로 통과해서 방금 떠난 계정으로 화면이 열린다. 설정 화면의 로그아웃·회원 탈퇴는 `useAccountExit` 훅이 이 함수를 부르고 로그인 화면으로 보낸다. - **연락처는 010 고정:** 가입 연락처는 `010`으로 시작하는 숫자 11자리여야 `다음`이 켜진다(`isValidPhoneDigits`). 설정의 전화번호 변경(#97)과 같은 기준이라, 구 번호(011·016 등)로 가입한 뒤 설정에서 같은 번호로 바꾸지 못하는 일이 없다. - **로그인 가드는 모든 화면에 걸린다:** `AuthEntryGate`가 레이아웃 라우트(`ScreenLayoutRoute`)를 감싸서, 이 탭에 로그인 표시가 없으면 `/login`으로 `replace`한다(모르는 경로 `*`도 마찬가지). 로그인 없이 열려야 하는 화면은 라우트 `handle`에 `isPublic: true`(`AuthRouteHandle`)로 표시하며 지금은 `/login`과 `/oauth/kconnect` 둘이다. 표시가 없으면 보호 화면으로 보므로 새 화면을 추가해도 가드가 자동으로 걸린다. 로그인한 상태로 `/login`에 오면 퍼널이 홈(`/home`)으로 보낸다. 홈은 `/home`이고 `/`는 `/home`으로 리다이렉트한다(앱 WebView 진입 주소 호환). 이 가드는 화면 이동을 정리할 뿐이고, 표시는 클라이언트가 바꿀 수 있어서 실제 인증은 서버 API가 검증해야 한다. 로그인 후 원래 가려던 화면으로 돌아가는 동작은 없고 항상 홈으로 간다. - **K-CONNECT 인증 우회(개발 서버 전용):** `/login?mockKConnect=new`(신규 → 온보딩) 또는 `?mockKConnect=existing`(기존 → 홈)으로 열고 `K-CONNECT로 시작하기`를 누르면 인증 사이트로 가지 않고 인증된 것으로 친다. `import.meta.env.DEV`로 가려서 배포 빌드에는 들어가지 않는다(번들에 `mockKConnect`가 없음을 확인). From c6874ebf7e13b6d592de4891758410cca7d43b9e Mon Sep 17 00:00:00 2001 From: sangrae Date: Tue, 3 Nov 2026 23:29:25 +0900 Subject: [PATCH 16/22] =?UTF-8?q?docs:=20=EC=9D=91=EB=8B=B5=20=EB=B3=80?= =?UTF-8?q?=ED=99=98=EC=9D=80=20=ED=99=94=EB=A9=B4=EC=9D=98=20select?= =?UTF-8?q?=EB=A1=9C=20=ED=95=98=EB=8A=94=20=EA=B8=B0=EC=A1=B4=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/conventions/coding-style.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index 5d8bc8d3..0c9f6a92 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -50,7 +50,7 @@ ## 데이터 로딩 -- 서버 데이터는 TanStack Query로 받는다. 조회 정의와 타입은 `shared/entities/<도메인>/`에 역할별 파일로 둔다 — `<도메인>Keys.ts`(조회 key)·`<도메인>Queries.ts`(조회·mutation)·`<도메인>Types.ts`(타입)·`<도메인>Mock.ts`(목데이터). 파일 이름은 레포의 다른 파일처럼 camelCase다. 조회 파일은 `<도메인>Queries` 객체에 조회마다 `queryOptions`를 돌려주는 함수를 두고(`eventsQueries.detail(id)`), 화면은 데이터 컴포넌트에서 `const { data } = useSuspenseQuery(eventsQueries.detail(id))`로 읽는다. 서버 응답과 화면이 쓰는 모양(필드 이름·날짜 형식 등)이 다르면 `queryFn` 안에서 화면 모양으로 바꿔 돌려준다(`toLockersSection(response)` 같은 변환 함수) — 화면마다 `select`를 달지 않고 한 곳에서 바꾼다. +- 서버 데이터는 TanStack Query로 받는다. 조회 정의와 타입은 `shared/entities/<도메인>/`에 역할별 파일로 둔다 — `<도메인>Keys.ts`(조회 key)·`<도메인>Queries.ts`(조회·mutation)·`<도메인>Types.ts`(타입)·`<도메인>Mock.ts`(목데이터). 파일 이름은 레포의 다른 파일처럼 camelCase다. 조회 파일은 `<도메인>Queries` 객체에 조회마다 `queryOptions`를 돌려주는 함수를 두고(`eventsQueries.detail(id)`), 화면은 데이터 컴포넌트에서 `const { data } = useSuspenseQuery(eventsQueries.detail(id))`로 읽는다. 화면 모양으로 바꿔야 하면 화면 쪽에서 `select`를 더한다 — 캐시에는 응답 그대로 둔다. 같은 데이터를 여러 화면이 다른 모양으로 쓰고(내 납부 상태 → 학생회비 화면·행사 상세), 캐시가 Swagger 응답과 같아야 `setQueryData`·디버깅이 단순하다. 여러 화면이 같은 변환을 쓰면 변환 함수를 `shared/entities/<도메인>/`에 하나 두고 `select`로 넘긴다. - 조회 key는 `<도메인>Keys.ts`의 key 함수 하나(`eventsKeys`)로 만든다. key만 따로 둬서 다른 도메인이 캐시를 무효화할 때 조회 파일 전체를 가져오지 않게 한다. 도메인 이름으로 시작하는 배열을 쌓아서(`root()` → `["events"]`, `application(id)` → `["events", "applications", id]`) 앞부분 key 하나로 아래 조회를 함께 무효화할 수 있게 한다. 조회와 무효화가 같은 key 함수를 써야 한쪽만 바뀌어 무효화가 조용히 빗나가지 않는다. - 요청은 `@/shared/api`의 `api.get(url, { signal })`·`api.post(url, body)` 등으로 보낸다. 서버 응답 봉투(`{ success, code, message, data }`)를 벗겨 `data`만 돌려주고, 실패는 `ApiError`(`status`·`code`·`message`)·`NetworkError`·`TimeoutError`로 던진다(`shared/api/errors.ts`). `shared/api` 밖에서는 `@/shared/api`(`index.ts`)로만 가져온다. 화면은 `api`를 직접 부르지 않고 entities의 `queryOptions`·mutation 훅만 쓴다. - 인증 토큰은 `shared/api/token.ts`로만 읽고 쓴다. 요청마다 `Authorization: Bearer`가 붙고, 서버가 401을 주면 토큰을 지운 뒤 `subscribeTokens`로 알린다 — 로그인 화면 이동은 알림을 구독하는 가드가 맡는다(요청 코드가 라우터를 import하면 router → 화면 → entities → client 순환이 생긴다). 가드는 로그인 실연결 때 붙인다. From 9f85085d5bbe4ced2e6db7f0bd2046c906508f28 Mon Sep 17 00:00:00 2001 From: sangrae Date: Wed, 4 Nov 2026 00:07:30 +0900 Subject: [PATCH 17/22] =?UTF-8?q?refactor:=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20=E2=80=94=20apiTypes=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=20=ED=86=B5=EC=9D=BC,=20lib=20=EC=84=A4=EB=AA=85=C2=B7?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/conventions/coding-style.md | 2 +- src/features/home/constants/homeMock.ts | 8 ++++---- src/features/lockers/components/LockersMineContent.tsx | 2 +- src/features/settings/constants/settings.ts | 2 +- src/shared/api/{api.types.ts => apiTypes.ts} | 0 src/shared/api/client.ts | 2 +- src/shared/api/errors.ts | 2 +- src/shared/api/index.ts | 2 +- 8 files changed, 10 insertions(+), 10 deletions(-) rename src/shared/api/{api.types.ts => apiTypes.ts} (100%) diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index 0c9f6a92..57b3762b 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -22,7 +22,7 @@ - 기능 기반(feature-based) 구조를 쓴다. `app/`(라우팅 껍데기)·`components/ui/`(공용 UI)·`hooks/`·`lib/`·`utils/`·`constants/`는 실제로 내용이 생길 때 만든다 — 빈 폴더를 미리 만들지 않는다. - 화면 단위 기능이 생기면 `features/<기능>/`, 여러 화면이 공유하는 서버 계약(API·쿼리·타입)이 생기면 `shared/entities/<도메인>/`을 그때 도입한다. -- 서버와 주고받는 코드는 `shared/`에 둔다 — 요청 클라이언트는 `shared/api/`, 도메인 조회는 `shared/entities/<도메인>/`. `lib/`에는 서버와 상관없는 도구(`queryClient`·날짜 포맷·앱 브리지 등)만 둔다. +- 서버와 주고받는 코드는 `shared/`에 둔다 — 요청 클라이언트는 `shared/api/`, 도메인 조회는 `shared/entities/<도메인>/`. `lib/`에는 요청을 직접 보내지 않는 도구(`queryClient`·날짜 포맷·앱 브리지 등)만 둔다. - import는 절대경로 `@/`(`./src/*`)를 쓴다. 상대경로는 같은 기능 폴더 내부에서만. - 그룹 순서: ① 외부 라이브러리 → ② `@/` → ③ 상대경로. 저장 시 Biome의 `organizeImports`가 자동 정렬한다. diff --git a/src/features/home/constants/homeMock.ts b/src/features/home/constants/homeMock.ts index 90bbc372..87ed9995 100644 --- a/src/features/home/constants/homeMock.ts +++ b/src/features/home/constants/homeMock.ts @@ -40,7 +40,7 @@ export interface HomeRental { hoursUntilDue: number; } -// applicationId는 신청내역 목데이터(entities/events)의 신청완료 건으로 맞춘다. 홈 행 이름(Figma 홈 문구)과 +// applicationId는 신청내역 목데이터(shared/entities/events)의 신청완료 건으로 맞춘다. 홈 행 이름(Figma 홈 문구)과 // 신청내역의 행사명은 목데이터라 서로 다르다. export interface HomeAppliedEvent { id: string; @@ -50,7 +50,7 @@ export interface HomeAppliedEvent { daysUntilEvent: number; } -// 납부내역의 상태(entities/fee)에 홈에서만 보이는 미납을 더한다 +// 납부내역의 상태(shared/entities/fee)에 홈에서만 보이는 미납을 더한다 export type HomeFeeStatus = FeePaymentStatus | "unpaid"; export interface HomeMyInfo { @@ -68,7 +68,7 @@ export interface HomeData { myInfo: HomeMyInfo; } -// noticeId는 공지 목데이터(entities/notices, 1~5번)에 있는 공지로 맞춘다. 배너 문구와 공지 내용은 +// noticeId는 공지 목데이터(shared/entities/notices, 1~5번)에 있는 공지로 맞춘다. 배너 문구와 공지 내용은 // 목데이터라 서로 다르다. 배너가 6개라 마지막 배너는 1번 공지를 연다. const NOTICES: HomeNotice[] = [ { @@ -115,7 +115,7 @@ const NOTICES: HomeNotice[] = [ }, ]; -// 행사 목데이터(entities/events)의 모집중 행사 +// 행사 목데이터(shared/entities/events)의 모집중 행사 const SPORTS_DAY: HomeApplyCard = { daysUntilDeadline: 2, eventId: "sw-sports-day-open", diff --git a/src/features/lockers/components/LockersMineContent.tsx b/src/features/lockers/components/LockersMineContent.tsx index c86d066f..c4457fcf 100644 --- a/src/features/lockers/components/LockersMineContent.tsx +++ b/src/features/lockers/components/LockersMineContent.tsx @@ -18,7 +18,7 @@ interface LockersMineContentProps { } // 배정된 구역의 칸 배치를 보기 전용으로 그리고 내 칸만 표시한다. -// 내 칸은 배정 정보(entities/lockers)의 칸 이름 하나로 정한다 — 구역 칸 데이터의 isMine에 기대지 않고, +// 내 칸은 배정 정보(shared/entities/lockers)의 칸 이름 하나로 정한다 — 구역 칸 데이터의 isMine에 기대지 않고, // 그 칸을 선택된 칸으로 넘겨 선택 모양(파란 배경)으로 그린다. function LockersMineContent({ fallbackPath, diff --git a/src/features/settings/constants/settings.ts b/src/features/settings/constants/settings.ts index 3b86e1cd..afaa3ee0 100644 --- a/src/features/settings/constants/settings.ts +++ b/src/features/settings/constants/settings.ts @@ -6,7 +6,7 @@ import { LOGIN_PATH } from "@/features/auth/constants/auth"; // // 백엔드 연동 시 확인할 것: `settings`는 용어 사전(terminology.md)에 없는 이름이다. 화면 묶음을 // 가리키는 말이라 폴더·라우트에만 쓰고, 데이터는 사전에 있는 `users` 도메인으로 뒀다 -// (`entities/users/`). 백엔드가 설정 전용 엔드포인트를 따로 내면 그때 사전에 추가하고 여기를 +// (`shared/entities/users/`). 백엔드가 설정 전용 엔드포인트를 따로 내면 그때 사전에 추가하고 여기를 // 맞춘다 — 프론트에서 먼저 정하지 않는다(terminology.md 원칙). export const SETTINGS_PATHS = { phoneChange: "/settings/phone", diff --git a/src/shared/api/api.types.ts b/src/shared/api/apiTypes.ts similarity index 100% rename from src/shared/api/api.types.ts rename to src/shared/api/apiTypes.ts diff --git a/src/shared/api/client.ts b/src/shared/api/client.ts index b003c266..1fbc9cba 100644 --- a/src/shared/api/client.ts +++ b/src/shared/api/client.ts @@ -1,6 +1,6 @@ import axios, { type AxiosRequestConfig, type AxiosResponse } from "axios"; -import type { ApiResponse } from "@/shared/api/api.types"; +import type { ApiResponse } from "@/shared/api/apiTypes"; import { ApiError, NetworkError, TimeoutError } from "@/shared/api/errors"; import { clearAccessToken, getAccessToken } from "@/shared/api/token"; diff --git a/src/shared/api/errors.ts b/src/shared/api/errors.ts index 47e8291d..b5965706 100644 --- a/src/shared/api/errors.ts +++ b/src/shared/api/errors.ts @@ -1,4 +1,4 @@ -import type { ApiResponse } from "@/shared/api/api.types"; +import type { ApiResponse } from "@/shared/api/apiTypes"; // 서버까지 닿지 못한 요청(오프라인·DNS·CORS 차단 등) export class NetworkError extends Error { diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts index 5c820ed6..840d394b 100644 --- a/src/shared/api/index.ts +++ b/src/shared/api/index.ts @@ -3,7 +3,7 @@ export type { ApiResponse, CursorSliceResponse, PageResponse, -} from "@/shared/api/api.types"; +} from "@/shared/api/apiTypes"; export { api } from "@/shared/api/client"; export { ApiError, From b1ad775ce9b4aab684e3fa172e33437ac1863274 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:36:23 +0900 Subject: [PATCH 18/22] =?UTF-8?q?refactor:=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=BD=94=EB=93=9C=EB=A5=BC=20sha?= =?UTF-8?q?red/entities/files=EB=A1=9C=20=EC=98=AE=EA=B8=B0=EA=B3=A0=20?= =?UTF-8?q?=ED=9A=8C=EB=B9=84=20=ED=8D=BC=EB=84=90=20import=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/fee/FeeTransferScreen.tsx | 14 +++++++------- .../entities/files/filesQueries.ts} | 7 +++++-- .../entities/files/filesTypes.ts} | 0 3 files changed, 12 insertions(+), 9 deletions(-) rename src/{entities/files/filesApi.ts => shared/entities/files/filesQueries.ts} (89%) rename src/{entities/files/types.ts => shared/entities/files/filesTypes.ts} (100%) diff --git a/src/features/fee/FeeTransferScreen.tsx b/src/features/fee/FeeTransferScreen.tsx index 608bddaf..46d8e830 100644 --- a/src/features/fee/FeeTransferScreen.tsx +++ b/src/features/fee/FeeTransferScreen.tsx @@ -3,12 +3,6 @@ import { Suspense, useRef, useState } from "react"; import { Navigate } from "react-router-dom"; import ScreenToast from "@/components/ui/ScreenToast"; -import { - FEE_ERROR_CODES, - feeQueries, - useSubmitFeeMutation, -} from "@/entities/fee/feeApi"; -import { useUploadFileMutation } from "@/entities/files/filesApi"; import FeeTransferSkeleton from "@/features/fee/components/FeeTransferSkeleton"; import { FEE_EXTERNAL_LINKS, @@ -26,9 +20,15 @@ import FeeTransferTossStep from "@/features/fee/steps/FeeTransferTossStep"; import FeeTransferTranscriptStep from "@/features/fee/steps/FeeTransferTranscriptStep"; import { HOME_PATH } from "@/features/home/constants/home"; import { useBackNavigation } from "@/hooks/useBackNavigation"; -import { isApiError } from "@/lib/api/errors"; import { openExternalLink } from "@/lib/openExternalLink"; import { useFunnel, useFunnelExit } from "@/lib/useFunnel"; +import { isApiError } from "@/shared/api"; +import { + FEE_ERROR_CODES, + feeQueries, + useSubmitFeeMutation, +} from "@/shared/entities/fee/feeQueries"; +import { useUploadFileMutation } from "@/shared/entities/files/filesQueries"; // 학생회비 계좌 송금(남은 학기 수 → 학점이수현황 캡처 → 토스 이동 → 송금 확인 → 확인요청 완료)을 // useFunnel 하나로 묶은 화면이다. 단계 화면은 `steps/`에 하나씩 나눠 두고, 이 파일은 단계 전환 diff --git a/src/entities/files/filesApi.ts b/src/shared/entities/files/filesQueries.ts similarity index 89% rename from src/entities/files/filesApi.ts rename to src/shared/entities/files/filesQueries.ts index e00a8d46..c9fe6792 100644 --- a/src/entities/files/filesApi.ts +++ b/src/shared/entities/files/filesQueries.ts @@ -1,8 +1,11 @@ import { useMutation } from "@tanstack/react-query"; import axios from "axios"; -import type { FileCategory, FileUploadUrl } from "@/entities/files/types"; -import { api } from "@/lib/api/client"; +import { api } from "@/shared/api"; +import type { + FileCategory, + FileUploadUrl, +} from "@/shared/entities/files/filesTypes"; interface UploadFileVariables { file: File; diff --git a/src/entities/files/types.ts b/src/shared/entities/files/filesTypes.ts similarity index 100% rename from src/entities/files/types.ts rename to src/shared/entities/files/filesTypes.ts From e4438855bd59ff4f4c33d1d950aad757d46d3605 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:10:26 +0900 Subject: [PATCH 19/22] =?UTF-8?q?docs:=20=ED=9A=8C=EB=B9=84=EA=B0=80=20?= =?UTF-8?q?=EC=B2=98=EC=9D=8C=20=EC=8B=A4=20API=EB=A5=BC=20=EB=B6=99?= =?UTF-8?q?=EC=9D=B8=20=EB=8F=84=EB=A9=94=EC=9D=B8=EC=9D=B4=EB=9D=BC?= =?UTF-8?q?=EB=8A=94=20=EB=AC=B8=EA=B5=AC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/conventions/coding-style.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index 5d8d9916..0997728b 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -56,7 +56,7 @@ - 파일은 `shared/entities/files/filesQueries.ts`의 `useUploadFileMutation`으로 올린다. 서버에서 업로드 URL을 받고(`POST /v1/files/presigned-url`, 용도는 `category`) 그 주소로 파일을 바로 PUT한 뒤, 다른 API에 넘길 파일 키를 돌려준다. 이 PUT만 `apiClient`를 거치지 않는다 — 스토리지 주소라 baseURL·인증 헤더가 붙으면 안 되고 응답도 서버 봉투가 아니다. 서버는 형식을 MIME이 아니라 파일 이름의 확장자로, 크기는 용도별 상한으로 거절하므로 고를 때 먼저 걸러 둔다(학생회비 캡처 `isAcceptableTranscript`). - 인증 토큰은 `shared/api/token.ts`로만 읽고 쓴다. 요청마다 `Authorization: Bearer`가 붙고, 서버가 401을 주면 토큰을 지운 뒤 `subscribeTokens`로 알린다 — 로그인 화면 이동은 알림을 구독하는 가드가 맡는다(요청 코드가 라우터를 import하면 router → 화면 → entities → client 순환이 생긴다). 가드는 로그인 실연결 때 붙인다. - 조회 기본값은 `lib/queryClient.ts`에 있다: 1분 안에 다시 들어온 화면은 요청 없이 캐시를 쓰고(`staleTime`), 실패는 네트워크·5xx만 한 번 더 시도한다(4xx는 다시 보내도 같다). 도메인마다 다르게 해야 하면 그 `queryOptions`에 덮어쓴다. -- 실 API 전까지 `queryFn`은 `<도메인>Mock.ts`의 목데이터를 `mockupResponse(() => data)`(`lib/mockupApi.ts`)로 돌려준다. 개발 서버에서는 스켈레톤을 확인할 수 있게 500ms 늦게 응답하고, 배포 빌드에서는 바로 응답한다. API가 붙으면 `queryFn` 안쪽만 `api.get(...)`으로 바꾼다 — 회비(`shared/entities/fee`)가 처음 붙인 도메인이다. +- 실 API 전까지 `queryFn`은 `<도메인>Mock.ts`의 목데이터를 `mockupResponse(() => data)`(`lib/mockupApi.ts`)로 돌려준다. 개발 서버에서는 스켈레톤을 확인할 수 있게 500ms 늦게 응답하고, 배포 빌드에서는 바로 응답한다. API가 붙으면 `queryFn` 안쪽만 `api.get(...)`으로 바꾼다. - 서버 응답 검사(zod 스키마)는 실 API를 붙이는 도메인부터 `<도메인>Queries.ts`에 넣는다. 목데이터는 서버 응답이 아니라 화면 모양이라, 미리 만든 스키마는 서버와 다른 계약이 된다. - 데이터를 바꾸는 요청(신청 취소 등)은 조회 파일에 `use<동작>Mutation` 훅(`useCancelEventApplicationMutation`)으로 두고, `onSuccess`에서 바뀐 조회 key를 `invalidateQueries`로 무효화한다(화면은 무효화를 모른다). 무효화 Promise를 돌려줘서 다시 받은 데이터가 들어온 뒤에 mutation이 끝나게 한다. 지금 화면에 없는데 돌아갔을 때 바로 보여야 하는 조회(신청내역 목록 등)는 `refetchQueries({ queryKey, exact: true })`로 그것만 같이 받는다 — `refetchType: "all"`은 열어 봤던 상세까지 전부 다시 받는다. 데이터가 이미 있는 조회는 다시 받는 동안 서스펜드되지 않고 지금 화면을 유지한다. - 화면(`<화면>Screen.tsx`)은 헤더 등록, UI 상태, 이동 같은 동작을 맡고, 데이터를 받는 영역만 `}>`으로 감싼다. `useSuspenseQuery`로 데이터를 읽고 그리는 부분은 `features/<기능>/components/`의 컴포넌트(`EventsList`, `EventsDetailContent` 등)로 분리하고, 이동 같은 동작은 콜백 prop으로 받는다. From d504e08e03f162e1de4f4a64af6b8f9c88b8f7a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:13:51 +0900 Subject: [PATCH 20/22] =?UTF-8?q?chore:=20zod=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 ++- pnpm-lock.yaml | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 51c7def8..0e101adb 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "lottie-react": "^3.1.2", "react": "^19.2.8", "react-dom": "^19.2.8", - "react-router-dom": "^7.18.3" + "react-router-dom": "^7.18.3", + "zod": "^4.6.7" }, "devDependencies": { "@biomejs/biome": "^2.5.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e77156c8..2ed4e590 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: react-router-dom: specifier: ^7.18.3 version: 7.18.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + zod: + specifier: ^4.6.7 + version: 4.6.7 devDependencies: '@biomejs/biome': specifier: ^2.5.11 @@ -1416,6 +1419,9 @@ packages: resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} engines: {node: '>= 6'} + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} + zustand@5.0.15: resolution: {integrity: sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==} engines: {node: '>=12.20.0'} @@ -2559,6 +2565,8 @@ snapshots: yaml@1.10.3: {} + zod@4.6.7: {} + zustand@5.0.15(@types/react@19.2.18)(react@19.2.8): optionalDependencies: '@types/react': 19.2.18 From 5badfb2e57113e86ec985be2291ca9e2445985fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:13:51 +0900 Subject: [PATCH 21/22] =?UTF-8?q?feat:=20=ED=95=99=EC=83=9D=ED=9A=8C?= =?UTF-8?q?=EB=B9=84=C2=B7=ED=8C=8C=EC=9D=BC=20=EC=97=85=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=20API=20=EC=9D=91=EB=8B=B5=EC=9D=84=20zod=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=EB=A1=9C=20=EA=B2=80=EC=82=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/entities/fee/feeQueries.ts | 56 +++++++++++++++++------ src/shared/entities/fee/feeTypes.ts | 30 ++++-------- src/shared/entities/files/filesQueries.ts | 23 ++++++---- src/shared/entities/files/filesTypes.ts | 13 ++---- 4 files changed, 73 insertions(+), 49 deletions(-) diff --git a/src/shared/entities/fee/feeQueries.ts b/src/shared/entities/fee/feeQueries.ts index 9b39a975..a1cd4113 100644 --- a/src/shared/entities/fee/feeQueries.ts +++ b/src/shared/entities/fee/feeQueries.ts @@ -3,13 +3,36 @@ import { useMutation, useQueryClient, } from "@tanstack/react-query"; +import { z } from "zod"; import { api, isApiError } from "@/shared/api"; import { feeKeys } from "@/shared/entities/fee/feeKeys"; -import type { - FeeResponse, - FeeSubmitRequest, -} from "@/shared/entities/fee/feeTypes"; +import type { FeeSubmitRequest } from "@/shared/entities/fee/feeTypes"; + +const FEE_PATH = "/v1/app/fee"; + +export const FEE_STATUSES = ["UNPAID", "PENDING", "PAID", "REJECTED"] as const; + +// 내 학생회비 납부 현황(서버 FeeResponse). 학생회비 조회·납부 요청이 같은 모양으로 준다. +// 금액·송금 링크는 지금 낼 수 있을 때(UNPAID·REJECTED에 남은 학기 수를 보냈을 때)만 계산해 주고, +// PENDING·PAID면 요청 때 남긴 금액만 준다(링크는 null). 일시는 시간대 없는 서버 시각("2026-09-04T11:49:00")이다. +export const feeResponseSchema = z.looseObject({ + // 한 번도 요청하지 않았으면 null + studentTransferStatusId: z.number().nullable(), + feeStatus: z.enum(FEE_STATUSES), + // 학생회가 납부 계좌를 설정하기 전이면 null이다(화면은 쓰지 않는다 — 송금은 서버가 만든 링크로 한다) + bank: z.string().nullable(), + accountNo: z.string().nullable(), + remainingSemesters: z.number().nullable(), + transferAmount: z.number().nullable(), + // 금액이 채워진 토스 송금 딥링크(supertoss://send?…) + transferLinkUrl: z.string().nullable(), + requestedAt: z.string().nullable(), + // 납부완료(PAID)일 때만 있다 + confirmedAt: z.string().nullable(), + // 확인필요(REJECTED)일 때만 있다 — 그때는 서버가 항상 채운다 + rejectReason: z.string().nullable(), +}); // 화면은 이 queryOptions로만 데이터를 받는다. 화면 모양으로 바꿔야 하면 화면 쪽에서 select를 더한다. export const feeQueries = { @@ -17,19 +40,23 @@ export const feeQueries = { me: () => queryOptions({ queryKey: feeKeys.me(), - queryFn: ({ signal }) => - api.get("/v1/app/fee/me", { signal }), + queryFn: async ({ signal }) => + feeResponseSchema.parse( + await api.get(`${FEE_PATH}/me`, { signal }), + ), }), // 남은 학기 수로 계산한 금액과 토스 송금 링크 — 같은 API에 학기 수를 실어 보낸다(서버는 아무것도 저장하지 않는다). // 지금 낼 수 없는 상태(납부확인중·납부완료)면 링크가 null이다. transferGuide: (remainingSemesters: number) => queryOptions({ queryKey: feeKeys.transferGuide(remainingSemesters), - queryFn: ({ signal }) => - api.get("/v1/app/fee/me", { - params: { remainingSemesters }, - signal, - }), + queryFn: async ({ signal }) => + feeResponseSchema.parse( + await api.get(`${FEE_PATH}/me`, { + params: { remainingSemesters }, + signal, + }), + ), }), }; @@ -50,8 +77,11 @@ export function useSubmitFeeMutation() { queryClient.invalidateQueries({ queryKey: feeKeys.root() }); return useMutation({ - mutationFn: (request: FeeSubmitRequest) => - api.post("/v1/app/fee", request), + // 응답(납부 현황)은 쓰지 않는다 — 무효화로 다시 받는 me 조회가 스키마로 검사한다. + // 여기서도 검사하면 서버에 이미 접수된 요청을 응답 모양 때문에 실패로 안내하게 된다. + mutationFn: async (request: FeeSubmitRequest) => { + await api.post(FEE_PATH, request); + }, // Promise를 돌려줘서 다시 받은 상태가 들어온 뒤에 mutation이 끝나게 한다 onSuccess: invalidateFee, onError: (error) => { diff --git a/src/shared/entities/fee/feeTypes.ts b/src/shared/entities/fee/feeTypes.ts index ccef3525..81f1b879 100644 --- a/src/shared/entities/fee/feeTypes.ts +++ b/src/shared/entities/fee/feeTypes.ts @@ -1,29 +1,19 @@ +import type { z } from "zod"; + +import type { + FEE_STATUSES, + feeResponseSchema, +} from "@/shared/entities/fee/feeQueries"; + // 학생 본인의 학생회비 납부 상태 — 서버(GET /v1/app/fee/me의 feeStatus) 문자열 그대로 쓴다. // 한 번도 요청하지 않았으면 UNPAID(서버 DB에는 없고 응답에서만 쓰는 상태)다. 납부확인중(PENDING)은 아직 납부로 보지 않는다. -export type FeeStatus = "UNPAID" | "PENDING" | "PAID" | "REJECTED"; +export type FeeStatus = (typeof FEE_STATUSES)[number]; // 납부를 요청한 뒤의 상태(납부확인중 · 확인필요 · 납부완료) — 미납이면 내역이 없다(홈에서 납부하기로 안내) export type FeePaymentStatus = Exclude; -// 내 학생회비 납부 현황(서버 FeeResponse). 학생회비 조회·납부 요청이 같은 모양으로 준다. -// 금액·송금 링크는 지금 낼 수 있을 때(UNPAID·REJECTED에 남은 학기 수를 보냈을 때)만 계산해 주고, -// PENDING·PAID면 요청 때 남긴 금액만 준다(링크는 null). 일시는 시간대 없는 서버 시각("2026-09-04T11:49:00")이다. -export interface FeeResponse { - /** 한 번도 요청하지 않았으면 null */ - studentTransferStatusId: number | null; - feeStatus: FeeStatus; - bank: string; - accountNo: string; - remainingSemesters: number | null; - transferAmount: number | null; - /** 금액이 채워진 토스 송금 딥링크(supertoss://send?…) */ - transferLinkUrl: string | null; - requestedAt: string | null; - /** 납부완료(PAID)일 때만 있다 */ - confirmedAt: string | null; - /** 확인필요(REJECTED)일 때만 있다 — 그때는 서버가 항상 채운다 */ - rejectReason: string | null; -} +// 내 학생회비 납부 현황(서버 FeeResponse) — 필드 설명은 feeQueries의 스키마에 있다 +export type FeeResponse = z.infer; // POST /v1/app/fee 요청 — 송금을 마친 뒤 남은 학기 수와 그 근거인 학점이수현황 캡처를 함께 낸다 export interface FeeSubmitRequest { diff --git a/src/shared/entities/files/filesQueries.ts b/src/shared/entities/files/filesQueries.ts index c9fe6792..76266eed 100644 --- a/src/shared/entities/files/filesQueries.ts +++ b/src/shared/entities/files/filesQueries.ts @@ -1,11 +1,19 @@ import { useMutation } from "@tanstack/react-query"; import axios from "axios"; +import { z } from "zod"; import { api } from "@/shared/api"; -import type { - FileCategory, - FileUploadUrl, -} from "@/shared/entities/files/filesTypes"; +import type { FileCategory } from "@/shared/entities/files/filesTypes"; + +// POST /v1/files/presigned-url 응답(서버 FileUploadUrlIssueResponse) +export const fileUploadUrlResponseSchema = z.looseObject({ + fileId: z.number(), + // 스토리지에 파일 본문을 바로 PUT하는 서명된 주소 + uploadUrl: z.string(), + // 다른 API(학생회비 납부 요청 등)에 넘기는 파일 키 + fileKey: z.string(), + expiresAt: z.string(), +}); interface UploadFileVariables { file: File; @@ -18,14 +26,13 @@ interface UploadFileVariables { export function useUploadFileMutation() { return useMutation({ mutationFn: async ({ file, category }: UploadFileVariables) => { - const { fileKey, uploadUrl } = await api.post( - "/v1/files/presigned-url", - { + const { fileKey, uploadUrl } = fileUploadUrlResponseSchema.parse( + await api.post("/v1/files/presigned-url", { category, contentType: file.type, fileSize: file.size, originalName: file.name, - }, + }), ); // 서명에 Content-Type이 들어가 있어서 발급 때 보낸 값과 같아야 한다 await axios.put(uploadUrl, file, { diff --git a/src/shared/entities/files/filesTypes.ts b/src/shared/entities/files/filesTypes.ts index 245bc16e..10c87778 100644 --- a/src/shared/entities/files/filesTypes.ts +++ b/src/shared/entities/files/filesTypes.ts @@ -1,13 +1,10 @@ +import type { z } from "zod"; + +import type { fileUploadUrlResponseSchema } from "@/shared/entities/files/filesQueries"; + // 업로드 URL을 받을 때 밝히는 파일 용도(서버 FileCategory) — 용도마다 서버가 받는 형식·용량이 다르다. // 앱이 올리는 용도만 둔다. export type FileCategory = "FEE_TRANSCRIPT_IMAGE"; // POST /v1/files/presigned-url 응답(서버 FileUploadUrlIssueResponse) -export interface FileUploadUrl { - fileId: number; - /** 스토리지에 파일 본문을 바로 PUT하는 서명된 주소 */ - uploadUrl: string; - /** 다른 API(학생회비 납부 요청 등)에 넘기는 파일 키 */ - fileKey: string; - expiresAt: string; -} +export type FileUploadUrlResponse = z.infer; From d79306e54f6be04b7bec4bb95aee563ec56a6718 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:14:44 +0900 Subject: [PATCH 22/22] =?UTF-8?q?fix:=20=ED=9A=8C=EB=B9=84=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=9D=98=20=EA=B3=84=EC=A2=8C=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=EB=A5=BC=20=EC=95=B1=20API=20=EA=B3=84=EC=95=BD=EB=8C=80?= =?UTF-8?q?=EB=A1=9C=20=ED=95=84=EC=88=98=20=EA=B0=92=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/entities/fee/feeQueries.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/shared/entities/fee/feeQueries.ts b/src/shared/entities/fee/feeQueries.ts index a1cd4113..f0e59f1c 100644 --- a/src/shared/entities/fee/feeQueries.ts +++ b/src/shared/entities/fee/feeQueries.ts @@ -20,9 +20,9 @@ export const feeResponseSchema = z.looseObject({ // 한 번도 요청하지 않았으면 null studentTransferStatusId: z.number().nullable(), feeStatus: z.enum(FEE_STATUSES), - // 학생회가 납부 계좌를 설정하기 전이면 null이다(화면은 쓰지 않는다 — 송금은 서버가 만든 링크로 한다) - bank: z.string().nullable(), - accountNo: z.string().nullable(), + // 계좌를 설정하기 전이면 서버가 이 응답 대신 404(FEE_ACCOUNT_NOT_FOUND)를 준다 + bank: z.string(), + accountNo: z.string(), remainingSemesters: z.number().nullable(), transferAmount: z.number().nullable(), // 금액이 채워진 토스 송금 딥링크(supertoss://send?…)