Skip to content
Merged
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
29 changes: 29 additions & 0 deletions docs/conventions/wds-component-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,3 +366,32 @@ WDS는 `useToast` 훅 + `Toast` 컴포넌트로 토스트 시스템을 완비하
- **`Divider(new)`의 8px 버전은 1px 구분선과 다른 별개 패턴**: 지금까지 쓰던 `divider(new)`는 1px 헤어라인(WDS `Divider`로 대체)이었는데, 이 화면의 섹션 사이 구분선은 같은 이름의 8px 두꺼운 버전(`bg-background-alternative`, `#f7f7f8`)이다. 헤어라인이 아니라 섹션을 통째로 나누는 용도라 `Divider` 컴포넌트로 대체하지 않고 `<div className="h-2 w-full bg-background-alternative" />`로 직접 그렸다 — 이미 있는 토큰이라 새로 추가한 색은 없다.
- **`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`를 그대로 재사용했다.
10 changes: 10 additions & 0 deletions src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -44,6 +45,15 @@ const routes = [
handle: { background: "normal" } satisfies ScreenRouteHandle,
path: "/feedbacks",
},
{
element: <FeedbacksDetailScreen />,
// 상세(모아보기) 화면 — 목록 화면과 같은 흰 배경, Bottom Nav 없이 뒤로가기(닫기)로만 나간다.
handle: {
background: "normal",
hasBottomNav: false,
} satisfies ScreenRouteHandle,
path: "/feedbacks/:feedbackId",
},
{
element: <EventsDetailScreen />,
// Bottom Nav 대신 하단 고정 버튼(Action Area)이 있는 화면
Expand Down
262 changes: 262 additions & 0 deletions src/features/feedbacks/FeedbacksDetailScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
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 (
<Typography as="span" key={key} variant="body2-reading" weight="bold">
{segment.slice(2, -2)}
</Typography>
);
}
return <Fragment key={key}>{segment}</Fragment>;
});
}

// 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<HTMLDivElement>(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는 별도로 반올림해서 추적한다 — 이전/다음 버튼의 비활성화 기준, 다음 이동 위치 계산에 쓴다.
// Safari의 탄성 오버스크롤(rubber-band)은 scrollLeft가 범위를 넘어설 수 있어서, 진행 바와
// 인덱스 둘 다 유효 범위로 클램프한다(/pr-check 지적).
const handleTrackScroll = (event: UIEvent<HTMLDivElement>) => {
const { scrollLeft, scrollWidth, clientWidth } = event.currentTarget;
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) {
const index = Math.round(clampedScrollLeft / clientWidth);
setCurrentIndex(Math.min(Math.max(index, 0), roundFeedbacks.length - 1));
}
};

// 이전/다음 버튼은 한 장만큼 프로그래밍적으로 스크롤한다 — 스와이프와 같은 스냅 위치로 맞춰진다.
const scrollToIndex = (index: number) => {
const el = trackRef.current;
if (!el || el.offsetWidth === 0) {
return;
}
el.scrollTo({ behavior: "smooth", left: index * el.offsetWidth });
};

useScreenHeader(
<ScreenHeader
title={feedback ? `${feedback.round} 피드백 모아보기` : undefined}
trailing={
<TopNavigationButton
aria-label="닫기"
onClick={() => navigate("/feedbacks")}
variant="icon"
>
<IconClose />
</TopNavigationButton>
}
variant="normal"
/>,
);

// 답변 없는 피드백 id로 직접 들어오면 이 화면에 그릴 답변 섹션이 없다 — roundFeedbacks도
// 빈 배열이 되어 진행 바·버튼 계산이 깨지므로(/pr-check 지적) 답변 유무까지 같이 본다.
if (!feedback?.answer) {
return (
<div className="flex flex-1 items-center justify-center">
<Typography
as="p"
color="semantic.label.alternative"
variant="label1"
weight="regular"
>
존재하지 않는 피드백이에요
</Typography>
</div>
);
}

const itemCount = roundFeedbacks.length;
const hasMultipleItems = itemCount > 1;
const thumbWidthPercent = 100 / itemCount;
const thumbLeftPercent = progress * (100 - thumbWidthPercent);

return (
<div className="flex h-full flex-col bg-background-normal">
<div className="shrink-0 px-5 py-2">
<div className="relative h-1 w-full rounded-full bg-background-alternative">
<div
className="absolute h-1 rounded-full bg-progress-fill"
style={{
left: `${thumbLeftPercent}%`,
width: `${thumbWidthPercent}%`,
}}
/>
</div>
</div>

<div
className="scrollbar-hidden mt-3 flex flex-1 snap-x snap-mandatory overflow-x-auto"
onScroll={hasMultipleItems ? handleTrackScroll : undefined}
ref={trackRef}
>
{roundFeedbacks.map((item) => (
<div
className="scrollbar-hidden h-full w-full shrink-0 snap-start overflow-y-auto"
key={item.id}
>
<div className="flex flex-col gap-3 px-5">
<div className="flex items-center gap-2">
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-background-alternative">
<img alt="" className="size-5" src={questionIcon} />
</div>
<Typography
color="semantic.label.neutral"
variant="label2"
weight="medium"
>
피드백 내용
</Typography>
</div>
<Typography
as="p"
color="semantic.label.normal"
variant="body2-reading"
weight="medium"
>
{item.question}
</Typography>
</div>

<div className="my-6 h-2 w-full bg-background-alternative" />

{item.answer && (
<div className="flex flex-col gap-3 px-5 pb-8">
<div className="flex items-center gap-2">
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-background-alternative">
<img alt="" className="size-4" src={answerIcon} />
</div>
<div className="flex flex-col gap-0.5">
<Typography
color="semantic.label.neutral"
variant="label2"
weight="medium"
>
학생회 답변
</Typography>
<Typography
color="semantic.label.alternative"
variant="caption2"
weight="regular"
>
{item.answerDate}
</Typography>
</div>
</div>
<Typography
as="p"
color="semantic.label.normal"
sx={{ whiteSpace: "pre-wrap" }}
variant="body2-reading"
weight="regular"
>
{renderAnswerText(item.answer, item.id)}
</Typography>
</div>
)}
</div>
))}
</div>

<div className="shrink-0">
{/* 버튼 둘을 가로 반반 두는 ActionArea 패턴은 그대로 두고, 회차 내 이전/다음 문항으로
넘기는 버튼으로 바꿨다(화면설계서 기준) — "이전"만 solid×assistive로 색을 덮는다. */}
<ActionArea variant="neutral">
<ActionAreaButton
buttonColor="assistive"
buttonVariant="solid"
disabled={currentIndex === 0}
onClick={() => scrollToIndex(currentIndex - 1)}
sx={{ padding: "16px 28px" }}
variant="alternative"
>
이전
</ActionAreaButton>
<ActionAreaButton
disabled={currentIndex === itemCount - 1}
onClick={() => scrollToIndex(currentIndex + 1)}
sx={{ padding: "16px 28px" }}
>
다음
</ActionAreaButton>
</ActionArea>
<div className="h-safe-bottom-extra bg-background-normal sm:h-[14px]" />
</div>
</div>
);
}

export default FeedbacksDetailScreen;
32 changes: 23 additions & 9 deletions src/features/feedbacks/FeedbacksListScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -107,11 +107,16 @@ function FeedbacksListScreen() {
ref={carouselRef}
>
{answeredFeedbacks.map((feedback) => (
<FeedbacksQaCard
answer={feedback.answer ?? ""}
<Link
className="block shrink-0"
key={feedback.id}
question={feedback.question}
/>
to={`/feedbacks/${feedback.id}`}
>
<FeedbacksQaCard
answer={feedback.answer ?? ""}
question={feedback.question}
/>
</Link>
))}
</div>
<PaginationDots
Expand Down Expand Up @@ -152,10 +157,19 @@ function FeedbacksListScreen() {
<Divider color="semantic.line.normal.alternative" />
</div>
)}
<FeedbacksCard
question={feedback.question}
round={feedback.round}
/>
{feedback.answer ? (
<Link className="block" to={`/feedbacks/${feedback.id}`}>
<FeedbacksCard
question={feedback.question}
round={feedback.round}
/>
</Link>
) : (
<FeedbacksCard
question={feedback.question}
round={feedback.round}
/>
)}
</Fragment>
))}
</div>
Expand Down
Loading