From 6a9d5fe625a3f2d8ca8a036bb1d9022c1a0a6b10 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Thu, 5 Nov 2026 05:07:01 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=EC=97=90=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=EC=96=B4=20=EC=9D=B8=EC=9E=90=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?-=20#193?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 행사·공지·열린피드백·빌릴게·아카이빙 목록 조회가 keyword를 받아 서버 목록 API에 넘긴다. 생략하면 null이라 기존 목록 화면은 그대로다. 아카이빙 목록 항목 스키마를 따로 꺼냈다. --- src/shared/entities/archives/archivesKeys.ts | 3 +- .../entities/archives/archivesQueries.ts | 30 +++++++++++-------- src/shared/entities/billilge/billilgeKeys.ts | 6 ++-- .../entities/billilge/billilgeQueries.ts | 8 ++--- src/shared/entities/events/eventsKeys.ts | 6 ++-- src/shared/entities/events/eventsQueries.ts | 11 ++++--- .../entities/feedbacks/feedbacksKeys.ts | 4 +-- .../entities/feedbacks/feedbacksQueries.ts | 11 +++++-- src/shared/entities/notices/noticesKeys.ts | 4 +-- src/shared/entities/notices/noticesQueries.ts | 8 ++--- 10 files changed, 52 insertions(+), 39 deletions(-) diff --git a/src/shared/entities/archives/archivesKeys.ts b/src/shared/entities/archives/archivesKeys.ts index 10d2879e..659a5bed 100644 --- a/src/shared/entities/archives/archivesKeys.ts +++ b/src/shared/entities/archives/archivesKeys.ts @@ -1,6 +1,7 @@ export const archivesKeys = { root: () => ["archives"] as const, - list: () => [...archivesKeys.root(), "list"] as const, + list: (keyword: string | null) => + [...archivesKeys.root(), "list", keyword] as const, detail: (archiveId: number) => [...archivesKeys.root(), "detail", archiveId] as const, }; diff --git a/src/shared/entities/archives/archivesQueries.ts b/src/shared/entities/archives/archivesQueries.ts index 60b5357f..1a8421df 100644 --- a/src/shared/entities/archives/archivesQueries.ts +++ b/src/shared/entities/archives/archivesQueries.ts @@ -6,16 +6,16 @@ import { archivesKeys } from "@/shared/entities/archives/archivesKeys"; const ARCHIVES_PATH = "/v1/app/archives"; +export const archiveListItemSchema = z.looseObject({ + archiveId: z.number(), + title: z.string(), + startDate: z.string(), + endDate: z.string().nullable(), + thumbnailUrl: z.string().nullable(), +}); + export const archiveListResponseSchema = z.looseObject({ - archives: z.array( - z.looseObject({ - archiveId: z.number(), - title: z.string(), - startDate: z.string(), - endDate: z.string().nullable(), - thumbnailUrl: z.string().nullable(), - }), - ), + archives: z.array(archiveListItemSchema), years: z.array(z.number()), }); @@ -42,13 +42,17 @@ export const archiveDetailResponseSchema = z.looseObject({ }); export const archivesQueries = { - // 연도 필터는 화면 select가 거른다 — year 없이 받아 연도를 바꿔도 다시 요청하지 않는다 - list: () => + // 연도 필터는 화면 select가 거른다 — year 없이 받아 연도를 바꿔도 다시 요청하지 않는다. + // keyword는 검색 화면의 아카이빙 탭이 제목으로 찾을 때만 넘긴다(연도 목록은 검색과 상관없이 전체다). + list: (keyword: string | null = null) => queryOptions({ - queryKey: archivesKeys.list(), + queryKey: archivesKeys.list(keyword), queryFn: async ({ signal }) => archiveListResponseSchema.parse( - await api.get(ARCHIVES_PATH, { signal }), + await api.get(ARCHIVES_PATH, { + params: { keyword }, + signal, + }), ), }), // 없는 아카이빙은 null — 상세·현장 사진 화면이 "존재하지 않는 아카이빙이에요" 빈 상태를 그린다 diff --git a/src/shared/entities/billilge/billilgeKeys.ts b/src/shared/entities/billilge/billilgeKeys.ts index 6bdced35..cd6a18c9 100644 --- a/src/shared/entities/billilge/billilgeKeys.ts +++ b/src/shared/entities/billilge/billilgeKeys.ts @@ -1,12 +1,12 @@ import type { BillilgeCategory } from "@/shared/entities/billilge/billilgeTypes"; // 조회 캐시 key. 반납 필요 대여는 대여 이력 key 아래에 둬서, 이력 key 하나로 둘을 같이 무효화한다. -// 물품 목록은 카테고리마다 따로 받아서 카테고리를 key에 넣는다 — items()로 모든 카테고리를 같이 무효화한다. +// 물품 목록은 카테고리·검색어마다 따로 받아서 key에 넣는다 — items()로 모든 카테고리를 같이 무효화한다. export const billilgeKeys = { root: () => ["billilge"] as const, items: () => [...billilgeKeys.root(), "items"] as const, - itemList: (category: BillilgeCategory | null) => - [...billilgeKeys.items(), category] as const, + itemList: (category: BillilgeCategory | null, keyword: string | null) => + [...billilgeKeys.items(), category, keyword] as const, histories: () => [...billilgeKeys.root(), "histories"] as const, returnRequired: () => [...billilgeKeys.histories(), "return-required"] as const, diff --git a/src/shared/entities/billilge/billilgeQueries.ts b/src/shared/entities/billilge/billilgeQueries.ts index dbfca474..f5768b3d 100644 --- a/src/shared/entities/billilge/billilgeQueries.ts +++ b/src/shared/entities/billilge/billilgeQueries.ts @@ -105,15 +105,15 @@ function getNextCursor(lastPage: CursorSliceResponse) { // 화면은 이 queryOptions로만 데이터를 받는다. 화면 모양으로 바꿔야 하면 화면 쪽에서 select를 더한다. export const billilgeQueries = { // 물품 목록을 이름순 커서로 이어 받는다(GET /v1/app/billilge/items). category가 null이면 거르지 않는다. - // 한 번에 서버 기본값(20개)씩 받는다. - items: (category: BillilgeCategory | null) => + // 한 번에 서버 기본값(20개)씩 받는다. keyword는 검색 화면의 빌릴게 탭이 이름으로 찾을 때만 넘긴다. + items: (category: BillilgeCategory | null, keyword: string | null = null) => infiniteQueryOptions({ - queryKey: billilgeKeys.itemList(category), + queryKey: billilgeKeys.itemList(category, keyword), queryFn: async ({ pageParam, signal }) => billilgeItemPageSchema.parse( await api.get(`${BILLILGE_PATH}/items`, { // axios는 null인 파라미터를 보내지 않는다 — 첫 페이지는 cursor 없이, 전체는 category 없이 간다 - params: { category, cursor: pageParam }, + params: { category, cursor: pageParam, keyword }, signal, }), ), diff --git a/src/shared/entities/events/eventsKeys.ts b/src/shared/entities/events/eventsKeys.ts index 867ea65b..d78c201b 100644 --- a/src/shared/entities/events/eventsKeys.ts +++ b/src/shared/entities/events/eventsKeys.ts @@ -1,13 +1,13 @@ import type { EventRecruitStatus } from "@/shared/entities/events/eventsTypes"; // 조회 캐시 key. 조회와 무효화가 같은 함수로 key를 만들어서, 한쪽만 바뀌어 무효화가 빗나가지 않게 한다. -// 행사 목록은 모집 상태 필터마다 따로 받아서 lists() 아래에 둔다 — lists() 하나로 모든 필터를 같이 무효화한다. +// 행사 목록은 모집 상태 필터·검색어마다 따로 받아서 lists() 아래에 둔다 — lists() 하나로 모든 필터를 같이 무효화한다. // 신청내역 상세는 목록 key 아래에 둬서, 목록 key 하나로 목록·상세를 같이 무효화한다. export const eventsKeys = { root: () => ["events"] as const, lists: () => [...eventsKeys.root(), "list"] as const, - list: (recruitStatus: EventRecruitStatus | null) => - [...eventsKeys.lists(), recruitStatus] as const, + list: (recruitStatus: EventRecruitStatus | null, keyword: string | null) => + [...eventsKeys.lists(), recruitStatus, keyword] as const, detail: (eventId: string) => [...eventsKeys.root(), "detail", eventId] as const, form: (eventId: string) => [...eventsKeys.root(), "form", eventId] as const, diff --git a/src/shared/entities/events/eventsQueries.ts b/src/shared/entities/events/eventsQueries.ts index 079b3faa..3d6ad28c 100644 --- a/src/shared/entities/events/eventsQueries.ts +++ b/src/shared/entities/events/eventsQueries.ts @@ -194,15 +194,18 @@ function getNextCursor(lastPage: CursorSliceResponse) { export const eventsQueries = { // 행사 목록을 커서로 이어 받는다(GET /v1/app/events). recruitStatus가 null이면 거르지 않는다. // 서버가 모집중(신청 마감 임박순) → 모집예정(신청 시작 임박순) → 모집종료(최근 행사순)로 정렬해 준다 — - // 필터를 걸어도 상태 안의 순서는 같아서 화면은 받은 순서대로 그린다. - list: (recruitStatus: EventRecruitStatus | null) => + // 필터를 걸어도 상태 안의 순서는 같아서 화면은 받은 순서대로 그린다. keyword는 검색 화면의 행사 탭이 제목으로 찾을 때만 넘긴다. + list: ( + recruitStatus: EventRecruitStatus | null, + keyword: string | null = null, + ) => infiniteQueryOptions({ - queryKey: eventsKeys.list(recruitStatus), + queryKey: eventsKeys.list(recruitStatus, keyword), queryFn: async ({ pageParam, signal }) => eventListPageSchema.parse( await api.get("/v1/app/events", { // axios는 null인 파라미터를 보내지 않는다 — 첫 페이지는 cursor 없이, 전체는 recruitStatus 없이 간다 - params: { cursor: pageParam, recruitStatus }, + params: { cursor: pageParam, keyword, recruitStatus }, signal, }), ), diff --git a/src/shared/entities/feedbacks/feedbacksKeys.ts b/src/shared/entities/feedbacks/feedbacksKeys.ts index a6ddfc64..b05aa76c 100644 --- a/src/shared/entities/feedbacks/feedbacksKeys.ts +++ b/src/shared/entities/feedbacks/feedbacksKeys.ts @@ -3,8 +3,8 @@ export const feedbacksKeys = { root: () => ["feedbacks"] as const, lists: () => [...feedbacksKeys.root(), "list"] as const, - list: (year: number | null, round: number | null) => - [...feedbacksKeys.lists(), year, round] as const, + list: (year: number | null, round: number | null, keyword: string | null) => + [...feedbacksKeys.lists(), year, round, keyword] as const, recent: () => [...feedbacksKeys.root(), "recent"] as const, rounds: (year: number | null) => [...feedbacksKeys.root(), "rounds", year] as const, diff --git a/src/shared/entities/feedbacks/feedbacksQueries.ts b/src/shared/entities/feedbacks/feedbacksQueries.ts index 89a31512..eefaffd1 100644 --- a/src/shared/entities/feedbacks/feedbacksQueries.ts +++ b/src/shared/entities/feedbacks/feedbacksQueries.ts @@ -63,14 +63,19 @@ function getNextPage(lastPage: z.infer) { export const feedbacksQueries = { // 공개된 답변이 있는 피드백을 최근 공개 회차 순으로 이어 받는다(GET /v1/app/feedbacks). 연도·차수는 서버가 걸러 준다 // — 번호로 나눠 받아서 받은 것만 거르면 다음 페이지의 피드백이 빠진다. 한 번에 서버 기본값(20건)씩 받는다. - list: (year: number | null, round: number | null) => + // keyword는 검색 화면의 열린피드백 탭이 질문으로 찾을 때만 넘긴다. + list: ( + year: number | null, + round: number | null, + keyword: string | null = null, + ) => infiniteQueryOptions({ - queryKey: feedbacksKeys.list(year, round), + queryKey: feedbacksKeys.list(year, round, keyword), queryFn: async ({ pageParam, signal }) => feedbackPageSchema.parse( await api.get(FEEDBACKS_PATH, { // axios는 null인 파라미터를 보내지 않는다 — 전체는 year·round 없이 간다 - params: { page: pageParam, round, year }, + params: { keyword, page: pageParam, round, year }, signal, }), ), diff --git a/src/shared/entities/notices/noticesKeys.ts b/src/shared/entities/notices/noticesKeys.ts index 0553e661..c1de5417 100644 --- a/src/shared/entities/notices/noticesKeys.ts +++ b/src/shared/entities/notices/noticesKeys.ts @@ -5,8 +5,8 @@ import type { NoticeCategoryCode } from "@/shared/entities/notices/noticesTypes" export const noticesKeys = { root: () => ["notices"] as const, lists: () => [...noticesKeys.root(), "list"] as const, - list: (category: NoticeCategoryCode | null) => - [...noticesKeys.lists(), category] as const, + list: (category: NoticeCategoryCode | null, keyword: string | null) => + [...noticesKeys.lists(), category, keyword] as const, detail: (noticeId: number) => [...noticesKeys.root(), "detail", noticeId] as const, }; diff --git a/src/shared/entities/notices/noticesQueries.ts b/src/shared/entities/notices/noticesQueries.ts index e7129ee0..bf6db54b 100644 --- a/src/shared/entities/notices/noticesQueries.ts +++ b/src/shared/entities/notices/noticesQueries.ts @@ -69,15 +69,15 @@ function getNextCursor(lastPage: z.infer) { // 화면은 이 queryOptions로만 데이터를 받는다. 화면 모양으로 바꿔야 하면 화면 쪽에서 select를 더한다. export const noticesQueries = { // 공지를 고정 먼저, 최신순으로 이어 받는다(GET /v1/app/notices). category가 null이면 거르지 않는다. - // 한 번에 서버 기본값(20개)씩 받는다. - list: (category: NoticeCategoryCode | null) => + // 한 번에 서버 기본값(20개)씩 받는다. keyword는 검색 화면의 공지 탭이 제목으로 찾을 때만 넘긴다. + list: (category: NoticeCategoryCode | null, keyword: string | null = null) => infiniteQueryOptions({ - queryKey: noticesKeys.list(category), + queryKey: noticesKeys.list(category, keyword), queryFn: async ({ pageParam, signal }) => noticePageSchema.parse( await api.get(NOTICES_PATH, { // axios는 null인 파라미터를 보내지 않는다 — 첫 페이지는 cursor 없이, 전체는 category 없이 간다 - params: { category, cursor: pageParam }, + params: { category, cursor: pageParam, keyword }, signal, }), ), From 89ae70bff10b2ee9d5d54d306f50130d1fd8d153 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Thu, 5 Nov 2026 05:07:01 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=EB=A5=BC=20=ED=86=B5=ED=95=A9=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?API=EB=A1=9C=20=EB=B0=9B=EA=B8=B0=20-=20#193?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전체 탭과 탭 개수는 GET /v1/app/search로 받고, 개별 탭은 각 목록 API에 keyword를 넘겨 이어 받는다. 카드는 서버 목록 응답 그대로 기존 카드를 다시 쓰고, 검색어는 서버 상한인 50자까지만 입력받는다. --- docs/conventions/wds-component-usage.md | 15 +- .../search/components/SearchArchivingCard.tsx | 24 +++- .../search/components/SearchCategoryItems.tsx | 90 ++++++------ .../search/components/SearchCategoryList.tsx | 133 ++++++++++++++++++ .../search/components/SearchField.tsx | 4 + .../search/components/SearchResultContent.tsx | 8 +- .../components/SearchResultSkeleton.tsx | 31 ++-- .../search/components/SearchResultView.tsx | 30 ++-- src/features/search/constants/search.ts | 57 ++++++-- src/shared/entities/search/searchQueries.ts | 72 +++++----- src/shared/entities/search/searchTypes.ts | 18 +-- 11 files changed, 345 insertions(+), 137 deletions(-) create mode 100644 src/features/search/components/SearchCategoryList.tsx diff --git a/docs/conventions/wds-component-usage.md b/docs/conventions/wds-component-usage.md index 1682a454..e2841c9f 100644 --- a/docs/conventions/wds-component-usage.md +++ b/docs/conventions/wds-component-usage.md @@ -432,12 +432,12 @@ WDS는 `useToast` 훅 + `Toast` 컴포넌트로 토스트 시스템을 완비하 `shared/entities/notices/noticesQueries.ts`가 학생 앱 API(`/v1/app/notices/**`)를 zod로 검사해 받는다. 화면 모양(카테고리 라벨 "일반"/"제휴", 날짜 `2026.09.03`)은 `features/notices/constants/notices.ts`의 헬퍼로 화면 쪽에서 바꾼다. - **목록은 카테고리별로 서버에서 따로 받는다:** `GET /v1/app/notices?category=`는 커서(`cursor`)로 이어 받고, 고정 공지를 먼저, 그다음 최신순으로 준다. 커서는 발급된 카테고리 필터에서만 유효해서(다른 필터의 커서는 `NOTICE_INVALID_CURSOR`) 탭마다 key를 따로 두고 처음부터 받는다. 받은 것만 거르면 다음 페이지의 공지가 빠지므로 클라이언트 필터링은 하지 않는다. 스크롤이 끝에 닿으면 다음 페이지를 이어 받는다(`useInfiniteScroll`). 탭은 바로 바뀌고 목록은 `useDeferredValue`로 새 카테고리를 받는 동안 지금 목록을 유지한다(빌릴게 목록과 같은 방식). -- **썸네일은 서버가 준 주소로 그린다:** 목록 항목의 `thumbnailUrl`(등록된 첫 이미지)이 있으면 56px 썸네일에 실제 이미지를 `LazyImage`로(받는 동안·실패 시 회색 칸), 없으면 썸네일 칸 자체를 그리지 않는다. 검색 결과 카드는 아직 목이라 `hasThumbnail`만 넘겨 회색 칸을 그린다. +- **썸네일은 서버가 준 주소로 그린다:** 목록 항목의 `thumbnailUrl`(등록된 첫 이미지)이 있으면 56px 썸네일에 실제 이미지를 `LazyImage`로(받는 동안·실패 시 회색 칸), 없으면 썸네일 칸 자체를 그리지 않는다. 검색 결과의 공지 카드도 같은 규칙으로 그린다. - **고정 공지의 핀은 썸네일 유무와 상관없이 제목 앞에 붙는다.** 썸네일이 없는 카드의 제목은 여러 줄로 이어질 수 있다. - **상세 본문은 서식 없는 일반 텍스트다:** 서버가 줄바꿈을 보존해 저장하므로 `whiteSpace: pre-wrap`으로 그리고 HTML로 해석하지 않는다. 상세의 `images`는 바로 쓸 수 있는 공개 URL이라 `PhotoGallery`에 `photos`로 넘기고, 이미지가 하나도 없으면 흰 배경 헤더를 쓴다. - **첨부파일은 서버 `attachments`로 그린다:** 상세 응답의 `attachments`(`fileId`·`fileName`·`fileUrl`)를 검사해 등록일 아래 접힌 아코디언(`NoticesAttachmentAccordion`, #191)에 넘긴다. 서버가 파일 크기를 주지 않아 캡션에는 형식(확장자)만 쓴다. 파일 주소는 스토리지라 앱과 출처가 달라 ``가 먹지 않고 지금 탭이 파일로 넘어가므로, 다운로드는 `openExternalLink`로 새 탭에서 연다. - **없는 공지:** 서버가 404 `NOTICE_NOT_FOUND`로 주면 쿼리 안에서 `null`로 바꿔 "존재하지 않는 공지예요"를 그린다. 숫자가 아닌 주소는 서버에 보내지 않고 같은 화면으로 처리한다. -- **검색은 아직 목이다:** 검색의 공지는 예전 화면 모양(`Notice`, `noticesMock.ts`)을 그대로 쓴다. 서버 검색 API를 붙이면 그 타입과 목은 지운다. 홈 배너는 서버가 준 공지 id(`targetId`)로 이 상세(`/notices/:noticeId`)를 연다. +- **홈 배너:** 서버가 준 공지 id(`targetId`)로 이 상세(`/notices/:noticeId`)를 연다. ## 열린피드백은 서버 API로 받는다 @@ -449,7 +449,6 @@ WDS는 `useToast` 훅 + `Toast` 컴포넌트로 토스트 시스템을 완비하 - **모아보기는 상세로 회차를 알아낸 뒤 그 회차를 한 번에 받는다:** 누른 피드백(`GET /v1/app/feedbacks/{id}`)의 연도·차수로 같은 회차의 공개 피드백을 최대 100건(서버 size 상한) 받는다. 한 회차가 이를 넘으면 뒤쪽이 잘리고, 누른 피드백이 잘렸으면 맨 앞에 붙인다. 없는 피드백, 남의 질문인데 공개된 답변이 없는 경우(서버가 질문이 있다는 것도 드러내지 않아 같은 404), 숫자가 아닌 주소는 모두 "존재하지 않는 피드백이에요"로 보여 준다. 숫자가 아닌 주소는 서버에 보내지 않는다. - **작성은 접수 중인 회차에 들어간다:** `POST /v1/app/feedbacks`. 접수 기간이 아니면 서버가 `FEEDBACK_NOT_OPEN`(409)으로 거절하고, 이때와 그 밖의 실패는 모달을 닫고 쓴 내용을 둔 채 토스트로 이유를 알린다(Figma에 없는 안내다). 성공하면 내 작성내역 캐시를 무효화하고 목록으로 이동해 완료 토스트를 띄운다. - **작성내역은 `GET /v1/app/feedbacks/mine`을 무한 스크롤로 받는다.** 답변대기와 답변완료는 `answer`가 있는지로 가른다 — 회차의 답변이 공개되기 전에는 서버가 내 질문의 답변도 비워서 주기 때문에, 운영진이 답변을 써 두었어도 공개 전에는 답변대기로 보인다. -- **검색은 아직 목이다:** 검색의 열린피드백은 서버 검색 API를 붙이기 전이라 예전 화면 모양(`Feedback`, `feedbacksMock.ts`)을 그대로 쓴다. 검색을 서버에 붙이면 둘 다 지운다. ## 열린피드백 상세페이지 · 모아보기(`1410:49988`) 구현 중 확정된 매핑 @@ -596,6 +595,14 @@ A-1 본문의 `Zone Grid Row`(`2159:110823`)가 `h-px`라 `위쪽 칸/아래쪽 `/component`로 검색 진입·검색 중·검색 완료(전체/행사 탭)·결과 없음 프레임을 구현하며 확인된 내용. 코드는 `src/features/search/` 참고. +### 검색 결과는 서버 API로 받는다 + +서버 통합 검색(stream-server #263)의 구조를 그대로 따른다. + +- **전체 탭과 탭 개수:** `searchQueries.results(keyword)`가 `GET /v1/app/search?keyword=`를 받는다. 응답은 섹션마다 앞의 2건(`content`)과 전체 수(`totalCount`)이고, 빌릴게 섹션만 서버 이름이 `items`다(`getSearchPreview`·`getSearchResultCount`가 화면 카테고리로 바꾼다). 탭 개수와 "n개 더보기"는 서버가 센 전체 수로 그린다. 카드는 각 목록 API의 항목 응답 그대로라 도메인 스키마로 검사한다. +- **개별 탭:** 탭을 열 때 각 도메인 목록 조회에 `keyword`를 넘겨 받는다(`SearchCategoryList`) — 행사·공지·빌릴게는 커서, 열린피드백은 페이지 번호로 이어 받고(`useInfiniteScroll`), 아카이빙은 연도 없이 한 번에 받는다. 정렬·노출 조건은 각 목록과 같다. 도메인 목록의 `keyword` 인자는 생략하면 `null`이라 기존 목록 화면은 그대로다. +- **검색어:** 서버가 제목·이름(열린피드백은 질문)만 부분 일치로 찾고, 50자를 넘으면 400이라 입력칸에 `maxLength`(`SEARCH_KEYWORD_MAX_LENGTH`)를 둔다. 앞뒤 공백은 화면이 지워 보낸다. + | WDS 컴포넌트 | 코드 export | 확인 내용 | |---|---|---| | `Tab/Tab` (결과 탭 줄) | `Tab` + `TabList` + `TabListItem` | Figma 탭 줄(`3147:138705`: 높이 40, 좌우 20, 탭 간격 24, 라벨 15px SemiBold)이 **`TabList size="small" horizontalPadding`**과 정확히 맞는다 — `small`이 Body 2/Bold + 세로 패딩 9(=높이 40)고, `horizontalPadding`이 좌우 20을 스크롤 영역 안쪽에 줘서 6개 탭이 375px을 넘을 때 화면 끝까지 스크롤된다(`tab/style.js`의 `tabPaddingStyle`·`tabSizeStyle`). 기본(`medium`, Headline 2 17px)은 높이가 48이라 어긋난다. 공지 화면처럼 바깥에 `px-5` 래퍼를 두면 스크롤이 20px 안쪽에서 잘린다 | @@ -625,7 +632,7 @@ A-1 본문의 `Zone Grid Row`(`2159:110823`)가 `h-px`라 `위쪽 칸/아래쪽 - **재사용(수정 없음)**: 행사 `EventsCard`, 공지 `NoticesCard`, 열린피드백 `FeedbacksCard` - **빌릴게**: `BillilgeItemCard`에 `bordered` prop만 추가했다(흰 배경 위에서 테두리 `border-line-solid-neutral`). 메인 목록은 회색 배경 위라 테두리가 없다 -- **아카이빙**: Figma `Search Archiving Card`(`3013:127633`)는 Stream 로컬이고 이 화면 전용이라 `features/search/components/SearchArchivingCard.tsx`로 새로 만들었다 +- **아카이빙**: Figma `Search Archiving Card`(`3013:127633`)는 Stream 로컬이고 이 화면 전용이라 `features/search/components/SearchArchivingCard.tsx`로 새로 만들었다. 썸네일은 서버 `thumbnailUrl`을 `LazyImage`로 그리고, 없으면 회색 칸이다 ## 홈 화면(`3147:146240` 2건 이하 / `3147:146297` 3건 이상 / `3562:163866` empty) 구현 중 확정된 매핑 diff --git a/src/features/search/components/SearchArchivingCard.tsx b/src/features/search/components/SearchArchivingCard.tsx index d9b505d4..29cdb3a6 100644 --- a/src/features/search/components/SearchArchivingCard.tsx +++ b/src/features/search/components/SearchArchivingCard.tsx @@ -1,18 +1,32 @@ import { Typography } from "@wanteddev/wds"; +import LazyImage from "@/components/ui/LazyImage"; + interface SearchArchivingCardProps { title: string; date: string; + /** 서버가 준 대표 사진 — 없으면 회색 칸을 그린다 */ + thumbnailUrl: string | null; } // Figma: Search Archiving Card (nodeId 3013:127633) — Stream 로컬 카드, WDS 아님. 검색 결과 전용이라 -// 이 feature 안에 둔다(설계서 4번: 기존 카드와 달라지는 카드 — 아카이빙·빌릴게). 아카이빙 상세 화면이 -// 아직 없어서 눌러도 이동하지 않는다. -// 썸네일은 실 이미지 API 전까지 Figma와 같은 단색 placeholder다. -function SearchArchivingCard({ title, date }: SearchArchivingCardProps) { +// 이 feature 안에 둔다(설계서 4번: 기존 카드와 달라지는 카드 — 아카이빙·빌릴게). +function SearchArchivingCard({ + title, + date, + thumbnailUrl, +}: SearchArchivingCardProps) { return (
-
+ {thumbnailUrl ? ( + + ) : ( +
+ )}
{ - switch (category) { + switch (entries.category) { case "events": - return results.events.map((event) => ({ - id: String(event.eventId), + return entries.items.map((event) => ({ + id: event.eventId, node: ( ({ - id: notice.id, + return entries.items.map((notice) => ({ + id: notice.noticeId, node: ( - + ), })); case "feedbacks": - return results.feedbacks.map((feedback) => ({ - id: feedback.id, - // 답변이 있는 피드백만 상세(모아보기)가 있다 — 열린피드백 목록과 같은 규칙 - node: feedback.answer ? ( - + return entries.items.map((feedback) => ({ + id: feedback.feedbackId, + // 답변이 있는 피드백만 상세(모아보기)가 있다 — 열린피드백 목록과 같은 규칙. 서버는 답변이 공개된 것만 찾아 준다 + node: hasFeedbackAnswer(feedback) ? ( + ) : ( ), })); case "archives": - return results.archives.map((archive) => ({ - id: archive.id, + return entries.items.map((archive) => ({ + id: archive.archiveId, node: ( - + ), })); case "billilge": - return results.billilge.map((item) => ({ - id: String(item.itemId), + return entries.items.map((item) => ({ + id: item.itemId, node: (
- {entries.map((entry, index) => ( + {buildEntries().map((entry, index) => ( {hasDivider && index > 0 && (
diff --git a/src/features/search/components/SearchCategoryList.tsx b/src/features/search/components/SearchCategoryList.tsx new file mode 100644 index 00000000..a95dfc65 --- /dev/null +++ b/src/features/search/components/SearchCategoryList.tsx @@ -0,0 +1,133 @@ +import { + useSuspenseInfiniteQuery, + useSuspenseQuery, +} from "@tanstack/react-query"; + +import SearchCategoryItems from "@/features/search/components/SearchCategoryItems"; +import type { + SearchCategory, + SearchCategoryEntries, +} from "@/features/search/constants/search"; +import { useInfiniteScroll } from "@/hooks/useInfiniteScroll"; +import { archivesQueries } from "@/shared/entities/archives/archivesQueries"; +import { billilgeQueries } from "@/shared/entities/billilge/billilgeQueries"; +import { eventsQueries } from "@/shared/entities/events/eventsQueries"; +import { feedbacksQueries } from "@/shared/entities/feedbacks/feedbacksQueries"; +import { noticesQueries } from "@/shared/entities/notices/noticesQueries"; + +interface SearchCategoryListProps { + category: SearchCategory; + keyword: string; +} + +interface CategoryListProps { + keyword: string; +} + +// 개별 탭의 전체 결과 — 통합 검색은 섹션마다 2건만 줘서, 탭은 각 목록 API에 keyword를 넘겨 그 목록의 순서·페이지 +// 방식 그대로 받는다(서버 #263). 받는 동안은 SearchResultView의 Suspense가 스켈레톤을 보여준다. +// 카테고리마다 조회 훅이 달라서(커서·페이지 번호·한 번에) 카테고리별 컴포넌트로 나눈다. +function SearchCategoryList({ category, keyword }: SearchCategoryListProps) { + switch (category) { + case "events": + return ; + case "notices": + return ; + case "feedbacks": + return ; + case "billilge": + return ; + case "archives": + return ; + } +} + +// 이어 받는 목록의 카드와, 보이면 다음 페이지를 받는 끝 요소 +// query는 조회 결과를 펼치지 않고 그대로 받는다 — 펼치면 TanStack Query가 모든 필드를 쓰는 것으로 보고 다시 그린다 +function InfiniteEntries({ + entries, + query, +}: { + entries: SearchCategoryEntries; + query: Parameters[0]; +}) { + const nextPageTriggerRef = useInfiniteScroll({ + fetchNextPage: query.fetchNextPage, + hasNextPage: query.hasNextPage, + isFetchNextPageError: query.isFetchNextPageError, + isFetchingNextPage: query.isFetchingNextPage, + }); + + return ( + <> + + {/* 카드 사이 gap이 붙지 않게 목록 밖에 둔다 */} +
+ + ); +} + +function SearchEventsList({ keyword }: CategoryListProps) { + const query = useSuspenseInfiniteQuery({ + ...eventsQueries.list(null, keyword), + select: (response) => response.pages.flatMap((page) => page.content), + }); + return ( + + ); +} + +function SearchNoticesList({ keyword }: CategoryListProps) { + const query = useSuspenseInfiniteQuery({ + ...noticesQueries.list(null, keyword), + select: (response) => response.pages.flatMap((page) => page.content), + }); + return ( + + ); +} + +function SearchFeedbacksList({ keyword }: CategoryListProps) { + const query = useSuspenseInfiniteQuery({ + ...feedbacksQueries.list(null, null, keyword), + select: (response) => response.pages.flatMap((page) => page.content), + }); + return ( + + ); +} + +function SearchBillilgeList({ keyword }: CategoryListProps) { + const query = useSuspenseInfiniteQuery({ + ...billilgeQueries.items(null, keyword), + select: (response) => response.pages.flatMap((page) => page.content), + }); + return ( + + ); +} + +// 아카이빙 목록은 페이지 없이 한 번에 온다(연도를 넘기지 않아 모든 연도에서 찾는다) +function SearchArchivesList({ keyword }: CategoryListProps) { + const { data } = useSuspenseQuery({ + ...archivesQueries.list(keyword), + select: (response) => response.archives, + }); + return ( + + ); +} + +export default SearchCategoryList; diff --git a/src/features/search/components/SearchField.tsx b/src/features/search/components/SearchField.tsx index b95945e6..7a26522c 100644 --- a/src/features/search/components/SearchField.tsx +++ b/src/features/search/components/SearchField.tsx @@ -2,6 +2,8 @@ import { Typography } from "@wanteddev/wds"; import { IconCircleCloseFill, IconSearch } from "@wanteddev/wds-icon"; import type { Ref } from "react"; +import { SEARCH_KEYWORD_MAX_LENGTH } from "@/shared/entities/search/searchQueries"; + interface SearchFieldProps { value: string; onChange: (value: string) => void; @@ -43,6 +45,8 @@ function SearchField({ className="min-w-0 flex-1 bg-transparent outline-none placeholder:text-label-assistive" color="semantic.label.normal" enterKeyHint="search" + // 서버가 50자를 넘는 검색어를 거절해서 입력에서 막는다 + maxLength={SEARCH_KEYWORD_MAX_LENGTH} onChange={(event) => onChange(event.target.value)} placeholder="검색어를 입력하세요" ref={inputRef} diff --git a/src/features/search/components/SearchResultContent.tsx b/src/features/search/components/SearchResultContent.tsx index 02a12e57..e1f9151f 100644 --- a/src/features/search/components/SearchResultContent.tsx +++ b/src/features/search/components/SearchResultContent.tsx @@ -2,10 +2,7 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import SearchEmptyState from "@/features/search/components/SearchEmptyState"; import SearchResultView from "@/features/search/components/SearchResultView"; -import { - getSearchResultCount, - type SearchTab, -} from "@/features/search/constants/search"; +import type { SearchTab } from "@/features/search/constants/search"; import { searchQueries } from "@/shared/entities/search/searchQueries"; interface SearchResultContentProps { @@ -23,7 +20,7 @@ function SearchResultContent({ const { data: results } = useSuspenseQuery(searchQueries.results(keyword)); // 전체 결과가 하나도 없으면 탭 없이 empty만 보여준다(Figma empty 프레임에 탭이 없다) - if (getSearchResultCount(results, "all") === 0) { + if (results.totalCount === 0) { return (
@@ -34,6 +31,7 @@ function SearchResultContent({ return ( diff --git a/src/features/search/components/SearchResultSkeleton.tsx b/src/features/search/components/SearchResultSkeleton.tsx index 2ea03252..44fbc863 100644 --- a/src/features/search/components/SearchResultSkeleton.tsx +++ b/src/features/search/components/SearchResultSkeleton.tsx @@ -8,7 +8,8 @@ const SKELETON_TAB_KEYS = ["tab-1", "tab-2", "tab-3", "tab-4"]; // 화면설계서 8번 "응답 대기 중 스켈레톤 UI" — Figma에는 이 상태 프레임이 없어서 결과 화면의 뼈대 // (탭 줄 + 썸네일/두 줄 텍스트 행)를 WDS `Skeleton`으로 흉내 냈다. 모션 줄이기 설정이면 깜빡임을 끈다. -function SearchResultSkeleton() { +// 개별 탭의 목록을 받는 동안은 탭 줄이 이미 있어서 행만 그린다(hasTabs false). +function SearchResultSkeleton({ hasTabs = true }: { hasTabs?: boolean }) { const prefersReducedMotion = usePrefersReducedMotion(); const animation = !prefersReducedMotion; @@ -16,21 +17,23 @@ function SearchResultSkeleton() {
-
- {SKELETON_TAB_KEYS.map((key) => ( - - ))} -
+ {hasTabs && ( +
+ {SKELETON_TAB_KEYS.map((key) => ( + + ))} +
+ )}
{SKELETON_ROW_KEYS.map((key) => (
diff --git a/src/features/search/components/SearchResultView.tsx b/src/features/search/components/SearchResultView.tsx index a28ec44a..ebda45fd 100644 --- a/src/features/search/components/SearchResultView.tsx +++ b/src/features/search/components/SearchResultView.tsx @@ -1,24 +1,27 @@ import { Button, Tab, TabList, TabListItem } from "@wanteddev/wds"; import { IconChevronRight } from "@wanteddev/wds-icon"; -import { Fragment } from "react"; +import { Fragment, Suspense } from "react"; import SearchCategoryItems from "@/features/search/components/SearchCategoryItems"; +import SearchCategoryList from "@/features/search/components/SearchCategoryList"; import SearchEmptyState from "@/features/search/components/SearchEmptyState"; +import SearchResultSkeleton from "@/features/search/components/SearchResultSkeleton"; import SearchSectionHeader from "@/features/search/components/SearchSectionHeader"; import { + getSearchPreview, getSearchResultCount, getSearchTabLabel, isSearchTab, - SEARCH_ALL_TAB_PREVIEW_COUNT, SEARCH_ALL_TAB_SECTIONS, SEARCH_CATEGORY_LABELS, SEARCH_TABS, type SearchTab, } from "@/features/search/constants/search"; -import type { SearchResults } from "@/shared/entities/search/searchTypes"; +import type { SearchResponse } from "@/shared/entities/search/searchTypes"; interface SearchResultViewProps { - results: SearchResults; + keyword: string; + results: SearchResponse; activeTab: SearchTab; onTabChange: (tab: SearchTab) => void; } @@ -26,14 +29,16 @@ interface SearchResultViewProps { // Figma: 검색 완료 - 모든 검색 UI(nodeId 3147:138695) / 행사 탭(3147:138745) // 탭은 결과 수를 같이 보여주고(설계서 6번 "행사 8"), 6개라 375px을 넘으면 WDS TabList가 // 가로 스크롤한다. 탭 줄은 고정하고(shrink-0) 아래 목록만 세로 스크롤한다. +// 탭 개수와 전체 탭 미리보기는 통합 검색 응답으로 그리고, 개별 탭은 그 탭을 열 때 목록을 따로 받는다(SearchCategoryList). function SearchResultView({ + keyword, results, activeTab, onTabChange, }: SearchResultViewProps) { const activeCount = getSearchResultCount(results, activeTab); const visibleSections = SEARCH_ALL_TAB_SECTIONS.filter( - (category) => results[category].length > 0, + (category) => getSearchResultCount(results, category) > 0, ); return ( @@ -70,8 +75,9 @@ function SearchResultView({ // 섹션 사이는 위아래 간격 32 + 8px 회색 띠(Divider(new) 8px)다
{visibleSections.map((category, index) => { + const preview = getSearchPreview(results, category); const hiddenCount = - results[category].length - SEARCH_ALL_TAB_PREVIEW_COUNT; + getSearchResultCount(results, category) - preview.items.length; return ( {index > 0 && ( @@ -84,7 +90,7 @@ function SearchResultView({ />
0 && ( // 설계서 5번: 눌러서 해당 카테고리 탭으로 이동해 전체 목록을 본다 @@ -101,8 +107,6 @@ function SearchResultView({
) } - limit={SEARCH_ALL_TAB_PREVIEW_COUNT} - results={results} /> @@ -110,9 +114,11 @@ function SearchResultView({ })}
) : ( -
- -
+ }> +
+ +
+
)}
diff --git a/src/features/search/constants/search.ts b/src/features/search/constants/search.ts index 92513503..e39d63ae 100644 --- a/src/features/search/constants/search.ts +++ b/src/features/search/constants/search.ts @@ -1,4 +1,9 @@ -import type { SearchResults } from "@/shared/entities/search/searchTypes"; +import type { ArchiveListItemResponse } from "@/shared/entities/archives/archivesTypes"; +import type { BillilgeItem } from "@/shared/entities/billilge/billilgeTypes"; +import type { EventListItem } from "@/shared/entities/events/eventsTypes"; +import type { FeedbackResponse } from "@/shared/entities/feedbacks/feedbacksTypes"; +import type { NoticeListItem } from "@/shared/entities/notices/noticesTypes"; +import type { SearchResponse } from "@/shared/entities/search/searchTypes"; // 검색 결과 카테고리 — 키는 용어 사전(terminology.md)의 코드 용어를 그대로 쓴다. export type SearchCategory = @@ -29,7 +34,7 @@ export const SEARCH_TABS: SearchTab[] = [ ]; // 전체 탭의 섹션 순서는 탭 순서와 다르다: 행사 → 공지 → 아카이빙 → 빌릴게 → 열린피드백 -// (nodeId 3147:138695). 화면설계서 4번: 카테고리별 최대 2개만 보여주고, 그보다 많으면 "n개 더보기". +// (nodeId 3147:138695). 화면설계서 4번: 카테고리별 최대 2개만 보여주고(서버가 2건씩 준다), 그보다 많으면 "n개 더보기". export const SEARCH_ALL_TAB_SECTIONS: SearchCategory[] = [ "events", "notices", @@ -37,7 +42,6 @@ export const SEARCH_ALL_TAB_SECTIONS: SearchCategory[] = [ "billilge", "feedbacks", ]; -export const SEARCH_ALL_TAB_PREVIEW_COUNT = 2; export function getSearchTabLabel(tab: SearchTab): string { return tab === "all" ? "전체" : SEARCH_CATEGORY_LABELS[tab]; @@ -53,16 +57,49 @@ export function getSearchPath(tab: SearchTab = "all"): string { return tab === "all" ? "/search" : `/search?tab=${tab}`; } -// 탭명에 붙는 결과 수(화면설계서 6번) — 전체 탭은 모든 카테고리의 합이다. +// 화면 카테고리(용어 사전 코드 용어)와 통합 검색 응답의 섹션 이름 — 빌릴게만 서버 이름이 items다 +const SEARCH_RESPONSE_SECTIONS = { + archives: "archives", + billilge: "items", + events: "events", + feedbacks: "feedbacks", + notices: "notices", +} as const satisfies Record; + +// 한 카테고리의 카드 목록 — 카드는 각 목록 API의 항목 응답 그대로다 +export type SearchCategoryEntries = + | { category: "events"; items: EventListItem[] } + | { category: "notices"; items: NoticeListItem[] } + | { category: "feedbacks"; items: FeedbackResponse[] } + | { category: "billilge"; items: BillilgeItem[] } + | { category: "archives"; items: ArchiveListItemResponse[] }; + +// 전체 탭 섹션의 미리보기(서버가 앞의 2건만 준다) +export function getSearchPreview( + response: SearchResponse, + category: SearchCategory, +): SearchCategoryEntries { + switch (category) { + case "events": + return { category, items: response.events.content }; + case "notices": + return { category, items: response.notices.content }; + case "feedbacks": + return { category, items: response.feedbacks.content }; + case "billilge": + return { category, items: response.items.content }; + case "archives": + return { category, items: response.archives.content }; + } +} + +// 탭명에 붙는 결과 수(화면설계서 6번) — 서버가 센 전체 수라 미리보기 2건과 상관없다. 전체 탭은 모든 섹션의 합이다. export function getSearchResultCount( - results: SearchResults, + response: SearchResponse, tab: SearchTab, ): number { if (tab === "all") { - return SEARCH_ALL_TAB_SECTIONS.reduce( - (sum, category) => sum + results[category].length, - 0, - ); + return response.totalCount; } - return results[tab].length; + return response[SEARCH_RESPONSE_SECTIONS[tab]].totalCount; } diff --git a/src/shared/entities/search/searchQueries.ts b/src/shared/entities/search/searchQueries.ts index 9a872eeb..e75af2a7 100644 --- a/src/shared/entities/search/searchQueries.ts +++ b/src/shared/entities/search/searchQueries.ts @@ -1,43 +1,49 @@ import { queryOptions } from "@tanstack/react-query"; +import { z } from "zod"; -import { mockupResponse } from "@/lib/mockupApi"; -import { ARCHIVES } from "@/shared/entities/archives/archivesMock"; -import { BILLILGE_ITEMS } from "@/shared/entities/billilge/billilgeMock"; -import { EVENTS } from "@/shared/entities/events/eventsMock"; -import { FEEDBACKS } from "@/shared/entities/feedbacks/feedbacksMock"; -import { NOTICES } from "@/shared/entities/notices/noticesMock"; +import { api } from "@/shared/api"; +import { archiveListItemSchema } from "@/shared/entities/archives/archivesQueries"; +import { billilgeItemSchema } from "@/shared/entities/billilge/billilgeQueries"; +import { eventListItemSchema } from "@/shared/entities/events/eventsQueries"; +import { feedbackSchema } from "@/shared/entities/feedbacks/feedbacksQueries"; +import { noticeListItemSchema } from "@/shared/entities/notices/noticesQueries"; 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); -} +// 서버가 받는 검색어 최대 길이 — 넘으면 400 INVALID_INPUT이라 입력칸에서 막는다 +export const SEARCH_KEYWORD_MAX_LENGTH = 50; -// 목데이터 기준 클라이언트 검색이다 — 제목류 필드만 대소문자 무시 부분 일치로 본다. -// 공지·행사 본문은 목데이터가 전부 같은 글이라 본문까지 보면 아무 키워드나 전부 걸린다. -function searchMocks(keyword: string): SearchResults { - return { - archives: ARCHIVES.filter((item) => includesKeyword(item.title, keyword)), - billilge: BILLILGE_ITEMS.filter((item) => - includesKeyword(item.name, keyword), - ), - events: EVENTS.filter((item) => includesKeyword(item.title, keyword)), - feedbacks: FEEDBACKS.filter((item) => - includesKeyword(item.question, keyword), - ), - notices: NOTICES.filter((item) => includesKeyword(item.title, keyword)), - }; +// 섹션 하나(서버 PreviewResponse) — 앞의 몇 건(지금은 2건)과 전체 수. 이어 받지 않아서 커서가 없다 +function previewSchema(itemSchema: T) { + return z.looseObject({ + content: z.array(itemSchema), + totalCount: z.number(), + }); } -// 실 API가 붙으면 queryFn 안쪽만 요청 코드로 바꾼다(`/api/search`) — 화면은 이 queryOptions로만 데이터를 받는다. -// 열린피드백은 아직 entities로 옮겨지지 않아 features의 목데이터를 직접 읽는다. +// 통합 검색 응답 — 카드는 각 목록 API의 항목 응답 그대로라 도메인 스키마를 쓴다. 섹션이 비어도 필드는 빠지지 않는다. +// 빌릴게 섹션의 서버 이름은 items다. +export const searchResponseSchema = z.looseObject({ + // 다섯 섹션 전체 수의 합 — 0이면 검색 결과가 없다 + totalCount: z.number(), + events: previewSchema(eventListItemSchema), + notices: previewSchema(noticeListItemSchema), + feedbacks: previewSchema(feedbackSchema), + items: previewSchema(billilgeItemSchema), + archives: previewSchema(archiveListItemSchema), +}); + +// 화면은 이 queryOptions로만 데이터를 받는다. 탭·더보기의 전체 목록은 각 도메인 목록 조회에 keyword를 넘겨 받는다. export const searchQueries = { - // 대소문자·앞뒤 공백만 다른 검색어는 같은 결과라 정리한 검색어로 key를 만든다 - results: (rawKeyword: string) => { - const keyword = rawKeyword.trim().toLowerCase(); - return queryOptions({ + // 검색 화면 전체 탭과 탭별 개수(GET /v1/app/search). 검색어는 화면이 앞뒤 공백을 지워 넘긴다. + results: (keyword: string) => + queryOptions({ queryKey: searchKeys.results(keyword), - queryFn: () => mockupResponse(() => searchMocks(keyword)), - }); - }, + queryFn: async ({ signal }) => + searchResponseSchema.parse( + await api.get("/v1/app/search", { + params: { keyword }, + signal, + }), + ), + }), }; diff --git a/src/shared/entities/search/searchTypes.ts b/src/shared/entities/search/searchTypes.ts index 1ef596b4..e03aaa01 100644 --- a/src/shared/entities/search/searchTypes.ts +++ b/src/shared/entities/search/searchTypes.ts @@ -1,14 +1,6 @@ -import type { ArchiveItem } from "@/shared/entities/archives/archivesTypes"; -import type { BillilgeItem } from "@/shared/entities/billilge/billilgeTypes"; -import type { EventListItem } from "@/shared/entities/events/eventsTypes"; -import type { Feedback } from "@/shared/entities/feedbacks/feedbacksTypes"; -import type { Notice } from "@/shared/entities/notices/noticesTypes"; +import type { z } from "zod"; -// 검색 결과는 카테고리별 배열이다 — 키는 용어 사전(terminology.md)의 코드 용어를 그대로 쓴다. -export interface SearchResults { - events: EventListItem[]; - notices: Notice[]; - feedbacks: Feedback[]; - billilge: BillilgeItem[]; - archives: ArchiveItem[]; -} +import type { searchResponseSchema } from "@/shared/entities/search/searchQueries"; + +// 통합 검색 응답(GET /v1/app/search) 그대로다 — searchQueries.ts의 응답 검사 스키마에서 만든다 +export type SearchResponse = z.infer; From 760aced64075e48a147823985740301ed4221c12 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Thu, 5 Nov 2026 05:07:02 +0900 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20=EA=B2=80=EC=83=89=EC=97=90?= =?UTF-8?q?=EC=84=9C=EB=A7=8C=20=EC=93=B0=EB=8D=98=20=EB=AA=A9=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=A0=95=EB=A6=AC=20-=20#193?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 검색이 서버를 쓰게 되어 쓰지 않는 행사·공지·열린피드백·빌릴게·아카이빙 목데이터와 목 전용 타입(Notice·Feedback·ArchiveItem 등)을 지웠다. --- src/shared/entities/archives/archivesMock.ts | 40 ---------- src/shared/entities/archives/archivesTypes.ts | 9 --- src/shared/entities/billilge/billilgeMock.ts | 80 ------------------- src/shared/entities/events/eventsMock.ts | 36 --------- .../entities/feedbacks/feedbacksMock.ts | 78 ------------------ .../entities/feedbacks/feedbacksTypes.ts | 14 ---- src/shared/entities/notices/noticesMock.ts | 73 ----------------- src/shared/entities/notices/noticesTypes.ts | 16 ---- 8 files changed, 346 deletions(-) delete mode 100644 src/shared/entities/archives/archivesMock.ts delete mode 100644 src/shared/entities/billilge/billilgeMock.ts delete mode 100644 src/shared/entities/events/eventsMock.ts delete mode 100644 src/shared/entities/feedbacks/feedbacksMock.ts delete mode 100644 src/shared/entities/notices/noticesMock.ts diff --git a/src/shared/entities/archives/archivesMock.ts b/src/shared/entities/archives/archivesMock.ts deleted file mode 100644 index 531734f4..00000000 --- a/src/shared/entities/archives/archivesMock.ts +++ /dev/null @@ -1,40 +0,0 @@ -import alumniPanelTalk from "@/assets/images/archives/alumni-panel-talk.jpg"; -import freshmanOrientation from "@/assets/images/archives/freshman-orientation.jpg"; -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 { ArchiveItem } from "@/shared/entities/archives/archivesTypes"; - -// 검색 결과(SearchArchivingCard)만 쓰는 목데이터 — 아카이빙 화면은 서버 조회로 바뀌었고, 검색 API를 붙일 때 지운다. -export const ARCHIVES: ArchiveItem[] = [ - { - date: "2025.05.04", - id: "1", - image: haeoreumje, - title: "2025-2 기말고사 간식행사", - }, - { - date: "2025.04.02", - id: "2", - image: snackEvent, - title: "2025-1 중간고사 간식행사", - }, - { - date: "2024.12.18", - id: "3", - image: alumniPanelTalk, - title: "2024-2 학생회 송년회", - }, - { - date: "2024.10.11", - id: "4", - image: sportsDay, - title: "2024 소프트웨어융합대학 체육대회", - }, - { - date: "2024.03.08", - id: "5", - image: freshmanOrientation, - title: "2024-1 새내기 배움터", - }, -]; diff --git a/src/shared/entities/archives/archivesTypes.ts b/src/shared/entities/archives/archivesTypes.ts index f3dd8fbd..ecdaf5c4 100644 --- a/src/shared/entities/archives/archivesTypes.ts +++ b/src/shared/entities/archives/archivesTypes.ts @@ -5,15 +5,6 @@ import type { archiveListResponseSchema, } from "@/shared/entities/archives/archivesQueries"; -export interface ArchiveItem { - id: string; - title: string; - /** 이미 포맷된 표시용 문자열 (예: "2025.05.04") */ - date: string; - /** 목록 카드·상세 대표 사진 */ - image: string; -} - export type ArchiveListResponse = z.infer; export type ArchiveListItemResponse = ArchiveListResponse["archives"][number]; export type ArchiveDetailResponse = z.infer; diff --git a/src/shared/entities/billilge/billilgeMock.ts b/src/shared/entities/billilge/billilgeMock.ts deleted file mode 100644 index 7cf17e10..00000000 --- a/src/shared/entities/billilge/billilgeMock.ts +++ /dev/null @@ -1,80 +0,0 @@ -import eightPinCharger from "@/assets/icons/billilge-items/8pin-charger.svg"; -import alcoholSwab from "@/assets/icons/billilge-items/alcohol-swab.svg"; -import bandAid from "@/assets/icons/billilge-items/band-aid.svg"; -import curlingIron from "@/assets/icons/billilge-items/curling-iron.svg"; -import eyeDrops from "@/assets/icons/billilge-items/eye-drops.svg"; -import hairDryer from "@/assets/icons/billilge-items/hair-dryer.svg"; -import laptopCharger from "@/assets/icons/billilge-items/laptop-charger.svg"; -import mask from "@/assets/icons/billilge-items/mask.svg"; -import ointment from "@/assets/icons/billilge-items/ointment.svg"; -import painReliefPatch from "@/assets/icons/billilge-items/pain-relief-patch.svg"; -import pill from "@/assets/icons/billilge-items/pill.svg"; -import powerBank from "@/assets/icons/billilge-items/power-bank.svg"; -import sanitaryPad from "@/assets/icons/billilge-items/sanitary-pad.svg"; -import umbrella from "@/assets/icons/billilge-items/umbrella.svg"; -import usbCCharger from "@/assets/icons/billilge-items/usb-c-charger.svg"; -import type { - BillilgeCategory, - BillilgeItem, -} from "@/shared/entities/billilge/billilgeTypes"; - -// 서버(ItemListItemResponse)가 반납 정책으로 만드는 반납 기한 문구를 흉내낸다 — 0일이면 당일 마감 시각까지, -// 1일이면 "내일", 그 밖은 날짜(M/d). 마감 시각은 서버가 17시로 고정한다. -function mockReturnDeadlineLabel(maxRentalDays: number): string { - if (maxRentalDays === 0) { - return "오늘 17시까지 반납"; - } - if (maxRentalDays === 1) { - return "내일까지 반납"; - } - const date = new Date(); - date.setDate(date.getDate() + maxRentalDays); - return `${date.getMonth() + 1}/${date.getDate()}까지 반납`; -} - -// 대여품은 반납 정책(빌릴 수 있는 날 수)이 있고, 소모품(maxRentalDays null)은 반납 정책·문구가 없다 -function mockItem( - itemId: number, - name: string, - category: BillilgeCategory, - imageUrl: string, - maxRentalDays: number | null, -): BillilgeItem { - const isRental = maxRentalDays !== null; - return { - category, - count: 28, - imageUrl, - itemId, - name, - returnDeadlineLabel: isRental - ? mockReturnDeadlineLabel(maxRentalDays) - : null, - returnPolicy: isRental ? { maxRentalDays, returnDeadline: "17:00" } : null, - type: isRental ? "RENTAL" : "CONSUMABLE", - }; -} - -// Figma: 빌릴게 Item Grid (nodeId 1243:73343) 순서·물품명·수량을 그대로 옮긴 목데이터 — 통합 검색만 쓴다(검색 API가 -// 붙으면 지운다). 대여 탭 목록은 서버에서 받는다. 분류·대여품 여부는 Figma에 없어 물품명 기준으로 정했다. -export const BILLILGE_ITEMS: BillilgeItem[] = [ - mockItem(1, "고데기", "ELECTRONICS", curlingIron, 0), - mockItem(2, "노트북 충전기(C타입)", "ELECTRONICS", laptopCharger, 1), - mockItem(3, "드라이기", "ELECTRONICS", hairDryer, 0), - mockItem(4, "마스크", "HYGIENE", mask, null), - mockItem(5, "밴드", "MEDICINE", bandAid, null), - mockItem(6, "보조배터리", "ELECTRONICS", powerBank, 1), - mockItem(7, "생리대(대형)", "HYGIENE", sanitaryPad, null), - mockItem(8, "생리대(소형)", "HYGIENE", sanitaryPad, null), - mockItem(9, "알콜스왑", "MEDICINE", alcoholSwab, null), - mockItem(10, "우산", "DAILY_SUPPLIES", umbrella, 2), - mockItem(11, "위장약", "MEDICINE", pill, null), - mockItem(12, "이지엔6", "MEDICINE", pill, null), - mockItem(13, "인공눈물", "MEDICINE", eyeDrops, null), - mockItem(14, "종합감기약", "MEDICINE", pill, null), - mockItem(15, "케이블(C to C)", "ELECTRONICS", usbCCharger, 0), - mockItem(16, "타이레놀", "MEDICINE", pill, null), - mockItem(17, "파스", "MEDICINE", painReliefPatch, null), - mockItem(18, "후시딘", "MEDICINE", ointment, null), - mockItem(19, "8핀 충전기", "ELECTRONICS", eightPinCharger, 0), -]; diff --git a/src/shared/entities/events/eventsMock.ts b/src/shared/entities/events/eventsMock.ts deleted file mode 100644 index ab68a6b0..00000000 --- a/src/shared/entities/events/eventsMock.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { EventListItem } from "@/shared/entities/events/eventsTypes"; - -// 행사 화면은 실 API로 받는다. 이 목록은 아직 목데이터로 도는 검색(searchQueries)만 쓴다 — 검색 API를 붙일 때 지운다. -// 행사 목록 응답(EventListItem) 모양이고, Figma 행사 Event List(1243:70866)처럼 같은 행사명에 모집 상태만 다르다. -export const EVENTS: EventListItem[] = [ - { - applyEndAt: "2026-06-03T23:59:00", - applyStartAt: "2026-05-25T10:00:00", - daysUntilDeadline: 2, - eventId: 1, - eventStartAt: "2026-06-04T18:30:00", - recruitStatus: "OPEN", - thumbnailUrl: null, - title: "소프트웨어융합대학 체육대회", - }, - { - applyEndAt: "2026-06-03T23:59:00", - applyStartAt: "2026-05-25T10:00:00", - daysUntilDeadline: null, - eventId: 2, - eventStartAt: "2026-06-04T18:30:00", - recruitStatus: "CLOSED", - thumbnailUrl: null, - title: "소프트웨어융합대학 체육대회", - }, - { - applyEndAt: "2026-08-20T23:59:00", - applyStartAt: "2026-08-10T10:00:00", - daysUntilDeadline: null, - eventId: 3, - eventStartAt: "2026-08-28T18:30:00", - recruitStatus: "BEFORE_OPEN", - thumbnailUrl: null, - title: "소프트웨어융합대학 체육대회", - }, -]; diff --git a/src/shared/entities/feedbacks/feedbacksMock.ts b/src/shared/entities/feedbacks/feedbacksMock.ts deleted file mode 100644 index ee94ebf9..00000000 --- a/src/shared/entities/feedbacks/feedbacksMock.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { Feedback } from "@/shared/entities/feedbacks/feedbacksTypes"; - -// 문단은 빈 줄(\n\n)로 나누고, 굵게 강조할 구간은 **텍스트**로 표시한다 — 둘 다 Figma -// 원본(nodeId 1410:50009)의 문단 구분·볼드 스팬을 그대로 옮긴 것이다. -// `FeedbacksAnswerText`가 이 문법을 해석해서 렌더링하고, 카드 미리보기(FeedbacksQaCard)는 -// **만 지운 평문으로 보여준다. -const MT_ANSWER = [ - "학우 여러분의 경제적 부담을 덜어드리기 위해 더욱 고민하겠습니다. 최근 **지속적인 물가 상승**으로 인해 숙박 및 대관료가 전반적으로 크게 인상된 상황이었습니다.", - "실제로는 기존보다 참가비가 인상될 수밖에 없는 여건이었으나, 학생회에서는 학우분들의 **부담을 최소화**하기 위해 업체 측과 수차례 협의를 진행하며 최대한 비용을 조정했고, 학교 측 지원금 확보 노력 등을 통해 기존 수준으로 **참가비를 동결**하고자 최선을 다했습니다.", - "그럼에도 불구하고 학우분들께서 체감하시는 부담을 충분히 줄여드리지 못한 점은 학생회 역시 무겁게 받아들이고 있습니다.", - "향후 행사에서는 보다 **합리적이고 만족도 높은 행사 운영**이 이루어질 수 있도록 최선을 다하겠습니다.", -].join("\n\n"); - -// Figma: 게시판 - 열린피드백 (nodeId 1410:50011) 목업 데이터 -export const FEEDBACKS: Feedback[] = [ - { - answer: MT_ANSWER, - answerDate: "2026-05-06", - id: "1", - question: "MT 참가 비용이 다소 비싼 편이라 부담스러웠어요.", - round: "2차", - year: "2026", - }, - { - answer: - "말씀 주신 대로 다음 학기부터는 개강총회를 목요일에 진행해 다음 날 부담을 줄일 수 있도록 반영하겠습니다.", - answerDate: "2026-05-06", - id: "2", - question: "목요일에 개강총회를 진행해서 다음 날 부담이 적어 좋았습니다.", - round: "2차", - year: "2026", - }, - { - answer: "건의해주신 내용은 다음 학생회 운영위에서 함께 논의하겠습니다.", - answerDate: "2026-03-14", - id: "3", - question: "동아리방 냉난방기 가동 시간을 조금 더 늘려주시면 좋겠습니다.", - round: "1차", - year: "2026", - }, - { - answer: "안내드린 대로 다음 공지에 상세 시간표를 함께 첨부하겠습니다.", - answerDate: "2026-07-02", - id: "4", - question: - "축제 공연 시간표를 미리 공지해주시면 일정을 맞추기 편할 것 같아요.", - round: "3차", - year: "2026", - }, - { - answer: "학생증 재발급 신청 절차를 학과 사무실과 다시 공유드리겠습니다.", - answerDate: "2026-08-20", - id: "5", - question: "학생증 재발급 절차가 헷갈려서 안내가 더 필요해요.", - round: "4차", - year: "2026", - }, - // 연도 드롭다운(1524:167569)으로 거를 수 있게 지난 연도 피드백을 둔다. 답변이 없어서 - // "최근 피드백" 캐러셀에는 안 나오고 전체 피드백 목록에만 보인다. - { - id: "6", - question: "시험 기간에 과방을 늦게까지 열어주시면 좋겠어요.", - round: "2차", - year: "2025", - }, - { - id: "7", - question: "신입생 OT 일정이 너무 늦게 공지돼서 아쉬웠어요.", - round: "1차", - year: "2025", - }, - { - id: "8", - question: "졸업 사진 촬영 날짜를 미리 알려주시면 좋겠습니다.", - round: "3차", - year: "2024", - }, -]; diff --git a/src/shared/entities/feedbacks/feedbacksTypes.ts b/src/shared/entities/feedbacks/feedbacksTypes.ts index fa549a29..4c9d90c9 100644 --- a/src/shared/entities/feedbacks/feedbacksTypes.ts +++ b/src/shared/entities/feedbacks/feedbacksTypes.ts @@ -15,17 +15,3 @@ export type FeedbackResponse = z.infer; export type FeedbackPageResponse = z.infer; export type FeedbackRoundsResponse = z.infer; - -// 검색 결과의 열린피드백 — 검색은 아직 목데이터라(서버 검색 API 연동 전) 예전 화면 모양을 그대로 쓴다. -// 검색을 서버에 붙이면 이 타입과 feedbacksMock.ts는 사라진다. -export type FeedbackRound = "1차" | "2차" | "3차" | "4차"; - -export interface Feedback { - id: string; - question: string; - // 몇 년도 차수인지("2026") — 차수는 해마다 1차부터 다시 열려서 연도와 같이 봐야 한다 - year: string; - round: FeedbackRound; - answer?: string; - answerDate?: string; -} diff --git a/src/shared/entities/notices/noticesMock.ts b/src/shared/entities/notices/noticesMock.ts deleted file mode 100644 index bb22b2e3..00000000 --- a/src/shared/entities/notices/noticesMock.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { Notice } from "@/shared/entities/notices/noticesTypes"; - -// Figma: 공지 상세 (nodeId 1256:81842) 목업 본문 — 목업 데이터라 공지 5건이 본문을 공유한다. -const NOTICE_BODY = `안녕하십니까, 제10대 소프트웨어융합대학 학생회 '에코'입니다. - -기말고사를 준비하고 계신 학우 여러분을 응원하기 위해 간식행사를 진행합니다 🍱✨ - -시험기간 동안 든든하게 힘내시길 바라며, 많은 관심과 참여 부탁드립니다! - -📌 간식행사 일정 -▪️ 일시 : 6월 1일 (월) 11:00 ~ -▪️ 장소 : 미래관 4층 신관 입구 (예대방면) - -📌 대상 -▪️ 소프트웨어융합대학 재학생 선착순 180명 -※ 과학생회비 미납부자 참여 가능 - -📌 간식행사 메뉴 -▪️ 돈까스 도련님 도시락 -▪️ 나랑드사이다 제로 - -📌 유의 사항 -▪️ 소프트웨어융합대학 학생임을 증명할 수 있는 모바일 학생증 혹은 실물 학생증을 지참해주시기 바랍니다. -▪️ 1인당 1세트만 수령 가능하며, 선착순 수량 소진 시 수령이 불가능합니다. - -많은 학우 여러분의 관심과 참여 부탁드립니다. -감사합니다 😊`; - -// Figma: 게시판 - 공지 (nodeId 1256:81776) 목업 데이터 -export const NOTICES: Notice[] = [ - { - body: NOTICE_BODY, - category: "일반", - date: "2026.09.03", - hasThumbnail: true, - id: "1", - isPinned: true, - photoCount: 7, - title: "2026-2학기 사물함 신청 안내", - }, - { - body: NOTICE_BODY, - category: "일반", - date: "2026.09.03", - hasThumbnail: true, - id: "2", - photoCount: 3, - title: "2026-2학기 사물함 신청 안내", - }, - { - body: NOTICE_BODY, - category: "일반", - date: "2026.09.03", - id: "3", - title: "2026-2학기 사물함 신청 안내", - }, - { - body: NOTICE_BODY, - category: "제휴", - date: "2026.09.03", - id: "4", - title: "2026-2학기 사물함 신청 안내", - }, - { - body: NOTICE_BODY, - category: "제휴", - date: "2026.09.03", - hasThumbnail: true, - id: "5", - photoCount: 4, - title: "2026-2학기 사물함 신청 안내", - }, -]; diff --git a/src/shared/entities/notices/noticesTypes.ts b/src/shared/entities/notices/noticesTypes.ts index 4e0eef35..628cbaf4 100644 --- a/src/shared/entities/notices/noticesTypes.ts +++ b/src/shared/entities/notices/noticesTypes.ts @@ -24,19 +24,3 @@ export type NoticeDetail = z.infer; // 상세의 첨부파일 한 건 export type NoticeAttachment = z.infer; - -// 검색 결과의 공지 — 검색은 아직 목데이터라(서버 검색 API 연동 전) 예전 화면 모양을 그대로 쓴다. -// 검색을 서버에 붙이면 아래 타입과 noticesMock.ts는 사라진다. -export type NoticeCategory = "일반" | "제휴"; - -export interface Notice { - id: string; - title: string; - date: string; - category: NoticeCategory; - isPinned?: boolean; - hasThumbnail?: boolean; - /** 상세 화면 이미지 갤러리 총 장수 — hasThumbnail일 때만 의미가 있다. */ - photoCount?: number; - body: string; -}