From acad227444c05390040956e64a1e2262e403cedb Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Wed, 23 Sep 2026 23:31:52 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=97=B4=EB=A6=B0=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=20=EC=83=81=EC=84=B8=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80(=EB=AA=A8=EC=95=84=EB=B3=B4=EA=B8=B0)=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/conventions/wds-component-usage.md | 29 ++ src/app/router.tsx | 10 + .../feedbacks/FeedbacksDetailScreen.tsx | 256 ++++++++++++++++++ .../feedbacks/FeedbacksListScreen.tsx | 32 ++- .../feedbacks/components/FeedbacksQaCard.tsx | 17 +- src/features/feedbacks/constants/feedbacks.ts | 19 +- src/index.css | 6 + 7 files changed, 355 insertions(+), 14 deletions(-) create mode 100644 src/features/feedbacks/FeedbacksDetailScreen.tsx diff --git a/docs/conventions/wds-component-usage.md b/docs/conventions/wds-component-usage.md index 280d4e4..0e5ef27 100644 --- a/docs/conventions/wds-component-usage.md +++ b/docs/conventions/wds-component-usage.md @@ -366,3 +366,32 @@ WDS는 `useToast` 훅 + `Toast` 컴포넌트로 토스트 시스템을 완비하 - **`Divider(new)`의 8px 버전은 1px 구분선과 다른 별개 패턴**: 지금까지 쓰던 `divider(new)`는 1px 헤어라인(WDS `Divider`로 대체)이었는데, 이 화면의 섹션 사이 구분선은 같은 이름의 8px 두꺼운 버전(`bg-background-alternative`, `#f7f7f8`)이다. 헤어라인이 아니라 섹션을 통째로 나누는 용도라 `Divider` 컴포넌트로 대체하지 않고 `
`로 직접 그렸다 — 이미 있는 토큰이라 새로 추가한 색은 없다. - **`PaginationDots`는 부모 flex 컨테이너에 `items-center`가 없으면 왼쪽으로 붙는다**: 이 컴포넌트의 실제 루트(`tabindex` wrapper div)는 `className`/`sx` prop이 그 div까지 전달되지 않아 직접 센터링을 줄 수 없다(내부 tablist는 `width: fit-content`). `flex-col` 부모에 `items-center`를 주고, 형제 요소(캐러셀 스크롤 행)에는 `w-full`을 명시해서 폭을 유지해야 정확히 중앙에 온다 — `/figma-check`로 실측하다 발견된 버그. - **`bg-background-alternative`(`#f7f7f8`)는 흰 배경과 3/255밖에 차이가 안 나서 화면에 따라 거의 안 보일 수 있다**: Q&A 카드 배경·8px 섹션 구분선 둘 다 이 값인데, 개별 레이어 단위로 `get_variable_defs`를 다시 떼어봐도 이 값 하나만 바인딩돼 있고 다른 색·테두리는 없었다 — 코드가 Figma 값을 정확히 따르고 있는 게 확인됐다. 그럼에도 시각적 구분이 약하다고 느껴지면, Figma 스펙을 벗어나 더 진한 톤(예: `Line/Normal/Neutral` `#70737c29`)으로 의도적으로 조정할지는 별도 논의 필요 — 이번 PR에서는 Figma 값 그대로 두었다. + +## 열린피드백 상세페이지 · 모아보기(`1410:49988`) 구현 중 확정된 매핑 + +같은 회차(round)의 답변된 피드백들을 가로로 스와이프해서 넘겨보는 화면. Figma는 정적 예시 한 장만 있어서(신청 폼·행사 상세처럼 스크롤 스냅 캐러셀은 화면에 안 잡힌다), 구조는 공지·행사 상세의 가로 스크롤 스냅 패턴을 그대로 가져오고, "스와이프하는 동안 진행 바가 같이 움직인다"는 요구사항은 스크롤 위치를 0~1 연속값으로 추적해서 반영했다. + +### 재검증 — `ProgressBar`는 여전히 Stream 로컬이다(WDS `ProgressIndicator`와 다름) + +WDS에 이름이 비슷한 `ProgressIndicator`가 실제로 존재해서(`제외됨` 표에 `ProgressBar`가 예전부터 있었지만 이 화면에서 직접 재확인했다) `node_modules/@wanteddev/wds/dist/components/progress-indicator/style.js`를 열어봤다: + +| 항목 | WDS `ProgressIndicator` | Figma `ProgressBar`(`547:35051`) | +|---|---|---| +| 트랙 높이 | 2px | 4px | +| 트랙 배경 | `Fill/Normal` | `Background/Normal/Alternative`(`#f7f7f8`) | +| 채움 색 | `Primary/Normal`(`#0066FF`) | `#3385FF`(아래 항목 참고) | +| 동작 | `--wds-progress-indicator-transform`으로 **단일 진행률**(0~100%)을 표현하는 로딩바 | 전체 중 **N개 중 1개 구간의 위치**를 나타내는 스크롤바 성격의 창(thumb) | + +색·치수가 다른 데다, 무엇보다 "진행률 1개 값"과 "N등분 중 한 구간의 폭+위치"는 동작 자체가 달라서 `ProgressIndicator`로는 표현이 안 된다(내부를 갈아엎어야 함 — `component-convention.md` 오버라이드 금지 원칙 위반). 트랙+채움 두 div로 직접 그렸다(`src/features/feedbacks/FeedbacksDetailScreen.tsx`). + +### 채움 색은 `--primary/normal`이 아니라 `Atomic/Blue/60`이다 + +`get_design_context`가 내보낸 raw class는 `bg-[var(--primary/normal,#3385ff)]`이지만, 이 파일 다른 곳의 진짜 `Semantic/Primary/Normal`은 항상 `#0066FF`(`#06f`)다 — 변수 이름표를 믿지 않고 실제 hex(`#3385FF`)로 역추적하니 `Atomic/Blue/60`과 정확히 일치했다. `src/index.css`에 `--color-progress-fill: var(--atomic-blue-60)`으로 새 토큰을 추가했다. + +### 진행 바 위치는 정수 페이지가 아니라 스크롤 비율로 추적한다 + +공지·행사 상세의 갤러리는 `Math.round(scrollLeft / offsetWidth)`로 정수 페이지만 구하지만, 이 화면은 "스와이프하는 동안 파란 칸이 같이 움직인다"는 요구사항이 있어서 `scrollLeft / (scrollWidth - clientWidth)`로 0~1 연속값을 추적한다. 칸 폭은 `100 / N`%, 위치는 `progress * (100 - 칸폭)`%로 계산해서 칸이 트랙 밖으로 튀어나가지 않게 한다(표준 스크롤바 thumb 공식과 동일). + +### `Icon/Feedback`·`Icon/Answer`는 게시판-열린피드백 목록과 같은 로컬 아이콘 + +이 화면의 "피드백 내용"/"학생회 답변" 아이콘은 목록 화면의 `Icon/Question`/`Icon/Answer`와 이름만 다를 뿐 같은 자리(질문/답변 구분 아이콘)라 새로 받지 않고 기존 `src/assets/icons/feedbacks/{question,answer}.svg`를 그대로 재사용했다. diff --git a/src/app/router.tsx b/src/app/router.tsx index 6eb52c5..c9c579d 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -11,6 +11,7 @@ import EventsApplicationCompleteScreen from "@/features/events/EventsApplication import EventsApplicationScreen from "@/features/events/EventsApplicationScreen"; import EventsDetailScreen from "@/features/events/EventsDetailScreen"; import EventsListScreen from "@/features/events/EventsListScreen"; +import FeedbacksDetailScreen from "@/features/feedbacks/FeedbacksDetailScreen"; import FeedbacksListScreen from "@/features/feedbacks/FeedbacksListScreen"; import HomeScreen from "@/features/home/HomeScreen"; import NoticesDetailScreen from "@/features/notices/NoticesDetailScreen"; @@ -44,6 +45,15 @@ const routes = [ handle: { background: "normal" } satisfies ScreenRouteHandle, path: "/feedbacks", }, + { + element: , + // 상세(모아보기) 화면 — 목록 화면과 같은 흰 배경, Bottom Nav 없이 뒤로가기(닫기)로만 나간다. + handle: { + background: "normal", + hasBottomNav: false, + } satisfies ScreenRouteHandle, + path: "/feedbacks/:feedbackId", + }, { element: , // Bottom Nav 대신 하단 고정 버튼(Action Area)이 있는 화면 diff --git a/src/features/feedbacks/FeedbacksDetailScreen.tsx b/src/features/feedbacks/FeedbacksDetailScreen.tsx new file mode 100644 index 0000000..fd1b408 --- /dev/null +++ b/src/features/feedbacks/FeedbacksDetailScreen.tsx @@ -0,0 +1,256 @@ +import { + ActionArea, + ActionAreaButton, + TopNavigationButton, + Typography, +} from "@wanteddev/wds"; +import { IconClose } from "@wanteddev/wds-icon"; +import { + Fragment, + type UIEvent, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useNavigate, useParams } from "react-router-dom"; + +import answerIcon from "@/assets/icons/feedbacks/answer.svg"; +import questionIcon from "@/assets/icons/feedbacks/question.svg"; +import ScreenHeader from "@/components/ui/ScreenHeader"; +import { useScreenHeader } from "@/components/ui/useScreenHeader"; +import { FEEDBACKS } from "@/features/feedbacks/constants/feedbacks"; + +// 답변 텍스트의 **강조** 구간을 볼드 Typography로 바꿔 그린다(Figma nodeId 1410:50009의 +// 인라인 볼드 스팬). 문단 구분(\n\n)은 별도 파싱 없이 부모의 white-space: pre-wrap에 맡긴다. +function renderAnswerText(text: string, idPrefix: string) { + return text.split(/(\*\*[^*]+\*\*)/g).map((segment, index) => { + const key = `${idPrefix}-${index}`; + if (segment.startsWith("**") && segment.endsWith("**")) { + return ( + + {segment.slice(2, -2)} + + ); + } + return {segment}; + }); +} + +// Figma: 열린피드백 상세페이지 (nodeId 1410:49988) +// 제목·진행 바는 같은 회차(round)의 답변된 피드백들을 한데 묶어 보여준다는 뜻에서 +// "N차 피드백 모아보기"다. 같은 회차 안에서 가로로 넘기면(스와이프) 문항이 바뀌고, +// 진행 바의 파란 칸이 그 스크롤 위치를 그대로 따라간다. +// 질문/답변 아이콘은 Q&A 카드와 같은 SVG(question.svg/answer.svg)를 재사용하지만, 이 화면은 +// Figma에서 그 아이콘 뒤에 32×32 #F7F7F8(bg-background-alternative) 배경 원이 하나 더 있다 +// (download_assets로 확인 — 카드 쪽엔 이 배경 원이 없다). 아이콘 자체 크기도 원 안에서 +// 원래 비율대로(각 5/4) 가운데 정렬한다 — size-8로 그대로 채우면 커 보이는 문제는 카드와 같다. +function FeedbacksDetailScreen() { + const { feedbackId } = useParams(); + const navigate = useNavigate(); + const trackRef = useRef(null); + + const feedback = FEEDBACKS.find((item) => item.id === feedbackId); + // 답변이 없는 피드백은 이 화면에 답변 섹션을 그릴 수 없어서 애초에 묶음에서 뺀다 + // (목록 카드도 답변이 있을 때만 이 화면으로 링크한다). + const roundFeedbacks = useMemo( + () => + feedback + ? FEEDBACKS.filter( + (item) => item.round === feedback.round && item.answer, + ) + : [], + [feedback], + ); + const initialIndex = Math.max( + roundFeedbacks.findIndex((item) => item.id === feedbackId), + 0, + ); + const [progress, setProgress] = useState( + roundFeedbacks.length > 1 ? initialIndex / (roundFeedbacks.length - 1) : 0, + ); + const [currentIndex, setCurrentIndex] = useState(initialIndex); + + // 카드에서 클릭한 항목이 맨 처음 보이도록, 그 항목 위치로 스크롤을 맞춘다 + // (부드럽게 넘기지 않는다 — 화면 진입 시점의 초기 위치 지정이라서). + useEffect(() => { + const el = trackRef.current; + if (!el || el.offsetWidth === 0 || initialIndex === 0) { + return; + } + el.scrollLeft = initialIndex * el.offsetWidth; + }, [initialIndex]); + + // 스크롤 위치를 0~1 연속값으로 추적한다 — 정수 페이지 단위로 반올림하지 않아서, + // 스와이프하는 동안 진행 바가 손가락을 따라 매끄럽게 같이 움직인다. + // currentIndex는 별도로 반올림해서 추적한다 — 이전/다음 버튼의 비활성화 기준, 다음 이동 위치 계산에 쓴다. + const handleTrackScroll = (event: UIEvent) => { + const { scrollLeft, scrollWidth, clientWidth } = event.currentTarget; + const maxScrollLeft = scrollWidth - clientWidth; + setProgress(maxScrollLeft > 0 ? scrollLeft / maxScrollLeft : 0); + if (clientWidth > 0) { + setCurrentIndex(Math.round(scrollLeft / clientWidth)); + } + }; + + // 이전/다음 버튼은 한 장만큼 프로그래밍적으로 스크롤한다 — 스와이프와 같은 스냅 위치로 맞춰진다. + const scrollToIndex = (index: number) => { + const el = trackRef.current; + if (!el || el.offsetWidth === 0) { + return; + } + el.scrollTo({ behavior: "smooth", left: index * el.offsetWidth }); + }; + + useScreenHeader( + navigate("/feedbacks")} + variant="icon" + > + + + } + variant="normal" + />, + ); + + if (!feedback) { + return ( +
+ + 존재하지 않는 피드백이에요 + +
+ ); + } + + const itemCount = roundFeedbacks.length; + const hasMultipleItems = itemCount > 1; + const thumbWidthPercent = 100 / itemCount; + const thumbLeftPercent = progress * (100 - thumbWidthPercent); + + return ( +
+
+
+
+
+
+ +
+ {roundFeedbacks.map((item) => ( +
+
+
+
+ +
+ + 피드백 내용 + +
+ + {item.question} + +
+ +
+ + {item.answer && ( +
+
+
+ +
+
+ + 학생회 답변 + + + {item.answerDate} + +
+
+ + {renderAnswerText(item.answer, item.id)} + +
+ )} +
+ ))} +
+ +
+ {/* 버튼 둘을 가로 반반 두는 ActionArea 패턴은 그대로 두고, 회차 내 이전/다음 문항으로 + 넘기는 버튼으로 바꿨다(화면설계서 기준) — "이전"만 solid×assistive로 색을 덮는다. */} + + scrollToIndex(currentIndex - 1)} + sx={{ padding: "16px 28px" }} + variant="alternative" + > + 이전 + + scrollToIndex(currentIndex + 1)} + sx={{ padding: "16px 28px" }} + > + 다음 + + +
+
+
+ ); +} + +export default FeedbacksDetailScreen; diff --git a/src/features/feedbacks/FeedbacksListScreen.tsx b/src/features/feedbacks/FeedbacksListScreen.tsx index 459d86a..7d25e23 100644 --- a/src/features/feedbacks/FeedbacksListScreen.tsx +++ b/src/features/feedbacks/FeedbacksListScreen.tsx @@ -6,7 +6,7 @@ import { } from "@wanteddev/wds"; import { IconBell, IconPlus, IconSearch } from "@wanteddev/wds-icon"; import { Fragment, useRef, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import FilterChipGroup from "@/components/ui/FilterChipGroup"; import ScreenHeader from "@/components/ui/ScreenHeader"; @@ -107,11 +107,16 @@ function FeedbacksListScreen() { ref={carouselRef} > {answeredFeedbacks.map((feedback) => ( - + to={`/feedbacks/${feedback.id}`} + > + + ))}
)} - + {feedback.answer ? ( + + + + ) : ( + + )} ))}
diff --git a/src/features/feedbacks/components/FeedbacksQaCard.tsx b/src/features/feedbacks/components/FeedbacksQaCard.tsx index cd80e51..cd20ac1 100644 --- a/src/features/feedbacks/components/FeedbacksQaCard.tsx +++ b/src/features/feedbacks/components/FeedbacksQaCard.tsx @@ -12,14 +12,23 @@ interface FeedbacksQaCardProps { // Icon/Question·Icon/Answer는 search_design_system 이름 매칭도 없고 get_design_context // Component descriptions에도 없어서(Icon/Answer는 이미 wds-component-usage.md 제외됨 표에 있음) // Figma 원본 SVG를 그대로 받아 씀. +// 아이콘은 24×24 칸(Figma 인스턴스 프레임) 안에 실제 그려진 도형만큼만 채운다 — 우리가 받은 +// SVG는 프레임이 아니라 도형 자체의 타이트한 바운딩(19.9998×19.8093 / 16×16.0012)이라, 칸에 +// 그대로 늘려 채우면(예전엔 size-6로 강제) Figma보다 20~50% 크게 보인다(/figma-check 지적). +// 칸 크기(24px, 텍스트와의 32px 오프셋)는 유지하고 아이콘만 칸 안에서 가운데 정렬한다. // 폭은 캐러셀에서 다음 카드가 41px 엿보이도록 컬럼 폭에 맞춰 잡은 값이다. 폰은 Figma 그대로 // 286px(375 기준), 데스크톱 컬럼(sm 이상, 480px)에서는 391px — 좌우 px-5(40px)와 gap(8px)을 // 빼고 남는 41px이 엿보기가 된다. FeedbacksListScreen은 이 폭을 DOM에서 재서 점을 맞춘다. function FeedbacksQaCard({ question, answer }: FeedbacksQaCardProps) { + // 상세페이지에서만 쓰는 **볼드** 문법을 미리보기 카드에서는 그대로 노출하지 않는다. + const plainAnswer = answer.replace(/\*\*/g, ""); + return (
- +
+ +
- +
+ +
- {answer} + {plainAnswer}
diff --git a/src/features/feedbacks/constants/feedbacks.ts b/src/features/feedbacks/constants/feedbacks.ts index ff733fc..394dce8 100644 --- a/src/features/feedbacks/constants/feedbacks.ts +++ b/src/features/feedbacks/constants/feedbacks.ts @@ -7,6 +7,8 @@ export interface Feedback { question: string; round: FeedbackRound; answer?: string; + // 상세페이지(모아보기)의 "학생회 답변" 날짜. 답변이 없으면 의미가 없어 answer와 함께 옵셔널이다. + answerDate?: string; } export const FEEDBACK_ROUND_FILTERS: FilterChipOption[] = [ @@ -17,13 +19,22 @@ export const FEEDBACK_ROUND_FILTERS: FilterChipOption[] = [ { label: "4차", value: "4차" }, ]; -const MT_ANSWER = - "학우 여러분의 경제적 부담을 덜어드리기 위해 더욱 고민하겠습니다. 최근 지속적인 물가 상승으로 인해 숙박 및 대관료가 전반적으로 크게 인상된 상황이었습니다. 실제로는 기존보다 참가비가 인상될 수밖에 없는 여건이었으나, 학생회에서는 학우분들의 부담을 최소화하기 위해 업체 측과 수차례 협의를 진행하며 최대한 비용을 조정했고, 학교 측 지원금 확보 노력 등을 통해 기존 수준으로 참가비를 동결하고자 최선을 다했습니다."; +// 문단은 빈 줄(\n\n)로 나누고, 굵게 강조할 구간은 **텍스트**로 표시한다 — 둘 다 Figma +// 원본(nodeId 1410:50009)의 문단 구분·볼드 스팬을 그대로 옮긴 것이다. +// FeedbacksDetailScreen이 이 문법을 해석해서 렌더링하고, 카드 미리보기(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차", @@ -31,18 +42,21 @@ export const FEEDBACKS: Feedback[] = [ { answer: "말씀 주신 대로 다음 학기부터는 개강총회를 목요일에 진행해 다음 날 부담을 줄일 수 있도록 반영하겠습니다.", + answerDate: "2026-05-06", id: "2", question: "목요일에 개강총회를 진행해서 다음 날 부담이 적어 좋았습니다.", round: "2차", }, { answer: "건의해주신 내용은 다음 학생회 운영위에서 함께 논의하겠습니다.", + answerDate: "2026-03-14", id: "3", question: "동아리방 냉난방기 가동 시간을 조금 더 늘려주시면 좋겠습니다.", round: "1차", }, { answer: "안내드린 대로 다음 공지에 상세 시간표를 함께 첨부하겠습니다.", + answerDate: "2026-07-02", id: "4", question: "축제 공연 시간표를 미리 공지해주시면 일정을 맞추기 편할 것 같아요.", @@ -50,6 +64,7 @@ export const FEEDBACKS: Feedback[] = [ }, { answer: "학생증 재발급 신청 절차를 학과 사무실과 다시 공유드리겠습니다.", + answerDate: "2026-08-20", id: "5", question: "학생증 재발급 절차가 헷갈려서 안내가 더 필요해요.", round: "4차", diff --git a/src/index.css b/src/index.css index 4dd2296..17fe3ae 100644 --- a/src/index.css +++ b/src/index.css @@ -43,6 +43,12 @@ raw #d9d9d9로 그려진 "이미지 슬롯" placeholder라 대응하는 WDS 시맨틱 토큰이 없다. 실제 행사 이미지 API가 붙으면 이 토큰과 함께 사라질 자리다. */ --color-thumbnail-placeholder: #d9d9d9; + + /* 열린피드백 상세("N차 피드백 모아보기")의 스와이프 진행 바 채움색. get_design_context가 + 내보낸 CSS 변수 이름은 --primary/normal이지만 실제 값(#3385FF)은 Atomic/Blue/60과 + 정확히 일치한다 — 이름이 아니라 실측 hex를 기준으로 확정했다(Semantic/Primary/Normal은 + #0066FF로 다른 값). */ + --color-progress-fill: var(--atomic-blue-60); } /* iOS 노치·홈 인디케이터 자리. index.html의 viewport-fit=cover와 한 쌍이다. From 471cb7f86d6a506830600309bbce7903a9f34d0e Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Mon, 28 Sep 2026 12:21:51 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EC=98=A4=EB=B2=84=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=20=EC=8B=9C=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=ED=81=B4=EB=9E=A8=ED=94=84,=20=EB=8B=B5=EB=B3=80?= =?UTF-8?q?=20=EC=97=86=EB=8A=94=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EC=A7=84?= =?UTF-8?q?=EC=9E=85=20=EA=B0=80=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/feedbacks/FeedbacksDetailScreen.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/features/feedbacks/FeedbacksDetailScreen.tsx b/src/features/feedbacks/FeedbacksDetailScreen.tsx index fd1b408..5a1712d 100644 --- a/src/features/feedbacks/FeedbacksDetailScreen.tsx +++ b/src/features/feedbacks/FeedbacksDetailScreen.tsx @@ -84,12 +84,16 @@ function FeedbacksDetailScreen() { // 스크롤 위치를 0~1 연속값으로 추적한다 — 정수 페이지 단위로 반올림하지 않아서, // 스와이프하는 동안 진행 바가 손가락을 따라 매끄럽게 같이 움직인다. // currentIndex는 별도로 반올림해서 추적한다 — 이전/다음 버튼의 비활성화 기준, 다음 이동 위치 계산에 쓴다. + // Safari의 탄성 오버스크롤(rubber-band)은 scrollLeft가 범위를 넘어설 수 있어서, 진행 바와 + // 인덱스 둘 다 유효 범위로 클램프한다(/pr-check 지적). const handleTrackScroll = (event: UIEvent) => { const { scrollLeft, scrollWidth, clientWidth } = event.currentTarget; - const maxScrollLeft = scrollWidth - clientWidth; - setProgress(maxScrollLeft > 0 ? scrollLeft / maxScrollLeft : 0); + const maxScrollLeft = Math.max(scrollWidth - clientWidth, 0); + const clampedScrollLeft = Math.min(Math.max(scrollLeft, 0), maxScrollLeft); + setProgress(maxScrollLeft > 0 ? clampedScrollLeft / maxScrollLeft : 0); if (clientWidth > 0) { - setCurrentIndex(Math.round(scrollLeft / clientWidth)); + const index = Math.round(clampedScrollLeft / clientWidth); + setCurrentIndex(Math.min(Math.max(index, 0), roundFeedbacks.length - 1)); } }; @@ -118,7 +122,9 @@ function FeedbacksDetailScreen() { />, ); - if (!feedback) { + // 답변 없는 피드백 id로 직접 들어오면 이 화면에 그릴 답변 섹션이 없다 — roundFeedbacks도 + // 빈 배열이 되어 진행 바·버튼 계산이 깨지므로(/pr-check 지적) 답변 유무까지 같이 본다. + if (!feedback?.answer) { return (