diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index a859b57..d43ce7e 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -41,11 +41,19 @@ 새 옵션이 필요하면 `src/app/ScreenLayoutRoute.tsx`의 `ScreenRouteHandle`에 필드를 추가하고, 그 값을 `ScreenLayout` prop으로 넘긴다. 현재 필드는 `hasBottomNav`(하단 탭 표시)와 `background`(375×812 프레임 배경 — 헤더 뒤까지 포함이라 화면 본문에서 칠할 수 없다. 신청 완료처럼 Figma가 흰 배경으로 그린 화면만 `"normal"`)다. - `ScreenLayout`은 **라우터를 모르는 prop 기반 컴포넌트**로 유지한다. 라우트 정보(`useMatches`)는 `ScreenLayoutRoute`만 읽는다. +- 화면 스택을 쌓는 이동(목록→상세, 상세→신청 등)은 `navigate(to, { viewTransition: true })`·``으로 슬라이드 전환을 켠다. 뒤로가기는 react-router가 그 이동을 기억해 반대 방향으로 자동 적용하므로 `navigate(-1)`은 그대로 둔다. 브라우저 앞으로가기도 POP이라, `ScreenLayoutRoute`는 히스토리 위치(`history.state.idx`)가 줄어든 POP만 뒤로 방향으로 본다. Bottom Nav·상단 탭처럼 형제 화면을 오가는 이동과 홈으로 돌아가는 이동은 켜지 않는다(즉시 전환). 애니메이션은 `index.css`, 방향은 `ScreenLayoutRoute`가 정한다. - 라우트가 없는 경로는 레이아웃 안의 `path: "*"` 라우트(`ComingSoonScreen`)가 받는다. 하단 탭이 유지돼서 다른 화면으로 돌아갈 수 있다. 구체적인 경로가 `*`보다 항상 우선하므로 배열 순서는 신경 쓰지 않아도 된다. +## 데이터 로딩 + +- 서버 데이터는 TanStack Query로 받는다. API 함수·쿼리·타입은 `entities/<도메인>/`에 두고(`<도메인>Api.ts`·`<도메인>Queries.ts`·`types.ts`), 화면은 `useSuspenseQuery(<도메인>Queries.list())`처럼 쿼리 팩토리로만 받는다. +- 실 API 전까지 API 함수는 `<도메인>Mock.ts`의 목데이터를 `mockResponse`(`lib/mockResponse.ts`)로 돌려준다. 개발 서버에서는 스켈레톤을 확인할 수 있게 500ms 늦게 응답하고, 배포 빌드에서는 바로 응답한다. API가 붙으면 API 함수 안쪽만 바꾼다. +- 화면(`<화면>Screen.tsx`)은 헤더 등록, UI 상태, 이동 같은 동작을 맡고, 데이터를 받는 영역만 `}>`으로 감싼다. `useSuspenseQuery`를 부르고 데이터를 그리는 부분은 `features/<기능>/components/`의 컴포넌트(`EventsList`, `EventsDetailContent` 등)로 분리하고, 이동 같은 동작은 콜백 prop으로 받는다. +- 데이터와 무관한 헤더·탭·필터는 Suspense 밖에서 바로 그리고, 스켈레톤은 데이터 영역의 배치만 따라 그린다. 전용 스켈레톤은 `features/<기능>/components/<화면>Skeleton.tsx`에 두고 WDS `Skeleton`으로 그린다. 헤더가 데이터에 따라 달라지는 화면(공지 상세)은 헤더도 데이터 컴포넌트가 등록하고, 스켈레톤이 `useScreenHeaderSkeleton`으로 헤더 자리를 채운다. + ## 에러 / 비동기 -- async는 try/catch 또는 서버 상태 라이브러리(도입 시)의 에러 상태로 다룬다. **빈 catch 금지**. +- async는 try/catch 또는 TanStack Query의 에러 상태로 다룬다. **빈 catch 금지**. - 사용자에게 보이는 메시지와 개발 로깅을 구분한다. ## 주석 diff --git a/package.json b/package.json index 8f3006b..9c8b93f 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@ncdai/react-wheel-picker": "^1.2.3", + "@tanstack/react-query": "^5.104.0", "@wanteddev/wds": "^3.12.0", "@wanteddev/wds-icon": "^3.12.0", "lottie-react": "^3.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80c978e..28b3a9b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@ncdai/react-wheel-picker': specifier: ^1.2.3 version: 1.2.3(react@19.2.8) + '@tanstack/react-query': + specifier: ^5.104.0 + version: 5.104.0(react@19.2.8) '@wanteddev/wds': specifier: ^3.12.0 version: 3.12.0(@emotion/cache@11.14.0)(@emotion/react@11.14.0(@types/react@19.2.18)(react@19.2.8))(@emotion/serialize@1.3.3)(@emotion/utils@1.4.2)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -716,6 +719,14 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@tanstack/query-core@5.104.0': + resolution: {integrity: sha512-JrC2r/JQlXt7khBSdUpsgxNvybzOg+aITa+ARRMlP2AFo93Y8vIqz077rp+e61mJetSZ0T6AJbwW1JHer1vrPQ==} + + '@tanstack/react-query@5.104.0': + resolution: {integrity: sha512-e1TZmDCQnWIfiDVryIHeA6Idj+Lfx1hORLOhXS/l5wcgvtVD4yYqbTDl378sGQKIi2cSgb0TEMC9cKK8GGoJEw==} + peerDependencies: + react: ^18 || ^19 + '@types/node@24.13.3': resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} @@ -1847,6 +1858,13 @@ snapshots: tailwindcss: 4.3.3 vite: 8.2.2(@types/node@24.13.3)(jiti@2.7.0) + '@tanstack/query-core@5.104.0': {} + + '@tanstack/react-query@5.104.0(react@19.2.8)': + dependencies: + '@tanstack/query-core': 5.104.0 + react: 19.2.8 + '@types/node@24.13.3': dependencies: undici-types: 7.18.2 diff --git a/src/app/ScreenLayoutRoute.tsx b/src/app/ScreenLayoutRoute.tsx index ef28cfb..8266c51 100644 --- a/src/app/ScreenLayoutRoute.tsx +++ b/src/app/ScreenLayoutRoute.tsx @@ -1,4 +1,5 @@ -import { useMatches } from "react-router-dom"; +import { useLayoutEffect, useRef } from "react"; +import { useLocation, useMatches, useNavigationType } from "react-router-dom"; import ScreenLayout, { type ScreenBackground, @@ -32,10 +33,47 @@ function resolveScreenRouteOption( return undefined; } +// createBrowserRouter는 history.state.idx에 히스토리 스택 위치를 적어 둔다(push마다 1씩 증가). +function getHistoryIndex(): number | null { + const state: unknown = window.history.state; + if ( + typeof state === "object" && + state !== null && + "idx" in state && + typeof state.idx === "number" + ) { + return state.idx; + } + return null; +} + // ScreenLayout은 라우터를 모르는 prop 기반 레이아웃으로 두고, 이 컴포넌트가 현재 라우트의 handle을 // 읽어 prop으로 넘기기만 한다. 그래서 옵션이 다른 화면이 생겨도 레이아웃 라우트를 따로 선언하지 않고, // 화면을 오가도 레이아웃이 다시 마운트되지 않는다. function ScreenLayoutRoute() { + const navigationType = useNavigationType(); + const location = useLocation(); + const historyIndexRef = useRef(null); + + // 스택 슬라이드 전환(index.css)의 방향. 뒤로가기면 반대로 빠진다. + // POP은 브라우저 뒤로가기·앞으로가기를 구분하지 않아서, 히스토리 위치가 이전보다 작아졌을 때만 뒤로 본다. + // 위치를 알 수 없는 POP은 앱에서 거의 뒤로가기뿐이라 뒤로 처리한다. + // 전환 애니메이션은 새 화면이 커밋된 뒤 시작되므로, 페인트 전에 도는 layout effect에서 정해 두면 + // 이번 전환부터 바로 반영된다. + // biome-ignore lint/correctness/useExhaustiveDependencies: 이동마다(location.key) 방향을 다시 정한다 + useLayoutEffect(() => { + const historyIndex = getHistoryIndex(); + const previousHistoryIndex = historyIndexRef.current; + historyIndexRef.current = historyIndex; + + const isForwardPop = + historyIndex !== null && + previousHistoryIndex !== null && + historyIndex > previousHistoryIndex; + document.documentElement.dataset.navigation = + navigationType === "POP" && !isForwardPop ? "back" : "forward"; + }, [navigationType, location.key]); + const handles = useMatches() .map((match) => match.handle) .filter(isScreenRouteHandle); diff --git a/src/app/router.tsx b/src/app/router.tsx index db1d4cd..e350fc2 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -28,8 +28,14 @@ const routes = [ children: [ { children: [ - { element: , path: "/" }, - { element: , path: "/bililge" }, + { + element: , + path: "/", + }, + { + element: , + path: "/bililge", + }, { element: , // 카드 없이 구분선으로만 나뉘는 목록이라 화면 전체가 흰 면이다 @@ -122,7 +128,10 @@ const routes = [ path: "/chat", }, // 라우트가 없는 경로 — 레이아웃 안에 둬서 하단 탭이 유지되고, 탭 경로(/event 등)면 그 탭이 활성으로 보인다 - { element: , path: "*" }, + { + element: , + path: "*", + }, ], element: , }, diff --git a/src/components/ui/ScreenLayout.tsx b/src/components/ui/ScreenLayout.tsx index b746d8b..8a4372f 100644 --- a/src/components/ui/ScreenLayout.tsx +++ b/src/components/ui/ScreenLayout.tsx @@ -65,6 +65,7 @@ interface ScreenLayoutProps { // 각자 자기 배경을 그 자리까지 연장해야 해서 컴포넌트마다 h-safe-bottom으로 처리한다. // 배경도 같은 방식으로 라우트 handle에서 받는다 — 헤더 슬롯까지 이 루트 div가 덮기 때문에, // 화면이 헤더와 본문에 따로 배경을 깔 필요가 없다. +// 화면 전환(index.css의 스택 슬라이드)은 이 컬럼만 움직인다 — view-transition-name: screen. function ScreenLayout({ hasBottomNav = true, background = "alternative", @@ -86,7 +87,7 @@ function ScreenLayout({
{/* 화면 전용 배경 포털 대상 — 프레임 안에서 가장 먼저(맨 아래) 그려져서, 투명한 헤더(예: 챗봇 진입 화면)까지 자연스럽게 비쳐 보인다. 콘텐츠가 없는 화면에서는 diff --git a/src/components/ui/ScreenSkeleton.tsx b/src/components/ui/ScreenSkeleton.tsx new file mode 100644 index 0000000..221f0ba --- /dev/null +++ b/src/components/ui/ScreenSkeleton.tsx @@ -0,0 +1,36 @@ +import { Skeleton } from "@wanteddev/wds"; + +import { useScreenHeader } from "@/components/ui/useScreenHeader"; +import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion"; + +// WDS Skeleton의 깜빡임(2초 pulse)은 JS prop으로만 끌 수 있어서, 스켈레톤마다 이 값으로 넘긴다. +export function useSkeletonAnimation() { + return !usePrefersReducedMotion(); +} + +// 상세 화면 ScreenHeader(variant="normal")와 같은 56px 자리에 좌상단 24px 뒤로가기 버튼 자리를 그린다. +// 헤더가 비어 있다가 실제 화면이 들어올 때 본문이 헤더 높이만큼 밀려 내려가지 않도록, 스켈레톤도 헤더 슬롯을 채운다. +function ScreenHeaderSkeleton() { + const animation = useSkeletonAnimation(); + + return ( +
+ +
+ ); +} + +// 헤더가 데이터에 따라 달라지는 화면(공지 상세)의 스켈레톤이 헤더 슬롯을 채울 때 쓴다. +// 데이터가 오면 그 화면의 useScreenHeader가 덮어쓴다. +export function useScreenHeaderSkeleton() { + useScreenHeader(); +} + +// 목록 스켈레톤의 행 key. 행 수만 필요하고 내용이 없어서 미리 만들어 둔다. +export const SKELETON_ROW_KEYS = ["row-1", "row-2", "row-3", "row-4", "row-5"]; diff --git a/src/entities/bililge/bililgeApi.ts b/src/entities/bililge/bililgeApi.ts new file mode 100644 index 0000000..fa32657 --- /dev/null +++ b/src/entities/bililge/bililgeApi.ts @@ -0,0 +1,8 @@ +import { BILILGE_ITEMS } from "@/entities/bililge/bililgeMock"; +import type { BililgeItem } from "@/entities/bililge/types"; +import { mockResponse } from "@/lib/mockResponse"; + +// 실 API가 붙으면 함수 안쪽만 요청 코드로 바꾼다 — 화면은 bililgeQueries로만 데이터를 받는다. +export function fetchBililgeItems(): Promise { + return mockResponse(BILILGE_ITEMS); +} diff --git a/src/features/bililge/constants/bililgeItems.ts b/src/entities/bililge/bililgeMock.ts similarity index 96% rename from src/features/bililge/constants/bililgeItems.ts rename to src/entities/bililge/bililgeMock.ts index 4f2f477..537161a 100644 --- a/src/features/bililge/constants/bililgeItems.ts +++ b/src/entities/bililge/bililgeMock.ts @@ -13,13 +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"; - -export interface BililgeItem { - id: string; - name: string; - quantity: number; - icon: string; -} +import type { BililgeItem } from "@/entities/bililge/types"; // Figma: 빌릴게 Item Grid (nodeId 1243:73343) 순서·물품명·수량을 그대로 옮긴 목데이터 — 실 API 연동 전까지 사용 export const BILILGE_ITEMS: BililgeItem[] = [ diff --git a/src/entities/bililge/bililgeQueries.ts b/src/entities/bililge/bililgeQueries.ts new file mode 100644 index 0000000..2078511 --- /dev/null +++ b/src/entities/bililge/bililgeQueries.ts @@ -0,0 +1,12 @@ +import { queryOptions } from "@tanstack/react-query"; + +import { fetchBililgeItems } from "@/entities/bililge/bililgeApi"; + +// 쿼리 키와 요청 함수를 한곳에 묶어 둔다. 화면은 useSuspenseQuery(bililgeQueries.items())처럼 쓴다. +export const bililgeQueries = { + items: () => + queryOptions({ + queryFn: fetchBililgeItems, + queryKey: ["bililge", "items"], + }), +}; diff --git a/src/entities/bililge/types.ts b/src/entities/bililge/types.ts new file mode 100644 index 0000000..c02d71d --- /dev/null +++ b/src/entities/bililge/types.ts @@ -0,0 +1,6 @@ +export interface BililgeItem { + id: string; + name: string; + quantity: number; + icon: string; +} diff --git a/src/entities/events/eventsApi.ts b/src/entities/events/eventsApi.ts new file mode 100644 index 0000000..ec392ea --- /dev/null +++ b/src/entities/events/eventsApi.ts @@ -0,0 +1,13 @@ +import { EVENTS } from "@/entities/events/eventsMock"; +import type { EventItem } from "@/entities/events/types"; +import { mockResponse } from "@/lib/mockResponse"; + +// 실 API가 붙으면 함수 안쪽만 요청 코드로 바꾼다 — 화면은 eventsQueries로만 데이터를 받는다. +export function fetchEvents(): Promise { + return mockResponse(EVENTS); +} + +// 없는 행사는 null — 상세 화면이 "행사를 찾을 수 없어요" 빈 상태를 그린다. +export function fetchEvent(eventId: string): Promise { + return mockResponse(EVENTS.find((event) => event.id === eventId) ?? null); +} diff --git a/src/entities/events/eventsMock.ts b/src/entities/events/eventsMock.ts new file mode 100644 index 0000000..653f22c --- /dev/null +++ b/src/entities/events/eventsMock.ts @@ -0,0 +1,71 @@ +import type { EventItem } from "@/entities/events/types"; + +// Figma 상세(1133:42433 모집중 / 1156:53992 모집예정)의 메타데이터·본문은 목업이라 행사별로 다르지 않다. +// 목록 카드가 같은 행사명 4개를 상태만 바꿔 보여주는 것과 같은 이유로, 상세 내용도 공통 상수로 두고 +// 상태별로 갈리는 값(뱃지 문구, CTA 문구)만 항목마다 다르게 준다. +const MOCK_SCHEDULE = "4월 15일 (수) 18:30 ~"; +const MOCK_LOCATION = "미래관 4층 신관 입구 (예대 방면)"; +const MOCK_AUDIENCE = ["소프트웨어융합대학 과학생회비 납부자", "선착순 150명"]; +const MOCK_DESCRIPTION = `안녕하십니까, 제10대 소프트웨어융합대학 학생회 ‘에코’입니다. + +기말고사를 준비하고 계신 학우 여러분을 응원하기 위해 간식행사를 진행합니다 🍱✨ + +시험기간 동안 든든하게 힘내시길 바라며, 많은 관심과 참여 부탁드립니다! + +📌 간식행사 일정 +▪️ 일시 : 6월 1일 (월) 11:00 ~ +▪️ 장소 : 미래관 4층 신관 입구 (예대방면) + +📌 대상 +▪️ 소프트웨어융합대학 재학생 선착순 180명 +※ 과학생회비 미납부자 참여 가능 + +📌 간식행사 메뉴 +▪️ 돈까스 도련님 도시락 +▪️ 나랑드사이다 제로 + +📌 유의 사항 +▪️ 소프트웨어융합대학 학생임을 증명할 수 있는 모바일 학생증 혹은 실물 학생증을 지참해주시기 바랍니다. +▪️ 1인당 1세트만 수령 가능하며, 선착순 수량 소진 시 수령이 불가능합니다. + +많은 학우 여러분의 관심과 참여 부탁드립니다. +감사합니다 😊`; + +// Figma: 행사 Event List (nodeId 1243:70866) 문구를 그대로 옮긴 목데이터 — 실 API 연동 전까지 사용. +// +// Figma 목업은 같은 행사명 4개(모집중 1 / 모집예정 1 / 모집종료 2)를 상태만 바꿔 보여주는데, +// 여기서는 모집중을 비우고 모집종료 중복도 하나로 줄였다(모집예정 1 / 모집종료 1). +// +// Empty State(1165:62713)는 Figma가 "모집중" 필터 버전으로만 그려져 있고, 일러스트·문구와 +// "아카이빙 둘러보기" 버튼이 그 조합의 스펙이다. 목데이터에 모집중 항목이 있으면 이 화면을 +// 아예 볼 수 없어서 모집중을 비웠다. 대신 모집중 상세(1133:42433)는 카드로 진입할 수 없다 — +// 실 API가 붙으면 사라질 제약이고, 지금 확인이 필요하면 아래 항목 하나의 status를 "open"으로 +// 되돌리면 된다. 모집예정은 상세 디자인(1156:53992)이 있어 진입 가능하게 남겼다. +export const EVENTS: EventItem[] = [ + { + actionLabel: "모집종료", + audience: MOCK_AUDIENCE, + description: MOCK_DESCRIPTION, + eventDate: "행사일 2026.06.04", + id: "sw-sports-day-closed", + imageCount: 7, + location: MOCK_LOCATION, + schedule: MOCK_SCHEDULE, + status: "closed", + statusLabel: "모집종료", + title: "소프트웨어융합대학 체육대회", + }, + { + actionLabel: "8월 10일 오픈", + audience: MOCK_AUDIENCE, + description: MOCK_DESCRIPTION, + eventDate: "행사일 2026.06.04", + id: "sw-sports-day-upcoming", + imageCount: 7, + location: MOCK_LOCATION, + schedule: MOCK_SCHEDULE, + status: "upcoming", + statusLabel: "모집예정", + title: "소프트웨어융합대학 체육대회", + }, +]; diff --git a/src/entities/events/eventsQueries.ts b/src/entities/events/eventsQueries.ts new file mode 100644 index 0000000..34afe36 --- /dev/null +++ b/src/entities/events/eventsQueries.ts @@ -0,0 +1,17 @@ +import { queryOptions } from "@tanstack/react-query"; + +import { fetchEvent, fetchEvents } from "@/entities/events/eventsApi"; + +// 쿼리 키와 요청 함수를 한곳에 묶어 둔다. 화면은 useSuspenseQuery(eventsQueries.list())처럼 쓴다. +export const eventsQueries = { + detail: (eventId: string) => + queryOptions({ + queryFn: () => fetchEvent(eventId), + queryKey: ["events", eventId], + }), + list: () => + queryOptions({ + queryFn: fetchEvents, + queryKey: ["events"], + }), +}; diff --git a/src/entities/events/types.ts b/src/entities/events/types.ts new file mode 100644 index 0000000..140d997 --- /dev/null +++ b/src/entities/events/types.ts @@ -0,0 +1,20 @@ +export type EventStatus = "open" | "upcoming" | "closed"; + +export interface EventItem { + id: string; + title: string; + eventDate: string; + status: EventStatus; + statusLabel: string; + actionLabel: string; + /** 상세 상단 Hero 이미지 개수 — 실 이미지 API 전까지 PageCounter 표기용 */ + imageCount: number; + /** 상세 메타데이터 "일시" */ + schedule: string; + /** 상세 메타데이터 "장소" */ + location: string; + /** 상세 메타데이터 "대상" — Figma가 두 줄로 쪼개 보여줘서 줄 단위로 들고 있는다 */ + audience: string[]; + /** 상세 본문. 줄바꿈을 그대로 살려 렌더링한다 */ + description: string; +} diff --git a/src/entities/notices/noticesApi.ts b/src/entities/notices/noticesApi.ts new file mode 100644 index 0000000..ec4f283 --- /dev/null +++ b/src/entities/notices/noticesApi.ts @@ -0,0 +1,13 @@ +import { NOTICES } from "@/entities/notices/noticesMock"; +import type { Notice } from "@/entities/notices/types"; +import { mockResponse } from "@/lib/mockResponse"; + +// 실 API가 붙으면 함수 안쪽만 요청 코드로 바꾼다 — 화면은 noticesQueries로만 데이터를 받는다. +export function fetchNotices(): Promise { + return mockResponse(NOTICES); +} + +// 없는 공지는 null — 상세 화면이 "존재하지 않는 공지예요"를 그린다. +export function fetchNotice(noticeId: string): Promise { + return mockResponse(NOTICES.find((notice) => notice.id === noticeId) ?? null); +} diff --git a/src/features/notices/constants/notices.ts b/src/entities/notices/noticesMock.ts similarity index 86% rename from src/features/notices/constants/notices.ts rename to src/entities/notices/noticesMock.ts index fc61f53..fa1ce56 100644 --- a/src/features/notices/constants/notices.ts +++ b/src/entities/notices/noticesMock.ts @@ -1,16 +1,4 @@ -export type NoticeCategory = "일반" | "제휴"; - -export interface Notice { - id: string; - title: string; - date: string; - category: NoticeCategory; - isPinned?: boolean; - hasThumbnail?: boolean; - /** 상세 화면 이미지 갤러리 총 장수 — hasThumbnail일 때만 의미가 있다. */ - photoCount?: number; - body: string; -} +import type { Notice } from "@/entities/notices/types"; // Figma: 공지 상세 (nodeId 1256:81842) 목업 본문 — 목업 데이터라 공지 5건이 본문을 공유한다. const NOTICE_BODY = `안녕하십니까, 제10대 소프트웨어융합대학 학생회 '에코'입니다. diff --git a/src/entities/notices/noticesQueries.ts b/src/entities/notices/noticesQueries.ts new file mode 100644 index 0000000..df6611e --- /dev/null +++ b/src/entities/notices/noticesQueries.ts @@ -0,0 +1,17 @@ +import { queryOptions } from "@tanstack/react-query"; + +import { fetchNotice, fetchNotices } from "@/entities/notices/noticesApi"; + +// 쿼리 키와 요청 함수를 한곳에 묶어 둔다. 화면은 useSuspenseQuery(noticesQueries.list())처럼 쓴다. +export const noticesQueries = { + detail: (noticeId: string) => + queryOptions({ + queryFn: () => fetchNotice(noticeId), + queryKey: ["notices", noticeId], + }), + list: () => + queryOptions({ + queryFn: fetchNotices, + queryKey: ["notices"], + }), +}; diff --git a/src/entities/notices/types.ts b/src/entities/notices/types.ts new file mode 100644 index 0000000..4419ef1 --- /dev/null +++ b/src/entities/notices/types.ts @@ -0,0 +1,13 @@ +export type NoticeCategory = "일반" | "제휴"; + +export interface Notice { + id: string; + title: string; + date: string; + category: NoticeCategory; + isPinned?: boolean; + hasThumbnail?: boolean; + /** 상세 화면 이미지 갤러리 총 장수 — hasThumbnail일 때만 의미가 있다. */ + photoCount?: number; + body: string; +} diff --git a/src/features/bililge/BililgeListScreen.tsx b/src/features/bililge/BililgeListScreen.tsx index 9408187..527d90e 100644 --- a/src/features/bililge/BililgeListScreen.tsx +++ b/src/features/bililge/BililgeListScreen.tsx @@ -4,18 +4,16 @@ import { TopNavigationButton, } from "@wanteddev/wds"; import { IconBell, IconSearch } from "@wanteddev/wds-icon"; -import { startTransition, useState } from "react"; +import { Suspense, startTransition, useState } from "react"; 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 BililgeItemCard from "@/features/bililge/components/BililgeItemCard"; +import BililgeItemList from "@/features/bililge/components/BililgeItemList"; +import BililgeListSkeleton from "@/features/bililge/components/BililgeListSkeleton"; import BililgeRentalSheet from "@/features/bililge/components/BililgeRentalSheet"; import BililgeReturnSection from "@/features/bililge/components/BililgeReturnSection"; -import { - BILILGE_ITEMS, - type BililgeItem, -} from "@/features/bililge/constants/bililgeItems"; // Figma: 빌릴게 (nodeId 1243:73331) function BililgeListScreen() { @@ -62,26 +60,21 @@ function BililgeListScreen() {
{tab === "rent" ? ( -
- {BILILGE_ITEMS.map((item) => ( - { - // 바텀시트를 여는 것(슬라이드 애니메이션)은 즉시 반영하고, 그 안의 휠 - // 피커(특히 분 60개) 마운트처럼 무거운 작업은 startTransition으로 낮은 - // 우선순위로 미뤄서 첫 프레임이 버벅이지 않게 한다 — 처음 열 때만 해당하고, - // rentalItem은 닫아도 null로 안 돌아가서 두 번째부터는 이 마운트 비용 자체가 없다. - setRentalSheetOpen(true); - startTransition(() => { - setRentalItem(item); - }); - }} - subtitle={`수량 ${item.quantity}`} - /> - ))} -
+ // 헤더·토글·필터는 데이터와 무관해서 바로 그리고, 데이터를 받는 목록 자리만 스켈레톤으로 채운다 + }> + { + // 바텀시트를 여는 것(슬라이드 애니메이션)은 즉시 반영하고, 그 안의 휠 + // 피커(특히 분 60개) 마운트처럼 무거운 작업은 startTransition으로 낮은 + // 우선순위로 미뤄서 첫 프레임이 버벅이지 않게 한다 — 처음 열 때만 해당하고, + // rentalItem은 닫아도 null로 안 돌아가서 두 번째부터는 이 마운트 비용 자체가 없다. + setRentalSheetOpen(true); + startTransition(() => { + setRentalItem(item); + }); + }} + /> + ) : ( setTab("rent")} /> )} diff --git a/src/features/bililge/components/BililgeItemList.tsx b/src/features/bililge/components/BililgeItemList.tsx new file mode 100644 index 0000000..1ba1d20 --- /dev/null +++ b/src/features/bililge/components/BililgeItemList.tsx @@ -0,0 +1,31 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; + +import { bililgeQueries } from "@/entities/bililge/bililgeQueries"; +import type { BililgeItem } from "@/entities/bililge/types"; +import BililgeItemCard from "@/features/bililge/components/BililgeItemCard"; + +interface BililgeItemListProps { + onRentRequest: (item: BililgeItem) => void; +} + +// 대여 탭의 물품 목록 데이터를 받아 그리는 부분. 데이터를 받는 동안은 BililgeListScreen의 +// Suspense가 BililgeListSkeleton을 보여준다. 대여 시트를 여는 동작은 Screen이 콜백으로 넘긴다. +function BililgeItemList({ onRentRequest }: BililgeItemListProps) { + const { data: items } = useSuspenseQuery(bililgeQueries.items()); + + return ( +
+ {items.map((item) => ( + onRentRequest(item)} + subtitle={`수량 ${item.quantity}`} + /> + ))} +
+ ); +} + +export default BililgeItemList; diff --git a/src/features/bililge/components/BililgeListSkeleton.tsx b/src/features/bililge/components/BililgeListSkeleton.tsx new file mode 100644 index 0000000..7d864e8 --- /dev/null +++ b/src/features/bililge/components/BililgeListSkeleton.tsx @@ -0,0 +1,57 @@ +import { Skeleton } from "@wanteddev/wds"; + +import { + SKELETON_ROW_KEYS, + useSkeletonAnimation, +} from "@/components/ui/ScreenSkeleton"; + +// 대여 물품 데이터를 받는 동안 BililgeListScreen의 목록 자리에 보이는 스켈레톤 — 헤더·토글·필터는 +// 화면이 바로 그리므로 물품 카드(BililgeItemCard) 배치만 따른다. 카드는 흰 면이라 회색 화면 배경 위에 +// 실제 카드와 같은 흰 박스를 깔아 둔다. +function BililgeListSkeleton() { + const animation = useSkeletonAnimation(); + + return ( +
+ {SKELETON_ROW_KEYS.map((key) => ( +
+ +
+ + +
+ +
+ ))} +
+ ); +} + +export default BililgeListSkeleton; diff --git a/src/features/bililge/components/BililgeRentalSheet.tsx b/src/features/bililge/components/BililgeRentalSheet.tsx index 719b737..8912f63 100644 --- a/src/features/bililge/components/BililgeRentalSheet.tsx +++ b/src/features/bililge/components/BililgeRentalSheet.tsx @@ -9,8 +9,8 @@ import { IconCircleInfo } from "@wanteddev/wds-icon"; import { useEffect, useState } from "react"; import BottomSheet from "@/components/ui/BottomSheet"; +import type { BililgeItem } from "@/entities/bililge/types"; import BililgeItemCard from "@/features/bililge/components/BililgeItemCard"; -import type { BililgeItem } from "@/features/bililge/constants/bililgeItems"; interface BililgeRentalSheetProps { item: BililgeItem | null; diff --git a/src/features/events/EventsApplicationScreen.tsx b/src/features/events/EventsApplicationScreen.tsx index 188aeab..a2c9c1b 100644 --- a/src/features/events/EventsApplicationScreen.tsx +++ b/src/features/events/EventsApplicationScreen.tsx @@ -96,7 +96,10 @@ function EventsApplicationScreen() { } // 제출이 끝난 폼으로는 돌아갈 수 없어야 해서 히스토리를 남기지 않고 바꿔치운다 const resultPath = result === "closed" ? "closed" : "complete"; - navigate(`/events/${eventId}/apply/${resultPath}`, { replace: true }); + navigate(`/events/${eventId}/apply/${resultPath}`, { + replace: true, + viewTransition: true, + }); }; useScreenHeader( diff --git a/src/features/events/EventsDetailScreen.tsx b/src/features/events/EventsDetailScreen.tsx index d407a92..cd4ea7c 100644 --- a/src/features/events/EventsDetailScreen.tsx +++ b/src/features/events/EventsDetailScreen.tsx @@ -1,181 +1,25 @@ -import { - ActionArea, - ActionAreaButton, - Divider, - TopNavigationButton, - Typography, -} from "@wanteddev/wds"; -import { IconChevronLeft } from "@wanteddev/wds-icon"; +import { Suspense } from "react"; import { useNavigate, useParams } from "react-router-dom"; -import PhotoGallery from "@/components/ui/PhotoGallery"; -import EventsEmptyState from "@/features/events/components/EventsEmptyState"; -import EventsStatusBadge from "@/features/events/components/EventsStatusBadge"; -import { EVENTS } from "@/features/events/constants/events"; +import EventsDetailContent from "@/features/events/components/EventsDetailContent"; +import EventsDetailSkeleton from "@/features/events/components/EventsDetailSkeleton"; -// Figma: 행사 상세 (nodeId 1133:42433 모집중 / 1156:53992 모집예정) -// -// 헤더를 useScreenHeader로 등록하지 않는다 — Figma는 뒤로가기 버튼이 Hero 이미지 위에 떠 있는 -// 오버레이인데 ScreenLayout의 헤더 슬롯은 본문 위에 자리를 차지하는 구조라 그대로는 못 맞춘다. -// 훅을 호출하지 않으면 슬롯이 null(0px)로 남아서, Hero가 화면 최상단부터 시작한다. -// 공용 ScreenHeader에 overlay 옵션을 넣는 방안도 검토했지만 다른 화면에 영향이 가서 로컬로 뒀다. -// -// 상태별로 갈리는 건 뱃지와 하단 CTA뿐이다 — 모집중만 활성이고 나머지는 disabled에 -// 목데이터의 actionLabel("8월 10일 오픈" / "모집종료")이 그대로 들어간다(목록 카드와 같은 규칙). +// 행사 상세 화면 — 이동 같은 화면 동작만 정하고, 행사 데이터를 받는 동안은 상세 배치를 따른 +// 스켈레톤을 보여준다. 헤더가 없는 화면이라(EventsDetailContent 주석 참고) 화면 전체가 데이터 영역이다. function EventsDetailScreen() { const navigate = useNavigate(); - const { eventId } = useParams<{ eventId: string }>(); - const event = EVENTS.find((item) => item.id === eventId); - - if (!event) { - return ( -
- -
- ); - } - - const isOpen = event.status === "open"; - const hasMultipleImages = event.imageCount > 1; + const { eventId = "" } = useParams<{ eventId: string }>(); return ( - // Figma 상세 루트 배경은 Background/Normal/Normal(#FFFFFF)이다 — ScreenLayout 기본 - // 배경(Background/Normal/Alternative, #F7F7F8)과 다르다. 흰 배경이어야 Action Area의 - // 흰 그라데이션이 본문으로 자연스럽게 사라진다(회색 위에 깔면 경계선으로 보인다). - // - // Action Area는 Figma처럼 absolute로 본문을 덮는다. 아래 형제로 두면 스크롤 영역이 - // Action Area 위에서 끝나 버려서, 글이 그 아래로 흘러 들어가며 사라지는 효과가 안 난다. -
-
- {/* Hero — 실제 행사 이미지 API 전까지 Figma와 같은 단색 placeholder. - Figma는 375×375 정사각이라 폭이 유동인 지금 레이아웃에서는 aspect-square로 둔다. - 이미지가 여러 장이면 가로 스크롤 스냅으로 한 장씩 넘긴다(공지 상세와 같은 PhotoGallery). */} - - navigate(-1)} - variant="icon" - > - - -
- } - photoCount={event.imageCount} - showCounter={hasMultipleImages} - slideClassName="aspect-square" - /> - -
-
-
- - - {event.title} - -
- - {/* Figma는 라벨 칼럼(28px)과 값 칼럼을 gap 12로 나란히 두고, 각 칼럼 안은 gap 8이다 */} -
-
- {["일시", "장소", "대상"].map((label) => ( - - {label} - - ))} -
-
- - {event.schedule} - - - {event.location} - - {/* 대상만 여러 줄 — Figma도 한 값 안에서 줄을 나눠 놨다 */} -
- {event.audience.map((line) => ( - - {line} - - ))} -
-
-
-
- - - - {/* 본문은 Figma "Label 1/Reading - Regular"(14px, line-height 1.571) = label1-reading. - 목데이터가 줄바꿈을 그대로 들고 있어서 whitespace-pre-wrap으로 살린다. */} - - {event.description} - -
- - {/* Action Area가 본문을 덮으므로 끝부분이 영구히 가리지 않도록 같은 높이를 비워둔다. - 96px = Action Area 위아래 padding 20+20 + 버튼 56. 데스크톱 프레임에서는 - 홈 인디케이터 여백 14px이 더 붙는다(아래 filler와 같은 규칙). */} -
-
- -
- - {/* WDS ActionAreaButton은 항상 Button size="large"(padding 12px 28px → 48px)로 그리는데, - Figma Main Action은 padding 16px 28px(56px)이라 세로 padding만 sx로 맞춘다. */} - navigate(`/events/${event.id}/apply`)} - sx={{ paddingBlock: "16px" }} - > - {event.actionLabel} - - - {/* Figma Action Area(110px)는 버튼 아래가 iOS Home Bar 여백까지 합쳐 34px인데, - WDS ActionArea는 아래 padding 20px만 준다 — 모자란 14px을 여기서 더한다. - 앱 WebView에서는 네이티브 세이프에어리어와 중복이라 데스크톱 프레임에서만 남긴다(BottomNav와 같은 규칙). */} -
-
-
+ }> + + navigate(`/events/${id}/apply`, { viewTransition: true }) + } + onBack={() => navigate(-1)} + /> + ); } diff --git a/src/features/events/EventsListScreen.tsx b/src/features/events/EventsListScreen.tsx index c9ac9ad..2bdfd49 100644 --- a/src/features/events/EventsListScreen.tsx +++ b/src/features/events/EventsListScreen.tsx @@ -1,50 +1,19 @@ import { - Divider, SegmentedControl, SegmentedControlItem, TopNavigationButton, Typography, } from "@wanteddev/wds"; import { IconBell, IconSearch } from "@wanteddev/wds-icon"; -import { Fragment, useState } from "react"; +import { Suspense, useState } from "react"; import { useNavigate } from "react-router-dom"; import FilterChipGroup from "@/components/ui/FilterChipGroup"; import ScreenHeader from "@/components/ui/ScreenHeader"; import { useScreenHeader } from "@/components/ui/useScreenHeader"; -import EventsCard from "@/features/events/components/EventsCard"; -import EventsEmptyState from "@/features/events/components/EventsEmptyState"; -import { - EVENT_STATUS_FILTERS, - EVENTS, -} from "@/features/events/constants/events"; - -// Figma: 행사 모집중 empty (nodeId 1165:62713)는 "모집중" 필터 버전만 준다 — -// 일러스트·레이아웃·"아카이빙 둘러보기" 버튼이 이 조합의 스펙이다. -// 나머지 필터 문구는 디자인에 없어서 같은 톤으로 맞춰 쓴 것이고, 확정 문구가 나오면 교체한다. -// 버튼은 Figma가 지정한 모집중에만 노출한다 — 모집종료 필터에서 "지난 행사를 보세요"는 모순이 된다. -const EMPTY_STATE_BY_FILTER: Record< - string, - { title: string; description: string; actionLabel?: string } -> = { - all: { - description: "새로운 행사가 열리면 알려드릴게요", - title: "등록된 행사가 없어요", - }, - closed: { - description: "종료된 행사가 아직 없어요", - title: "지난 행사가 없어요", - }, - open: { - actionLabel: "아카이빙 둘러보기", - description: "지난 행사의 활동을 확인해 보세요", - title: "모집 중인 행사가 없어요", - }, - upcoming: { - description: "새로운 행사가 열리면 알려드릴게요", - title: "모집 예정인 행사가 없어요", - }, -}; +import EventsList from "@/features/events/components/EventsList"; +import EventsListSkeleton from "@/features/events/components/EventsListSkeleton"; +import { EVENT_STATUS_FILTERS } from "@/features/events/constants/events"; // Figma: 행사 (nodeId 1243:70854) function EventsListScreen() { @@ -68,12 +37,6 @@ function EventsListScreen() { />, ); - // 빌릴게 카테고리 필터와 달리 모집 상태는 목데이터에 이미 들어있어서 실제로 걸러낼 수 있다. - const visibleEvents = - statusFilter === "all" - ? EVENTS - : EVENTS.filter((event) => event.status === statusFilter); - return ( <> {/* 행사/신청내역 토글 + 필터는 화면마다 값·동작이 달라 헤더가 아니라 화면이 직접 그린다. @@ -99,37 +62,19 @@ function EventsListScreen() {
- {tab === "event" && visibleEvents.length === 0 ? ( - // Figma는 Empty State를 목록 영역(헤더·Bottom Nav 사이) 가운데에 둔다. - // 카드 목록과 같은 gap-6 래퍼 안에 넣으면 flex-1이 높이를 못 받아 위에 붙어버려서, - // 빈 목록일 때는 래퍼를 대체해 h-full로 가운데 정렬한다. -
- }> + + navigate(`/events/${eventId}/apply`, { viewTransition: true }) + } + onSelect={(eventId) => + navigate(`/events/${eventId}`, { viewTransition: true }) + } + statusFilter={statusFilter} /> -
- ) : tab === "event" ? ( -
- {visibleEvents.map((event, index) => ( - - {index > 0 && ( -
- -
- )} - navigate(`/events/${event.id}/apply`)} - onSelect={() => navigate(`/events/${event.id}`)} - status={event.status} - statusLabel={event.statusLabel} - title={event.title} - /> -
- ))} -
+ ) : (
void; + onApply: (eventId: string) => void; +} + +// Figma: 행사 상세 (nodeId 1133:42433 모집중 / 1156:53992 모집예정) +// 행사 데이터를 받아 그리는 부분. 데이터를 받는 동안은 EventsDetailScreen의 Suspense가 +// EventsDetailSkeleton을 보여준다. 이동 같은 화면 동작은 Screen이 콜백으로 넘긴다. +// +// 헤더를 useScreenHeader로 등록하지 않는다 — Figma는 뒤로가기 버튼이 Hero 이미지 위에 떠 있는 +// 오버레이인데 ScreenLayout의 헤더 슬롯은 본문 위에 자리를 차지하는 구조라 그대로는 못 맞춘다. +// 훅을 호출하지 않으면 슬롯이 null(0px)로 남아서, Hero가 화면 최상단부터 시작한다. +// 공용 ScreenHeader에 overlay 옵션을 넣는 방안도 검토했지만 다른 화면에 영향이 가서 로컬로 뒀다. +// +// 상태별로 갈리는 건 뱃지와 하단 CTA뿐이다 — 모집중만 활성이고 나머지는 disabled에 +// 목데이터의 actionLabel("8월 10일 오픈" / "모집종료")이 그대로 들어간다(목록 카드와 같은 규칙). +function EventsDetailContent({ + eventId, + onBack, + onApply, +}: EventsDetailContentProps) { + const { data: event } = useSuspenseQuery(eventsQueries.detail(eventId)); + + if (!event) { + return ( +
+ +
+ ); + } + + const isOpen = event.status === "open"; + const hasMultipleImages = event.imageCount > 1; + + return ( + // Figma 상세 루트 배경은 Background/Normal/Normal(#FFFFFF)이다 — ScreenLayout 기본 + // 배경(Background/Normal/Alternative, #F7F7F8)과 다르다. 흰 배경이어야 Action Area의 + // 흰 그라데이션이 본문으로 자연스럽게 사라진다(회색 위에 깔면 경계선으로 보인다). + // + // Action Area는 Figma처럼 absolute로 본문을 덮는다. 아래 형제로 두면 스크롤 영역이 + // Action Area 위에서 끝나 버려서, 글이 그 아래로 흘러 들어가며 사라지는 효과가 안 난다. +
+
+ {/* Hero — 실제 행사 이미지 API 전까지 Figma와 같은 단색 placeholder. + Figma는 375×375 정사각이라 폭이 유동인 지금 레이아웃에서는 aspect-square로 둔다. + 이미지가 여러 장이면 가로 스크롤 스냅으로 한 장씩 넘긴다(공지 상세와 같은 PhotoGallery). */} + + + + +
+ } + photoCount={event.imageCount} + showCounter={hasMultipleImages} + slideClassName="aspect-square" + /> + +
+
+
+ + + {event.title} + +
+ + {/* Figma는 라벨 칼럼(28px)과 값 칼럼을 gap 12로 나란히 두고, 각 칼럼 안은 gap 8이다 */} +
+
+ {["일시", "장소", "대상"].map((label) => ( + + {label} + + ))} +
+
+ + {event.schedule} + + + {event.location} + + {/* 대상만 여러 줄 — Figma도 한 값 안에서 줄을 나눠 놨다 */} +
+ {event.audience.map((line) => ( + + {line} + + ))} +
+
+
+
+ + + + {/* 본문은 Figma "Label 1/Reading - Regular"(14px, line-height 1.571) = label1-reading. + 목데이터가 줄바꿈을 그대로 들고 있어서 whitespace-pre-wrap으로 살린다. */} + + {event.description} + +
+ + {/* Action Area가 본문을 덮으므로 끝부분이 영구히 가리지 않도록 같은 높이를 비워둔다. + 96px = Action Area 위아래 padding 20+20 + 버튼 56. 데스크톱 프레임에서는 + 홈 인디케이터 여백 14px이 더 붙는다(아래 filler와 같은 규칙). */} +
+
+ +
+ + {/* WDS ActionAreaButton은 항상 Button size="large"(padding 12px 28px → 48px)로 그리는데, + Figma Main Action은 padding 16px 28px(56px)이라 세로 padding만 sx로 맞춘다. */} + onApply(event.id)} + sx={{ paddingBlock: "16px" }} + > + {event.actionLabel} + + + {/* Figma Action Area(110px)는 버튼 아래가 iOS Home Bar 여백까지 합쳐 34px인데, + WDS ActionArea는 아래 padding 20px만 준다 — 모자란 14px을 여기서 더한다. + 앱 WebView에서는 네이티브 세이프에어리어와 중복이라 데스크톱 프레임에서만 남긴다(BottomNav와 같은 규칙). */} +
+
+
+ ); +} + +export default EventsDetailContent; diff --git a/src/features/events/components/EventsDetailSkeleton.tsx b/src/features/events/components/EventsDetailSkeleton.tsx new file mode 100644 index 0000000..7593fb3 --- /dev/null +++ b/src/features/events/components/EventsDetailSkeleton.tsx @@ -0,0 +1,55 @@ +import { Skeleton } from "@wanteddev/wds"; + +import { + SKELETON_ROW_KEYS, + useSkeletonAnimation, +} from "@/components/ui/ScreenSkeleton"; + +// 행사 데이터를 받는 동안 EventsDetailScreen에 보이는 자리 — 정사각 Hero 이미지, 상태 뱃지·제목, 일시/장소/대상 +// 정보 행을 그대로 따른다. 실제 화면처럼 헤더 없이 Hero가 맨 위에서 시작하므로 헤더 슬롯은 비워 둔다. +function EventsDetailSkeleton() { + const animation = useSkeletonAnimation(); + + return ( +
+ +
+
+ + +
+
+ {SKELETON_ROW_KEYS.slice(0, 3).map((key) => ( + + ))} +
+
+
+ ); +} + +export default EventsDetailSkeleton; diff --git a/src/features/events/components/EventsList.tsx b/src/features/events/components/EventsList.tsx new file mode 100644 index 0000000..16b68bb --- /dev/null +++ b/src/features/events/components/EventsList.tsx @@ -0,0 +1,91 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; +import { Divider } from "@wanteddev/wds"; +import { Fragment } from "react"; + +import { eventsQueries } from "@/entities/events/eventsQueries"; +import EventsCard from "@/features/events/components/EventsCard"; +import EventsEmptyState from "@/features/events/components/EventsEmptyState"; + +// Figma: 행사 모집중 empty (nodeId 1165:62713)는 "모집중" 필터 버전만 준다 — +// 일러스트·레이아웃·"아카이빙 둘러보기" 버튼이 이 조합의 스펙이다. +// 나머지 필터 문구는 디자인에 없어서 같은 톤으로 맞춰 쓴 것이고, 확정 문구가 나오면 교체한다. +// 버튼은 Figma가 지정한 모집중에만 노출한다 — 모집종료 필터에서 "지난 행사를 보세요"는 모순이 된다. +const EMPTY_STATE_BY_FILTER: Record< + string, + { title: string; description: string; actionLabel?: string } +> = { + all: { + description: "새로운 행사가 열리면 알려드릴게요", + title: "등록된 행사가 없어요", + }, + closed: { + description: "종료된 행사가 아직 없어요", + title: "지난 행사가 없어요", + }, + open: { + actionLabel: "아카이빙 둘러보기", + description: "지난 행사의 활동을 확인해 보세요", + title: "모집 중인 행사가 없어요", + }, + upcoming: { + description: "새로운 행사가 열리면 알려드릴게요", + title: "모집 예정인 행사가 없어요", + }, +}; + +interface EventsListProps { + statusFilter: string; + onSelect: (eventId: string) => void; + onApply: (eventId: string) => void; +} + +// 행사 목록 데이터를 받아 그리는 부분. 데이터를 받는 동안은 EventsListScreen의 Suspense가 +// EventsListSkeleton을 보여준다. 이동 같은 화면 동작은 Screen이 콜백으로 넘긴다. +function EventsList({ statusFilter, onSelect, onApply }: EventsListProps) { + const { data: events } = useSuspenseQuery(eventsQueries.list()); + + // 빌릴게 카테고리 필터와 달리 모집 상태는 목데이터에 이미 들어있어서 실제로 걸러낼 수 있다. + const visibleEvents = + statusFilter === "all" + ? events + : events.filter((event) => event.status === statusFilter); + + if (visibleEvents.length === 0) { + return ( + // Figma는 Empty State를 목록 영역(헤더·Bottom Nav 사이) 가운데에 둔다. + // 카드 목록과 같은 gap-6 래퍼 안에 넣으면 flex-1이 높이를 못 받아 위에 붙어버려서, + // 빈 목록일 때는 래퍼를 대체해 h-full로 가운데 정렬한다. +
+ +
+ ); + } + + return ( +
+ {visibleEvents.map((event, index) => ( + + {index > 0 && ( +
+ +
+ )} + onApply(event.id)} + onSelect={() => onSelect(event.id)} + status={event.status} + statusLabel={event.statusLabel} + title={event.title} + /> +
+ ))} +
+ ); +} + +export default EventsList; diff --git a/src/features/events/components/EventsListSkeleton.tsx b/src/features/events/components/EventsListSkeleton.tsx new file mode 100644 index 0000000..942e83e --- /dev/null +++ b/src/features/events/components/EventsListSkeleton.tsx @@ -0,0 +1,72 @@ +import { Divider, Skeleton } from "@wanteddev/wds"; +import { Fragment } from "react"; + +import { + SKELETON_ROW_KEYS, + useSkeletonAnimation, +} from "@/components/ui/ScreenSkeleton"; + +// 행사 목록 데이터를 받는 동안 EventsListScreen의 목록 자리에 보이는 스켈레톤 — 헤더·토글·필터는 +// 화면이 바로 그리므로 행사 카드(EventsCard: 112px 썸네일 + 뱃지·제목·날짜 + 우하단 버튼) 배치만 따른다. +function EventsListSkeleton() { + const animation = useSkeletonAnimation(); + + return ( +
+ {SKELETON_ROW_KEYS.map((key, index) => ( + + {index > 0 && ( +
+ +
+ )} +
+ +
+
+ + + +
+
+ +
+
+
+
+ ))} +
+ ); +} + +export default EventsListSkeleton; diff --git a/src/features/events/components/EventsStatusBadge.tsx b/src/features/events/components/EventsStatusBadge.tsx index 14d6c0d..c30cf9e 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 "@/features/events/constants/events"; +import type { EventStatus } from "@/entities/events/types"; interface EventsStatusBadgeProps { status: EventStatus; diff --git a/src/features/events/constants/events.ts b/src/features/events/constants/events.ts index 85e2130..64ac9c9 100644 --- a/src/features/events/constants/events.ts +++ b/src/features/events/constants/events.ts @@ -1,24 +1,3 @@ -export type EventStatus = "open" | "upcoming" | "closed"; - -export interface EventItem { - id: string; - title: string; - eventDate: string; - status: EventStatus; - statusLabel: string; - actionLabel: string; - /** 상세 상단 Hero 이미지 개수 — 실 이미지 API 전까지 PageCounter 표기용 */ - imageCount: number; - /** 상세 메타데이터 "일시" */ - schedule: string; - /** 상세 메타데이터 "장소" */ - location: string; - /** 상세 메타데이터 "대상" — Figma가 두 줄로 쪼개 보여줘서 줄 단위로 들고 있는다 */ - audience: string[]; - /** 상세 본문. 줄바꿈을 그대로 살려 렌더링한다 */ - description: string; -} - // 모집 상태 필터. value는 EventStatus + "all"이고, 라벨은 Figma Filter Chips(1243:70861) 그대로. export const EVENT_STATUS_FILTERS = [ { label: "전체", value: "all" }, @@ -26,73 +5,3 @@ export const EVENT_STATUS_FILTERS = [ { label: "모집예정", value: "upcoming" }, { label: "모집종료", value: "closed" }, ] as const; - -// Figma 상세(1133:42433 모집중 / 1156:53992 모집예정)의 메타데이터·본문은 목업이라 행사별로 다르지 않다. -// 목록 카드가 같은 행사명 4개를 상태만 바꿔 보여주는 것과 같은 이유로, 상세 내용도 공통 상수로 두고 -// 상태별로 갈리는 값(뱃지 문구, CTA 문구)만 항목마다 다르게 준다. -const MOCK_SCHEDULE = "4월 15일 (수) 18:30 ~"; -const MOCK_LOCATION = "미래관 4층 신관 입구 (예대 방면)"; -const MOCK_AUDIENCE = ["소프트웨어융합대학 과학생회비 납부자", "선착순 150명"]; -const MOCK_DESCRIPTION = `안녕하십니까, 제10대 소프트웨어융합대학 학생회 ‘에코’입니다. - -기말고사를 준비하고 계신 학우 여러분을 응원하기 위해 간식행사를 진행합니다 🍱✨ - -시험기간 동안 든든하게 힘내시길 바라며, 많은 관심과 참여 부탁드립니다! - -📌 간식행사 일정 -▪️ 일시 : 6월 1일 (월) 11:00 ~ -▪️ 장소 : 미래관 4층 신관 입구 (예대방면) - -📌 대상 -▪️ 소프트웨어융합대학 재학생 선착순 180명 -※ 과학생회비 미납부자 참여 가능 - -📌 간식행사 메뉴 -▪️ 돈까스 도련님 도시락 -▪️ 나랑드사이다 제로 - -📌 유의 사항 -▪️ 소프트웨어융합대학 학생임을 증명할 수 있는 모바일 학생증 혹은 실물 학생증을 지참해주시기 바랍니다. -▪️ 1인당 1세트만 수령 가능하며, 선착순 수량 소진 시 수령이 불가능합니다. - -많은 학우 여러분의 관심과 참여 부탁드립니다. -감사합니다 😊`; - -// Figma: 행사 Event List (nodeId 1243:70866) 문구를 그대로 옮긴 목데이터 — 실 API 연동 전까지 사용. -// -// Figma 목업은 같은 행사명 4개(모집중 1 / 모집예정 1 / 모집종료 2)를 상태만 바꿔 보여주는데, -// 여기서는 모집중을 비우고 모집종료 중복도 하나로 줄였다(모집예정 1 / 모집종료 1). -// -// Empty State(1165:62713)는 Figma가 "모집중" 필터 버전으로만 그려져 있고, 일러스트·문구와 -// "아카이빙 둘러보기" 버튼이 그 조합의 스펙이다. 목데이터에 모집중 항목이 있으면 이 화면을 -// 아예 볼 수 없어서 모집중을 비웠다. 대신 모집중 상세(1133:42433)는 카드로 진입할 수 없다 — -// 실 API가 붙으면 사라질 제약이고, 지금 확인이 필요하면 아래 항목 하나의 status를 "open"으로 -// 되돌리면 된다. 모집예정은 상세 디자인(1156:53992)이 있어 진입 가능하게 남겼다. -export const EVENTS: EventItem[] = [ - { - actionLabel: "모집종료", - audience: MOCK_AUDIENCE, - description: MOCK_DESCRIPTION, - eventDate: "행사일 2026.06.04", - id: "sw-sports-day-closed", - imageCount: 7, - location: MOCK_LOCATION, - schedule: MOCK_SCHEDULE, - status: "closed", - statusLabel: "모집종료", - title: "소프트웨어융합대학 체육대회", - }, - { - actionLabel: "8월 10일 오픈", - audience: MOCK_AUDIENCE, - description: MOCK_DESCRIPTION, - eventDate: "행사일 2026.06.04", - id: "sw-sports-day-upcoming", - imageCount: 7, - location: MOCK_LOCATION, - schedule: MOCK_SCHEDULE, - status: "upcoming", - statusLabel: "모집예정", - title: "소프트웨어융합대학 체육대회", - }, -]; diff --git a/src/features/notices/NoticesDetailScreen.tsx b/src/features/notices/NoticesDetailScreen.tsx index 4d8576a..bdbbcc4 100644 --- a/src/features/notices/NoticesDetailScreen.tsx +++ b/src/features/notices/NoticesDetailScreen.tsx @@ -1,121 +1,19 @@ -import { - ContentBadge, - type ThemeColorsToken, - TopNavigationButton, - Typography, -} from "@wanteddev/wds"; -import { IconChevronLeft } from "@wanteddev/wds-icon"; +import { Suspense } from "react"; import { useNavigate, useParams } from "react-router-dom"; -import PhotoGallery from "@/components/ui/PhotoGallery"; -import ScreenHeader from "@/components/ui/ScreenHeader"; -import { useScreenHeader } from "@/components/ui/useScreenHeader"; -import { - NOTICES, - type NoticeCategory, -} from "@/features/notices/constants/notices"; +import NoticesDetailContent from "@/features/notices/components/NoticesDetailContent"; +import NoticesDetailSkeleton from "@/features/notices/components/NoticesDetailSkeleton"; -const CATEGORY_BADGE_COLOR: Record = { - 일반: "semantic.accent.foreground.blue", - 제휴: "semantic.accent.foreground.redOrange", -}; - -// Figma: 공지 상세 (nodeId 1256:81842), 공지 상세 - 사진 없을 때 (nodeId 1256:81856) +// 공지 상세 화면 — 이동 같은 화면 동작만 정하고, 공지 데이터를 받는 동안은 상세 배치를 따른 +// 스켈레톤을 보여준다. 헤더도 데이터(사진 유무)에 따라 달라서 화면 전체가 데이터 영역이다. function NoticesDetailScreen() { - const { noticeId } = useParams(); const navigate = useNavigate(); - const notice = NOTICES.find((item) => item.id === noticeId); - const photoCount = notice?.photoCount ?? 0; - - const backButton = ( - navigate(-1)} - variant="icon" - > - - - ); - - // 사진이 있으면 행사 상세와 같은 방식으로 뒤로가기를 사진 위 오버레이로 그려서 사진이 화면 - // 최상단부터 시작하게 한다(ScreenLayout 헤더 슬롯을 비우면 0px로 접힌다). 사진이 없으면 - // 덮을 이미지가 없어서 기존처럼 흰 배경 헤더를 쓴다. - useScreenHeader( - notice?.hasThumbnail ? null : ( - - ), - ); - - if (!notice) { - return ( -
- - 존재하지 않는 공지예요 - -
- ); - } + const { noticeId = "" } = useParams<{ noticeId: string }>(); return ( -
- {notice.hasThumbnail && ( - {backButton}
- } - photoCount={photoCount} - showCounter - slideClassName="aspect-square" - /> - )} - -
-
- - {notice.category} 공지 - -
- - {notice.title} - - - 등록일 {notice.date} - -
-
- - - {notice.body} - -
-
+ }> + navigate(-1)} /> + ); } diff --git a/src/features/notices/NoticesListScreen.tsx b/src/features/notices/NoticesListScreen.tsx index fa2e86f..6a3aa39 100644 --- a/src/features/notices/NoticesListScreen.tsx +++ b/src/features/notices/NoticesListScreen.tsx @@ -1,21 +1,13 @@ -import { - Divider, - Tab, - TabList, - TabListItem, - TopNavigationButton, -} from "@wanteddev/wds"; +import { Tab, TabList, TabListItem, TopNavigationButton } from "@wanteddev/wds"; import { IconBell, IconSearch } from "@wanteddev/wds-icon"; -import { Fragment, useState } from "react"; -import { Link, useNavigate } from "react-router-dom"; +import { Suspense, useState } from "react"; +import { useNavigate } from "react-router-dom"; import ScreenHeader from "@/components/ui/ScreenHeader"; import { useScreenHeader } from "@/components/ui/useScreenHeader"; -import NoticesCard from "@/features/notices/components/NoticesCard"; -import { - NOTICES, - type NoticeCategory, -} from "@/features/notices/constants/notices"; +import type { NoticeCategory } from "@/entities/notices/types"; +import NoticesList from "@/features/notices/components/NoticesList"; +import NoticesListSkeleton from "@/features/notices/components/NoticesListSkeleton"; type NoticeTab = "all" | "general" | "partnership"; @@ -53,10 +45,6 @@ function NoticesListScreen() { />, ); - const notices = NOTICES.filter( - (notice) => tab === "all" || notice.category === TAB_CATEGORY[tab], - ); - return ( <> {/* 전체/일반 공지/제휴 공지 탭은 Figma에서 타이틀 행 아래("Tool" 영역)에 붙지만, @@ -73,26 +61,10 @@ function NoticesListScreen() {
-
- {notices.map((notice, index) => ( - - {index > 0 && ( -
- -
- )} - - - -
- ))} -
+ {/* 헤더·탭은 데이터와 무관해서 바로 그리고, 데이터를 받는 목록 자리만 스켈레톤으로 채운다 */} + }> + +
); diff --git a/src/features/notices/components/NoticesDetailContent.tsx b/src/features/notices/components/NoticesDetailContent.tsx new file mode 100644 index 0000000..9dd1c07 --- /dev/null +++ b/src/features/notices/components/NoticesDetailContent.tsx @@ -0,0 +1,121 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; +import { + ContentBadge, + type ThemeColorsToken, + TopNavigationButton, + Typography, +} from "@wanteddev/wds"; +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/noticesQueries"; +import type { NoticeCategory } from "@/entities/notices/types"; + +const CATEGORY_BADGE_COLOR: Record = { + 일반: "semantic.accent.foreground.blue", + 제휴: "semantic.accent.foreground.redOrange", +}; + +interface NoticesDetailContentProps { + noticeId: string; + onBack: () => void; +} + +// Figma: 공지 상세 (nodeId 1256:81842), 공지 상세 - 사진 없을 때 (nodeId 1256:81856) +// 공지 데이터를 받아 그리는 부분. 데이터를 받는 동안은 NoticesDetailScreen의 Suspense가 +// NoticesDetailSkeleton을 보여준다. 헤더 모양이 공지의 사진 유무로 갈려서 헤더도 여기서 등록한다. +function NoticesDetailContent({ noticeId, onBack }: NoticesDetailContentProps) { + const { data: notice } = useSuspenseQuery(noticesQueries.detail(noticeId)); + const photoCount = notice?.photoCount ?? 0; + + const backButton = ( + + + + ); + + // 사진이 있으면 행사 상세와 같은 방식으로 뒤로가기를 사진 위 오버레이로 그려서 사진이 화면 + // 최상단부터 시작하게 한다(ScreenLayout 헤더 슬롯을 비우면 0px로 접힌다). 사진이 없으면 + // 덮을 이미지가 없어서 기존처럼 흰 배경 헤더를 쓴다. + useScreenHeader( + notice?.hasThumbnail ? null : ( + + ), + ); + + if (!notice) { + return ( +
+ + 존재하지 않는 공지예요 + +
+ ); + } + + return ( +
+ {notice.hasThumbnail && ( + {backButton}
+ } + photoCount={photoCount} + showCounter + slideClassName="aspect-square" + /> + )} + +
+
+ + {notice.category} 공지 + +
+ + {notice.title} + + + 등록일 {notice.date} + +
+
+ + + {notice.body} + +
+
+ ); +} + +export default NoticesDetailContent; diff --git a/src/features/notices/components/NoticesDetailSkeleton.tsx b/src/features/notices/components/NoticesDetailSkeleton.tsx new file mode 100644 index 0000000..bf61950 --- /dev/null +++ b/src/features/notices/components/NoticesDetailSkeleton.tsx @@ -0,0 +1,56 @@ +import { Skeleton } from "@wanteddev/wds"; + +import { + SKELETON_ROW_KEYS, + useScreenHeaderSkeleton, + useSkeletonAnimation, +} from "@/components/ui/ScreenSkeleton"; + +// 공지 데이터를 받는 동안 NoticesDetailScreen에 보이는 자리 — 카테고리 뱃지, 제목, 등록일, 본문 문단을 따른다. +// 사진 유무는 공지마다 달라서 사진 자리는 그리지 않는다. +function NoticesDetailSkeleton() { + const animation = useSkeletonAnimation(); + useScreenHeaderSkeleton(); + + return ( +
+
+ + + +
+
+ {SKELETON_ROW_KEYS.map((key) => ( + + ))} +
+
+ ); +} + +export default NoticesDetailSkeleton; diff --git a/src/features/notices/components/NoticesList.tsx b/src/features/notices/components/NoticesList.tsx new file mode 100644 index 0000000..0ef1f55 --- /dev/null +++ b/src/features/notices/components/NoticesList.tsx @@ -0,0 +1,46 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; +import { Divider } from "@wanteddev/wds"; +import { Fragment } from "react"; +import { Link } from "react-router-dom"; + +import { noticesQueries } from "@/entities/notices/noticesQueries"; +import type { NoticeCategory } from "@/entities/notices/types"; +import NoticesCard from "@/features/notices/components/NoticesCard"; + +interface NoticesListProps { + category: NoticeCategory | "all"; +} + +// 공지 목록 데이터를 받아 그리는 부분. 데이터를 받는 동안은 NoticesListScreen의 Suspense가 +// NoticesListSkeleton을 보여준다. +function NoticesList({ category }: NoticesListProps) { + const { data } = useSuspenseQuery(noticesQueries.list()); + const notices = data.filter( + (notice) => category === "all" || notice.category === category, + ); + + return ( +
+ {notices.map((notice, index) => ( + + {index > 0 && ( +
+ +
+ )} + + + +
+ ))} +
+ ); +} + +export default NoticesList; diff --git a/src/features/notices/components/NoticesListSkeleton.tsx b/src/features/notices/components/NoticesListSkeleton.tsx new file mode 100644 index 0000000..6389854 --- /dev/null +++ b/src/features/notices/components/NoticesListSkeleton.tsx @@ -0,0 +1,60 @@ +import { Divider, Skeleton } from "@wanteddev/wds"; +import { Fragment } from "react"; + +import { + SKELETON_ROW_KEYS, + useSkeletonAnimation, +} from "@/components/ui/ScreenSkeleton"; + +// 사진이 있는 공지만 우측에 56px 썸네일이 붙는다(NoticesCard). 목록이 한 모양으로 반복돼 보이지 않게 +// 실제 목데이터처럼 몇 행에만 둔다. +const ROWS_WITH_THUMBNAIL = new Set(["row-1", "row-2", "row-5"]); + +// 공지 목록 데이터를 받는 동안 NoticesListScreen의 목록 자리에 보이는 스켈레톤 — 헤더·카테고리 탭은 +// 화면이 바로 그리므로 제목·날짜 행만 따른다. +function NoticesListSkeleton() { + const animation = useSkeletonAnimation(); + + return ( +
+ {SKELETON_ROW_KEYS.map((key, index) => ( + + {index > 0 && ( +
+ +
+ )} +
+
+ + +
+ {ROWS_WITH_THUMBNAIL.has(key) && ( + + )} +
+
+ ))} +
+ ); +} + +export default NoticesListSkeleton; diff --git a/src/index.css b/src/index.css index 9421301..a60e643 100644 --- a/src/index.css +++ b/src/index.css @@ -87,6 +87,91 @@ } } +/* 스택 슬라이드 전환 — 목록→상세처럼 viewTransition을 켠 이동만 탄다(react-router가 그 이동의 + 뒤로가기에도 자동으로 켠다). 탭 전환은 옵션을 안 켜서 지금처럼 즉시 바뀐다. + 움직이는 건 화면 컬럼(ScreenLayout, view-transition-name: screen)뿐이고, 데스크톱 양옆 여백인 + root는 그대로 둔다. 방향은 ScreenLayoutRoute가 html[data-navigation]에 적는다. + View Transitions를 지원하지 않는 WebView·브라우저는 react-router가 그냥 즉시 이동한다. */ +@media (prefers-reduced-motion: no-preference) { + ::view-transition-old(root), + ::view-transition-new(root) { + animation: none; + } + + ::view-transition-group(screen) { + animation-duration: 350ms; + animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1); + } + + /* 데스크톱 컬럼(480px) 밖으로 슬라이드가 삐져나오지 않게 자른다 */ + ::view-transition-image-pair(screen) { + overflow: hidden; + } + + ::view-transition-old(screen), + ::view-transition-new(screen) { + animation-duration: inherit; + animation-timing-function: inherit; + /* 기본값(plus-lighter)은 크로스페이드용이라, 겹쳐 미끄러지는 두 화면이 밝게 합쳐진다 */ + mix-blend-mode: normal; + } + + /* 앞으로: 새 화면이 오른쪽에서 덮고, 이전 화면은 왼쪽으로 조금 밀려난다(iOS push와 같은 비율) */ + html[data-navigation="forward"]::view-transition-old(screen) { + animation-name: screen-slide-out-to-left; + } + + html[data-navigation="forward"]::view-transition-new(screen) { + animation-name: screen-slide-in-from-right; + } + + /* 뒤로: 현재 화면이 오른쪽으로 빠지며 아래 화면을 드러낸다 — 빠지는 쪽이 위에 있어야 한다 */ + html[data-navigation="back"]::view-transition-old(screen) { + z-index: 1; + animation-name: screen-slide-out-to-right; + } + + html[data-navigation="back"]::view-transition-new(screen) { + animation-name: screen-slide-in-from-left; + } +} + +/* 동작 줄이기를 켠 사용자는 슬라이드는 물론 브라우저 기본 크로스페이드도 없이 즉시 바뀐다 */ +@media (prefers-reduced-motion: reduce) { + ::view-transition-group(root), + ::view-transition-old(root), + ::view-transition-new(root), + ::view-transition-group(screen), + ::view-transition-old(screen), + ::view-transition-new(screen) { + animation: none; + } +} + +@keyframes screen-slide-in-from-right { + from { + transform: translateX(100%); + } +} + +@keyframes screen-slide-out-to-left { + to { + transform: translateX(-30%); + } +} + +@keyframes screen-slide-out-to-right { + to { + transform: translateX(100%); + } +} + +@keyframes screen-slide-in-from-left { + from { + transform: translateX(-30%); + } +} + /* 챗봇 진입 화면 배경 블러 — Figma에 실제 애니메이션(Smart Animate)은 없지만, "화면 1" (nodeId 1887:91534)과 "화면 2"(1887:91558)가 이 블러 위치·크기만 다른 상태라 애니메이션의 시작/끝 키프레임으로 보고 그 사이를 오가게 만들었다. 회전각(36.29deg)은 두 상태 동일해서 diff --git a/src/lib/mockResponse.ts b/src/lib/mockResponse.ts new file mode 100644 index 0000000..8ca6783 --- /dev/null +++ b/src/lib/mockResponse.ts @@ -0,0 +1,12 @@ +// 실 API가 붙기 전까지 entities의 API 함수가 목데이터를 서버 응답처럼 비동기로 돌려줄 때 쓴다. +// 개발 서버에서는 화면의 로딩 스켈레톤을 확인할 수 있게 일부러 늦게 응답하고, 배포 빌드에서는 바로 응답한다. +const MOCK_DELAY_MS = 500; + +export function mockResponse(data: T): Promise { + if (!import.meta.env.DEV) { + return Promise.resolve(data); + } + return new Promise((resolve) => { + setTimeout(() => resolve(data), MOCK_DELAY_MS); + }); +} diff --git a/src/main.tsx b/src/main.tsx index 0bbfd0c..ffa91d5 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,3 +1,4 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ThemeProvider } from "@wanteddev/wds"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; @@ -6,10 +7,14 @@ import { RouterProvider } from "react-router-dom"; import { router } from "@/app/router"; import "./index.css"; +const queryClient = new QueryClient(); + createRoot(document.getElementById("root")!).render( - + + + , );