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
10 changes: 10 additions & 0 deletions src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ 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 FeedbacksNewScreen from "@/features/feedbacks/FeedbacksNewScreen";
import HomeScreen from "@/features/home/HomeScreen";
import NoticesDetailScreen from "@/features/notices/NoticesDetailScreen";
import NoticesListScreen from "@/features/notices/NoticesListScreen";
Expand Down Expand Up @@ -45,6 +46,15 @@ const routes = [
handle: { background: "normal" } satisfies ScreenRouteHandle,
path: "/feedbacks",
},
{
element: <FeedbacksNewScreen />,
// Bottom Nav 대신 하단 고정 버튼(Action Area)이 있는 화면. Figma 루트 배경도 흰 면이다.
handle: {
background: "normal",
hasBottomNav: false,
} satisfies ScreenRouteHandle,
path: "/feedbacks/new",
},
{
element: <FeedbacksDetailScreen />,
// 상세(모아보기) 화면 — 목록 화면과 같은 흰 배경, Bottom Nav 없이 뒤로가기(닫기)로만 나간다.
Expand Down
39 changes: 27 additions & 12 deletions src/components/ui/ScreenToast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,25 @@ interface ScreenToastProps {
open: boolean;
message: string;
onOpenChange: (open: boolean) => void;
/**
* negative: 실패 안내(느낌표, Figma 1450:93026). positive: 성공 안내(체크, Figma 1410:50080) —
* WDS 기본 아이콘(IconCircleCheckFill)이 Figma와 이미 일치해서 따로 넘기지 않는다.
*/
variant?: "negative" | "positive";
}

// Figma: Toast/Toast (nodeId 1450:93026). 배경·흐림·여백·모서리·글자가 모두 WDS Toast 기본값이라
// 그대로 쓰고, 두 가지만 우리가 맡는다.
// Figma: Toast/Toast (nodeId 1450:93026 negative / 1410:50080 positive). 배경·흐림·여백·모서리·
// 글자가 모두 WDS Toast 기본값이라 그대로 쓰고, 두 가지만 우리가 맡는다.
// 1. 위치 — WDS 기본 배치는 자체 영역(#wds-region-manager-bottom)이라 화면 프레임을 벗어난다.
// 화면 포털에 직접 그려서 Figma 좌표(좌우 20px, 아래에서 100px)에 놓는다.
// 2. 아이콘 — negative variant의 기본 아이콘은 X(Circle Close)인데 Figma는 느낌표라 직접 넘긴다.
function ScreenToast({ open, message, onOpenChange }: ScreenToastProps) {
// positive는 기본 아이콘(체크)이 Figma와 그대로 맞아서 커스텀하지 않는다.
function ScreenToast({
open,
message,
onOpenChange,
variant = "negative",
}: ScreenToastProps) {
const portalEl = useScreenSheetPortal();

if (!portalEl) {
Expand All @@ -37,17 +48,21 @@ function ScreenToast({ open, message, onOpenChange }: ScreenToastProps) {
maxWidth: "100%",
},
})}
variant="negative"
variant={variant}
>
<ToastContainer>
<ToastIcon>
<span className="relative flex shrink-0 text-toast-negative">
{/* 느낌표는 아이콘에서 뚫린 자리라 반투명한 토스트 배경이 그대로 비친다.
WDS 기본 아이콘과 같은 방식으로 흰 바탕을 뒤에 깔아 하얗게 보이게 한다. */}
<span className="absolute top-1/2 left-1/2 h-[10px] w-[8px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-static-white" />
<IconCircleExclamationFill className="relative" />
</span>
</ToastIcon>
{variant === "negative" ? (
<ToastIcon>
<span className="relative flex shrink-0 text-toast-negative">
{/* 느낌표는 아이콘에서 뚫린 자리라 반투명한 토스트 배경이 그대로 비친다.
WDS 기본 아이콘과 같은 방식으로 흰 바탕을 뒤에 깔아 하얗게 보이게 한다. */}
<span className="absolute top-1/2 left-1/2 h-[10px] w-[8px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-static-white" />
<IconCircleExclamationFill className="relative" />
</span>
</ToastIcon>
) : (
<ToastIcon />
)}
<ToastContent>{message}</ToastContent>
</ToastContainer>
</Toast>
Expand Down
34 changes: 30 additions & 4 deletions src/features/feedbacks/FeedbacksListScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import {
Typography,
} from "@wanteddev/wds";
import { IconBell, IconPlus, IconSearch } from "@wanteddev/wds-icon";
import { Fragment, useRef, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Fragment, useEffect, useRef, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";

import FilterChipGroup from "@/components/ui/FilterChipGroup";
import ScreenHeader from "@/components/ui/ScreenHeader";
import ScreenToast from "@/components/ui/ScreenToast";
import { useScreenHeader } from "@/components/ui/useScreenHeader";
import FeedbacksCard from "@/features/feedbacks/components/FeedbacksCard";
import FeedbacksQaCard from "@/features/feedbacks/components/FeedbacksQaCard";
Expand All @@ -30,12 +31,30 @@ function getCarouselItemWidth(el: HTMLDivElement): number {
return firstCard.getBoundingClientRect().width + gap;
}

// Figma: 게시판 - 열린피드백 (nodeId 1410:50011)
interface FeedbacksListLocationState {
feedbackSent?: boolean;
}

// Figma: 게시판 - 열린피드백 (nodeId 1410:50011), 피드백 전송 완료 토스트 (nodeId 1410:50080)
function FeedbacksListScreen() {
const navigate = useNavigate();
const location = useLocation();
const [roundFilter, setRoundFilter] = useState("all");
const carouselRef = useRef<HTMLDivElement>(null);
const [carouselPage, setCarouselPage] = useState(1);
// 피드백 작성 화면에서 넘어올 때만 history state로 신호를 받는다(navigate state) — 그 외
// 진입(바텀 탭 등)에서는 안 뜬다. 새로고침 시 재노출을 막으려고 받자마자 state를 비운다.
const [isSentToastOpen, setIsSentToastOpen] = useState(
Boolean(
(location.state as FeedbacksListLocationState | null)?.feedbackSent,
),
);

useEffect(() => {
if ((location.state as FeedbacksListLocationState | null)?.feedbackSent) {
navigate(location.pathname, { replace: true, state: null });
}
}, [location.pathname, location.state, navigate]);

// 캐러셀은 가로 스크롤이라 PaginationDots.currentPage를 고정값으로 두면 스크롤해도 첫 점만
// 활성으로 보인다(/pr-check 리뷰 지적) — 스크롤 위치로 현재 카드를 계산해 동기화한다.
Expand Down Expand Up @@ -176,17 +195,24 @@ function FeedbacksListScreen() {
</div>
</div>

{/* 피드백 작성 화면은 이번 범위 밖이라 클릭 동작은 아직 없다 */}
<button
aria-label="피드백 작성"
className="absolute right-5 bottom-5 flex items-center gap-1 rounded-full bg-primary px-4 py-3 shadow-[0px_6px_5px_rgba(23,23,23,0.08),0px_16px_12px_rgba(23,23,23,0.08)]"
onClick={() => navigate("/feedbacks/new")}
type="button"
>
<IconPlus className="size-5 text-white" />
<Typography color="atomic.common.100" variant="label1" weight="bold">
피드백 작성
</Typography>
</button>

<ScreenToast
message="피드백을 보냈어요."
onOpenChange={setIsSentToastOpen}
open={isSentToastOpen}
variant="positive"
/>
</div>
);
}
Expand Down
118 changes: 118 additions & 0 deletions src/features/feedbacks/FeedbacksNewScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import {
ActionArea,
ActionAreaButton,
TextArea,
TextAreaContent,
TopNavigationButton,
Typography,
} from "@wanteddev/wds";
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";

const FEEDBACKS_QUESTION_MAX_LENGTH = 500;

// Figma: 피드백 질문 (nodeId 1410:49972), 피드백 질문 확인 모달 (nodeId 1410:49980)
// 실제 제출 API 전까지는 확인 모달에서 "보내기"를 누르면 바로 열린피드백 목록으로 보내고,
// 목록 화면에 완료 토스트(Figma nodeId 1410:50080)를 띄우라고 navigate state로 신호를 넘긴다.
function FeedbacksNewScreen() {
const navigate = useNavigate();
const [content, setContent] = useState("");
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const headingId = useId();

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 });
};

useScreenHeader(
<ScreenHeader
leading={
<TopNavigationButton
aria-label="뒤로가기"
onClick={goBack}
variant="icon"
>
<IconChevronLeft />
</TopNavigationButton>
}
title="피드백 작성"
variant="normal"
/>,
);

return (
<div className="flex h-full flex-col">
<div className="scrollbar-hidden flex-1 overflow-y-auto">
<div className="flex flex-col gap-2 px-5 pt-2">
<Typography
color="semantic.label.neutral"
id={headingId}
variant="label1"
weight="bold"
>
궁금하거나 건의하고 싶은 내용을 자유롭게 적어 주세요.
</Typography>
<TextArea
aria-labelledby={headingId}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
aria-required="true"
maxLength={FEEDBACKS_QUESTION_MAX_LENGTH}
minRows={1}
onChange={(event) => setContent(event.target.value)}
placeholder="피드백 내용을 입력하세요."
trailingContent={
<TextAreaContent variant="characterCounter">
{FEEDBACKS_QUESTION_MAX_LENGTH}
</TextAreaContent>
}
value={content}
width="100%"
/>
</div>
</div>

<div className="shrink-0">
<ActionArea background>
<ActionAreaButton
disabled={!canSubmit}
onClick={() => setIsConfirmOpen(true)}
sx={{ paddingBlock: "16px" }}
>
피드백 보내기
</ActionAreaButton>
</ActionArea>
<div className="h-safe-bottom-extra bg-background-elevated-normal sm:h-[14px]" />
</div>

<ConfirmModal
cancelLabel="수정"
confirmLabel="보내기"
description="보내 주신 피드백은 정기 답변에서 함께 답변해 드려요."
onCancel={() => setIsConfirmOpen(false)}
onConfirm={() =>
navigate("/feedbacks", {
replace: true,
state: { feedbackSent: true },
})
}
open={isConfirmOpen}
title="피드백을 보낼까요?"
/>
</div>
);
}

export default FeedbacksNewScreen;