Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/conventions/coding-style.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
- `ScreenLayout`은 **라우터를 모르는 prop 기반 컴포넌트**로 유지한다. 라우트 정보(`useMatches`)는 `ScreenLayoutRoute`만 읽는다.
- 헤더는 `useScreenHeader`로 `ScreenLayout`의 헤더 자리에 등록한다. 예외는 홈 하나다 — Figma 홈은 헤더 배경이 없어 배너 그림자가 헤더까지 번지는데, 헤더 자리에 두면 경계에서 그림자가 잘려 선이 생긴다. 그래서 홈만 스크롤 영역 안에 `sticky` 헤더를 직접 그린다(`wds-component-usage.md` 홈 섹션 참고).
- 화면 스택을 쌓는 이동(목록→상세, 상세→신청 등)은 `navigate(to, { viewTransition: true })`·`<Link viewTransition>`으로 슬라이드 전환을 켠다. 뒤로가기는 react-router가 그 이동을 기억해 반대 방향으로 자동 적용하므로 `navigate(-1)`은 그대로 둔다. 브라우저 앞으로가기도 POP이라, `ScreenLayoutRoute`는 히스토리 위치(`history.state.idx`)가 줄어든 POP만 뒤로 방향으로 본다. Bottom Nav·상단 탭처럼 형제 화면을 오가는 이동과 홈으로 돌아가는 이동은 켜지 않는다(즉시 전환). 애니메이션은 `index.css`, 방향은 `ScreenLayoutRoute`가 정한다.
- **헤더 뒤로가기는 `useBackNavigation(fallbackPath)`를 쓴다**(`src/hooks/`). 화면으로 바로 들어오면(딥링크·앱 WebView 진입·주소 직접 입력) 뒤에 갈 화면이 없어 `navigate(-1)`이 아무 일도 하지 않는다 — 훅이 그때 `fallbackPath`(그 화면의 한 단계 위)로 `replace`해서 보낸다. `fallbackPath` 없이 `navigate(-1)`만 쓰면 그 화면은 딥링크로 열렸을 때 뒤로가기가 먹통이 된다. 히스토리 위치(`history.state.idx`)는 react-router 내부 형태라 `lib/historyIndex.ts`에서만 읽는다.
- 라우트가 없는 경로는 레이아웃 안의 `path: "*"` 라우트(`ComingSoonScreen`)가 받는다. 하단 탭이 유지돼서 다른 화면으로 돌아갈 수 있다. 구체적인 경로가 `*`보다 항상 우선하므로 배열 순서는 신경 쓰지 않아도 된다.

## 데이터 로딩
Expand Down
16 changes: 1 addition & 15 deletions src/app/ScreenLayoutRoute.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useLayoutEffect, useRef } from "react";
import { useLocation, useMatches, useNavigationType } from "react-router-dom";

import ScreenLayout, {
type ScreenBackground,
} from "@/components/ui/ScreenLayout";
import { getHistoryIndex } from "@/lib/historyIndex";

// 화면별 레이아웃 옵션 — router.tsx의 각 라우트에 `handle: { ... } satisfies ScreenRouteHandle`로 지정한다.
// 모든 필드는 선택이고, 지정하지 않은 필드는 바깥 라우트의 값이나 기본값을 따른다.
Expand Down Expand Up @@ -33,20 +33,6 @@ function resolveScreenRouteOption<K extends keyof ScreenRouteHandle>(
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으로 넘기기만 한다. 그래서 옵션이 다른 화면이 생겨도 레이아웃 라우트를 따로 선언하지 않고,
// 화면을 오가도 레이아웃이 다시 마운트되지 않는다.
Expand Down
14 changes: 2 additions & 12 deletions src/features/events/EventsApplicationScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
import { IconChevronLeft } from "@wanteddev/wds-icon";
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";

import ConfirmModal from "@/components/ui/ConfirmModal";
import ScreenHeader from "@/components/ui/ScreenHeader";
import ScreenToast from "@/components/ui/ScreenToast";
Expand All @@ -20,6 +19,7 @@ import {
type EventsAnswer,
} from "@/features/events/constants/eventsApplication";
import { submitEventsApplication } from "@/features/events/constants/eventsApplicationSubmit";
import { useBackNavigation } from "@/hooks/useBackNavigation";

// 복수 선택은 하나라도 골라야 하고, 기타를 골랐으면 입력 내용까지 있어야 충족으로 본다
function isAnswered(answer: EventsAnswer | undefined): boolean {
Expand Down Expand Up @@ -69,17 +69,7 @@ function EventsApplicationScreen() {
// 하나라도 고르거나 쓴 게 있으면 그냥 나갔을 때 잃는 내용이 있다는 뜻이라 확인부터 받는다
const hasDraftAnswer = Object.values(answers).some(hasDraft);

// 이 화면으로 바로 들어오면(딥링크·앱 WebView 진입) 뒤로 갈 히스토리가 없어서 navigate(-1)이
// 아무 일도 하지 않는다 — 작성 중단 모달이 열린 채로 멈춰버린다. 그럴 땐 행사 목록으로 보낸다.
// react-router가 히스토리 위치를 history.state.idx에 넣어두고, 직접 진입이면 0이다(실측 확인).
const goBack = () => {
const historyIndex = (window.history.state as { idx?: number } | null)?.idx;
if (historyIndex !== undefined && historyIndex > 0) {
navigate(-1);
return;
}
navigate("/events", { replace: true });
};
const goBack = useBackNavigation("/events");

const handleSubmit = async () => {
setIsConfirmOpen(false);
Expand Down
13 changes: 2 additions & 11 deletions src/features/feedbacks/FeedbacksNewScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import {
import { IconChevronLeft } from "@wanteddev/wds-icon";
import { useId, useState } from "react";
import { useNavigate } from "react-router-dom";

import ConfirmModal from "@/components/ui/ConfirmModal";
import ScreenHeader from "@/components/ui/ScreenHeader";
import { useScreenHeader } from "@/components/ui/useScreenHeader";
import { useBackNavigation } from "@/hooks/useBackNavigation";

const FEEDBACKS_QUESTION_MAX_LENGTH = 500;

Expand All @@ -27,16 +27,7 @@ function FeedbacksNewScreen() {

const canSubmit = content.trim().length > 0;

// 이 화면으로 바로 들어오면(딥링크·새로고침) 뒤로 갈 히스토리가 없어서 navigate(-1)이
// 아무 일도 하지 않는다 — 행사 신청 화면과 같은 이유로 그럴 땐 목록으로 보낸다.
const goBack = () => {
const historyIndex = (window.history.state as { idx?: number } | null)?.idx;
if (historyIndex !== undefined && historyIndex > 0) {
navigate(-1);
return;
}
navigate("/feedbacks", { replace: true });
};
const goBack = useBackNavigation("/feedbacks");

useScreenHeader(
<ScreenHeader
Expand Down
13 changes: 2 additions & 11 deletions src/features/notifications/NotificationsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,20 @@ import { TopNavigationButton } from "@wanteddev/wds";
import { IconChevronLeft, IconSetting } from "@wanteddev/wds-icon";
import { Suspense } from "react";
import { useNavigate } from "react-router-dom";

import ScreenHeader from "@/components/ui/ScreenHeader";
import { useScreenHeader } from "@/components/ui/useScreenHeader";
import NotificationsList from "@/features/notifications/components/NotificationsList";
import NotificationsListSkeleton from "@/features/notifications/components/NotificationsListSkeleton";
import { NOTIFICATIONS_SETTINGS_PATH } from "@/features/notifications/constants/notifications";
import { useBackNavigation } from "@/hooks/useBackNavigation";

// Figma: 알림 (nodeId 3595:107100), 알림 empty state (3595:107112)
// 홈·행사·게시판·빌릴게 헤더의 알림 아이콘이 이 화면으로 보낸다. 헤더는 데이터와 상관없어 바로 그리고,
// 목록 영역만 Suspense로 감싼다.
function NotificationsScreen() {
const navigate = useNavigate();

// 이 화면으로 바로 들어오면(딥링크·앱 WebView 진입) 뒤로 갈 히스토리가 없어서 navigate(-1)이
// 아무 일도 하지 않는다 — 그럴 땐 홈으로 보낸다(행사 신청 화면의 goBack과 같은 규칙).
const goBack = () => {
const historyIndex = (window.history.state as { idx?: number } | null)?.idx;
if (historyIndex !== undefined && historyIndex > 0) {
navigate(-1);
return;
}
navigate("/", { replace: true });
};
const goBack = useBackNavigation("/");

useScreenHeader(
<ScreenHeader
Expand Down
14 changes: 2 additions & 12 deletions src/features/notifications/NotificationsSettingsScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { TopNavigationButton } from "@wanteddev/wds";
import { IconChevronLeft } from "@wanteddev/wds-icon";
import { useState } from "react";
import { useNavigate } from "react-router-dom";

import ScreenHeader from "@/components/ui/ScreenHeader";
import { useScreenHeader } from "@/components/ui/useScreenHeader";
import NotificationsSettingRow from "@/features/notifications/components/NotificationsSettingRow";
Expand All @@ -12,12 +10,12 @@ import {
NOTIFICATIONS_PATH,
type NotificationSettingKey,
} from "@/features/notifications/constants/notifications";
import { useBackNavigation } from "@/hooks/useBackNavigation";

// Figma: 알림 설정 (nodeId 3628:110837)
// 스위치 상태는 서버 API가 없어서 이 화면 state로만 들고 있다(새로고침하면 초기값으로 돌아간다).
// "전체 알림"은 따로 값을 두지 않고 카테고리가 모두 켜졌는지로 파생한다 — 누르면 전부 같은 값으로 맞춘다.
function NotificationsSettingsScreen() {
const navigate = useNavigate();
const [settings, setSettings] = useState(INITIAL_NOTIFICATION_SETTINGS);

const isAllEnabled = NOTIFICATION_SETTINGS.every(({ key }) => settings[key]);
Expand All @@ -35,15 +33,7 @@ function NotificationsSettingsScreen() {
setSettings((prev) => ({ ...prev, [key]: enabled }));
};

// 알림 목록을 거치지 않고 바로 들어오면(딥링크) 뒤로 갈 히스토리가 없어 알림 목록으로 보낸다.
const goBack = () => {
const historyIndex = (window.history.state as { idx?: number } | null)?.idx;
if (historyIndex !== undefined && historyIndex > 0) {
navigate(-1);
return;
}
navigate(NOTIFICATIONS_PATH, { replace: true });
};
const goBack = useBackNavigation(NOTIFICATIONS_PATH);

useScreenHeader(
<ScreenHeader
Expand Down
16 changes: 3 additions & 13 deletions src/features/search/SearchScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Suspense, useEffect, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";

import { useSearchParams } from "react-router-dom";
import SearchHeader from "@/features/search/components/SearchHeader";
import SearchRecentList from "@/features/search/components/SearchRecentList";
import SearchResultContent from "@/features/search/components/SearchResultContent";
Expand All @@ -10,6 +9,7 @@ import {
type SearchTab,
} from "@/features/search/constants/search";
import { useRecentSearches } from "@/features/search/hooks/useRecentSearches";
import { useBackNavigation } from "@/hooks/useBackNavigation";

// Figma: 검색 (nodeId 1879:90575) — 검색 진입(3147:138646) / 검색 중(3147:138670) /
// 검색 완료(3147:138695, 3147:138745) / 검색 결과 empty state(3147:138770). 동작은 화면설계서
Expand All @@ -21,7 +21,6 @@ import { useRecentSearches } from "@/features/search/hooks/useRecentSearches";
// 홈·행사·게시판·빌릴게 헤더의 검색 아이콘이 `?tab=`으로 넘긴 탭을 결과의 시작 탭으로 쓴다
// (설계서 3번: 홈 검색은 전체 탭부터, 기능 내 검색은 해당 탭부터).
function SearchScreen() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const requestedTab = searchParams.get("tab");
const initialTab: SearchTab = isSearchTab(requestedTab)
Expand Down Expand Up @@ -64,16 +63,7 @@ function SearchScreen() {
setKeyword(null);
};

// 이 화면으로 바로 들어오면(딥링크·앱 WebView 진입) 뒤로 갈 히스토리가 없어서 navigate(-1)이
// 아무 일도 하지 않는다 — 그럴 땐 홈으로 보낸다(행사 신청 화면의 goBack과 같은 규칙).
const goBack = () => {
const historyIndex = (window.history.state as { idx?: number } | null)?.idx;
if (historyIndex !== undefined && historyIndex > 0) {
navigate(-1);
return;
}
navigate("/", { replace: true });
};
const goBack = useBackNavigation("/");

return (
<>
Expand Down
27 changes: 27 additions & 0 deletions src/hooks/useBackNavigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useCallback } from "react";
import { useNavigate } from "react-router-dom";

import { getHistoryIndex } from "@/lib/historyIndex";

/**
* 헤더 뒤로가기 동작. 앱 안에서 타고 들어왔으면 한 단계 뒤로 가고, 이 화면으로 **바로**
* 들어왔으면(딥링크·앱 WebView 진입·주소 직접 입력) `fallbackPath`로 보낸다.
*
* 바로 들어온 경우 `navigate(-1)`은 뒤에 갈 화면이 없어서 아무 일도 하지 않는다 — 사용자
* 입장에서는 뒤로가기 버튼이 먹통이고, 모달을 띄워 둔 화면이면 그대로 갇힌다.
*
* `fallbackPath`는 그 화면의 "한 단계 위"를 준다(알림 설정 → 알림 목록, 행사 신청 → 행사 목록).
* 되돌아올 화면이 아니라 대신 가는 곳이라 히스토리를 치환(`replace`)한다.
*/
export function useBackNavigation(fallbackPath: string) {
const navigate = useNavigate();

return useCallback(() => {
const historyIndex = getHistoryIndex();
if (historyIndex !== null && historyIndex > 0) {
navigate(-1);
return;
}
navigate(fallbackPath, { replace: true });
}, [navigate, fallbackPath]);
}
18 changes: 18 additions & 0 deletions src/lib/historyIndex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// createBrowserRouter는 history.state.idx에 히스토리 스택 위치를 적어 둔다(push마다 1씩 증가).
// 화면으로 바로 들어오면(딥링크·앱 WebView 진입·새로고침 후 첫 화면) 0이다.
//
// react-router 내부가 정한 형태라 라이브러리를 올릴 때 깨질 수 있는 자리다. 여러 곳에서 각자
// 캐스팅하지 않고 여기서만 읽는다 — 읽는 쪽은 `ScreenLayoutRoute`(전환 방향 판정)와
// `useBackNavigation`(뒤로 갈 곳이 있는지)뿐이다.
export function getHistoryIndex(): number | null {
const state: unknown = window.history.state;
if (
typeof state === "object" &&
state !== null &&
"idx" in state &&
typeof state.idx === "number"
) {
return state.idx;
}
return null;
}
Loading