From 6653e5da2a0f5ab13a57a45d6202658121068c11 Mon Sep 17 00:00:00 2001 From: SEAN Date: Fri, 27 Feb 2026 15:33:42 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 메시지 타입 정의 + 목 데이터 10건 (types.ts) - iOS/Android 푸시 알림 프리뷰 컴포넌트 (MessagePreview) - 슬라이드 패널 상세 보기 + 삭제 기능 (MessageSlidePanel) - 메시지 목록 페이지: 필터(ID/제목 검색, 서비스), 테이블, 페이지네이션 - 메시지 작성 페이지: 12-col 그리드 폼 + 실시간 프리뷰, 필수 검증, 저장 모달 Closes #19 Co-Authored-By: Claude Opus 4.6 --- .../src/features/message/components/.gitkeep | 0 .../message/components/MessagePreview.tsx | 379 ++++++++++++++++++ .../message/components/MessageSlidePanel.tsx | 262 ++++++++++++ .../message/pages/MessageListPage.tsx | 263 +++++++++++- .../message/pages/MessageRegisterPage.tsx | 359 ++++++++++++++++- react/src/features/message/types.ts | 139 ++++++- 6 files changed, 1397 insertions(+), 5 deletions(-) delete mode 100644 react/src/features/message/components/.gitkeep create mode 100644 react/src/features/message/components/MessagePreview.tsx create mode 100644 react/src/features/message/components/MessageSlidePanel.tsx diff --git a/react/src/features/message/components/.gitkeep b/react/src/features/message/components/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/react/src/features/message/components/MessagePreview.tsx b/react/src/features/message/components/MessagePreview.tsx new file mode 100644 index 0000000..0112f96 --- /dev/null +++ b/react/src/features/message/components/MessagePreview.tsx @@ -0,0 +1,379 @@ +import { useState } from "react"; + +interface MessagePreviewProps { + title: string; + body: string; + hasImage: boolean; + appName?: string; + variant?: "large" | "small"; +} + +// 폰 배경 이미지 (밝은 그래디언트 배경) +const PHONE_BG = + "https://lh3.googleusercontent.com/aida-public/AB6AXuDmbD5msJ9uegWOcy0256wH6JsipGzrgtab3foEKiGVs_a4SbUCTPti6BVDJOQEP4ZCvbcAw9hI3C7QuUdPxrBf3jJm3VgKkWoqSzl--ZEbPIzimbYnM1HQEsRbil7nmWG_XscwPP30V3OFnyleVY_R7Urk0UYbrL8P1OJwW1xwYfBDJv4htBuICd9GR2NIJlSShaBxfF9Kgp59Cte3VapdHxCz9p2Cb9tf1t13xc2LV348V-kfyQNtL8XCZNP3LMrrUIR4SrV3cGM"; + +export default function MessagePreview({ + title, + body, + hasImage, + appName = "SPMS", + variant = "large", +}: MessagePreviewProps) { + const [tab, setTab] = useState<"ios" | "android">("ios"); + + const isLarge = variant === "large"; + const phoneWidth = isLarge ? "w-[300px]" : "w-[240px]"; + const truncatedBody = + body.length > 50 ? body.substring(0, 50) + "..." : body; + + return ( +
+ {/* 탭 */} +
+ + +
+ + {/* iOS 프리뷰 */} + {tab === "ios" && ( +
+ {/* Banner Notification */} +
+

+ Banner Notification +

+
+
+
+ + shopping_bag + +
+
+

+ {title || "메시지 제목을 입력하세요"} +

+

+ {body || "메시지 내용을 입력하세요"} +

+
+
+

now

+ {hasImage && ( +
+ + image + +
+ )} +
+
+
+
+ + {/* Full Screen Preview - iOS */} +
+

+ Full Screen Preview +

+
+
+
+ {/* 노치 */} +
+
+ {/* 상태바 */} +
+ 9:41 +
+ + signal_cellular_alt + + + wifi + + + battery_full + +
+
+ {/* 알림 카드 */} +
+
+
+
+ + shopping_bag + +
+
+

+ {title || "메시지 제목을 입력하세요"} +

+

+ {truncatedBody || "메시지 내용을 입력하세요"} +

+
+

+ now +

+
+
+ {hasImage && ( +
+ + image + +
+ )} +
+ {/* 홈 인디케이터 */} +
+
+
+
+
+
+
+
+
+ )} + + {/* Android 프리뷰 */} + {tab === "android" && ( +
+ {/* Banner Notification */} +
+

+ Banner Notification +

+
+
+
+ + shopping_bag + +
+

{appName}

+ · +

now

+
+
+
+

+ {title || "메시지 제목을 입력하세요"} +

+

+ {body || "메시지 내용을 입력하세요"} +

+
+ {hasImage && ( +
+ + image + +
+ )} +
+
+
+ + {/* Full Screen Preview - Android */} +
+

+ Full Screen Preview +

+
+
+
+
+ {/* 상태바 */} +
+ 12:30 +
+ + signal_cellular_alt + + + wifi + + + battery_full + +
+
+ {/* 알림 카드 */} +
+
+
+
+ + shopping_bag + +
+

+ {appName} +

+ + · + +

now

+ + expand_more + +
+
+
+

+ {title || "메시지 제목을 입력하세요"} +

+

+ {truncatedBody || "메시지 내용을 입력하세요"} +

+
+ {hasImage && ( +
+ + image + +
+ )} +
+
+ {hasImage && ( +
+ + image + +
+ )} +
+ {/* 네비게이션 바 */} +
+ + arrow_back + + + circle + + + crop_square + +
+
+
+
+
+
+
+ )} + +

+ 이 미리보기는 예상 디자인입니다. +
+ 실제 표시는 기기 설정 및 OS 버전에 따라 다를 수 있습니다. +

+
+ ); +} diff --git a/react/src/features/message/components/MessageSlidePanel.tsx b/react/src/features/message/components/MessageSlidePanel.tsx new file mode 100644 index 0000000..f210110 --- /dev/null +++ b/react/src/features/message/components/MessageSlidePanel.tsx @@ -0,0 +1,262 @@ +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import CopyButton from "@/components/common/CopyButton"; +import MessagePreview from "./MessagePreview"; +import { MOCK_MESSAGE_DETAILS } from "../types"; + +interface MessageSlidePanelProps { + isOpen: boolean; + onClose: () => void; + messageId: string | null; +} + +export default function MessageSlidePanel({ + isOpen, + onClose, + messageId, +}: MessageSlidePanelProps) { + const detail = messageId ? MOCK_MESSAGE_DETAILS[messageId] : null; + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const bodyRef = useRef(null); + + // 패널 열릴 때 스크롤 최상단으로 리셋 + useEffect(() => { + if (isOpen) { + bodyRef.current?.scrollTo(0, 0); + } + }, [isOpen, messageId]); + + // ESC 닫기 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && isOpen) onClose(); + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onClose]); + + // 패널 열릴 때 body 스크롤 잠금 + useEffect(() => { + if (isOpen) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + return () => { + document.body.style.overflow = ""; + }; + }, [isOpen]); + + return ( + <> + {/* 오버레이 */} +
+ + {/* 패널 */} + + + {/* 삭제 확인 모달 */} + {showDeleteConfirm && detail && ( +
+
setShowDeleteConfirm(false)} + /> +
+
+
+ + warning + +
+

메시지 삭제

+
+

+ {detail.messageId} 메시지를 삭제하시겠습니까? +

+
+
+ + info + + 삭제 후 복구가 불가능합니다. +
+
+
+ + +
+
+
+ )} + + ); +} diff --git a/react/src/features/message/pages/MessageListPage.tsx b/react/src/features/message/pages/MessageListPage.tsx index daf3915..0163f88 100644 --- a/react/src/features/message/pages/MessageListPage.tsx +++ b/react/src/features/message/pages/MessageListPage.tsx @@ -1,7 +1,266 @@ +import { useState, useMemo } from "react"; +import { Link } from "react-router-dom"; +import PageHeader from "@/components/common/PageHeader"; +import SearchInput from "@/components/common/SearchInput"; +import FilterDropdown from "@/components/common/FilterDropdown"; +import FilterResetButton from "@/components/common/FilterResetButton"; +import Pagination from "@/components/common/Pagination"; +import EmptyState from "@/components/common/EmptyState"; +import CopyButton from "@/components/common/CopyButton"; +import MessageSlidePanel from "../components/MessageSlidePanel"; +import { formatDate } from "@/utils/format"; +import { MOCK_MESSAGES, SERVICE_FILTER_OPTIONS } from "../types"; + +const PAGE_SIZE = 10; + export default function MessageListPage() { + // 필터 입력 상태 + const [search, setSearch] = useState(""); + const [serviceFilter, setServiceFilter] = useState("전체 서비스"); + const [currentPage, setCurrentPage] = useState(1); + const [loading, setLoading] = useState(false); + + // 실제 적용될 필터 (조회 버튼 클릭 시 반영) + const [appliedSearch, setAppliedSearch] = useState(""); + const [appliedService, setAppliedService] = useState("전체 서비스"); + + // 슬라이드 패널 상태 + const [panelOpen, setPanelOpen] = useState(false); + const [selectedMessageId, setSelectedMessageId] = useState( + null + ); + + // 조회 버튼 + const handleQuery = () => { + setLoading(true); + setTimeout(() => { + setAppliedSearch(search); + setAppliedService(serviceFilter); + setCurrentPage(1); + setLoading(false); + }, 400); + }; + + // 필터 초기화 + const handleReset = () => { + setSearch(""); + setServiceFilter("전체 서비스"); + setAppliedSearch(""); + setAppliedService("전체 서비스"); + setCurrentPage(1); + }; + + // 필터링된 데이터 + const filtered = useMemo(() => { + return MOCK_MESSAGES.filter((msg) => { + // 검색 (메시지 ID / 제목) + if (appliedSearch) { + const q = appliedSearch.toLowerCase(); + if ( + !msg.messageId.toLowerCase().includes(q) && + !msg.title.toLowerCase().includes(q) + ) return false; + } + // 서비스 필터 + if ( + appliedService !== "전체 서비스" && + msg.serviceName !== appliedService + ) { + return false; + } + return true; + }); + }, [appliedSearch, appliedService]); + + // 페이지네이션 + const totalItems = filtered.length; + const totalPages = Math.max(1, Math.ceil(totalItems / PAGE_SIZE)); + const paged = filtered.slice( + (currentPage - 1) * PAGE_SIZE, + currentPage * PAGE_SIZE + ); + + // 행 클릭 → 슬라이드 패널 + const handleRowClick = (messageId: string) => { + setSelectedMessageId(messageId); + setPanelOpen(true); + }; + return ( -
-

메시지 목록

+
+ + add + 새 메시지 + + } + /> + + {/* 필터바 */} +
+
+ + + + +
+
+ + {/* 테이블 */} + {loading ? ( + /* 로딩 스켈레톤 */ +
+ + + + + + + + + + + {Array.from({ length: 5 }).map((_, i) => ( + + + + + + + ))} + +
+ 메시지 ID + + 메시지 제목 + + 서비스 + + 작성일 +
+
+
+
+
+
+
+
+
+
+ ) : paged.length > 0 ? ( +
+
+ + + + + + + + + + + {paged.map((msg, idx) => ( + handleRowClick(msg.messageId)} + > + {/* 메시지 ID */} + + {/* 메시지 제목 */} + + {/* 서비스 */} + + {/* 작성일 */} + + + ))} + +
+ 메시지 ID + + 메시지 제목 + + 서비스 + + 작성일 +
+
e.stopPropagation()} + > + + {msg.messageId} + + +
+
+ + {msg.title} + + + + {msg.serviceName} + + + + {formatDate(msg.createdAt)} + +
+
+ + {/* 페이지네이션 */} + +
+ ) : ( + + )} + + {/* 슬라이드 패널 */} + setPanelOpen(false)} + messageId={selectedMessageId} + />
); } diff --git a/react/src/features/message/pages/MessageRegisterPage.tsx b/react/src/features/message/pages/MessageRegisterPage.tsx index ec064da..109eff2 100644 --- a/react/src/features/message/pages/MessageRegisterPage.tsx +++ b/react/src/features/message/pages/MessageRegisterPage.tsx @@ -1,7 +1,362 @@ +import { useState, useRef } from "react"; +import { useNavigate } from "react-router-dom"; +import { toast } from "sonner"; +import PageHeader from "@/components/common/PageHeader"; +import useShake from "@/hooks/useShake"; +import MessagePreview from "../components/MessagePreview"; +import { SERVICE_OPTIONS, LINK_TYPE } from "../types"; +import type { LinkType } from "../types"; + export default function MessageRegisterPage() { + const navigate = useNavigate(); + const { triggerShake, cls } = useShake(); + + // 폼 상태 + const [service, setService] = useState(""); + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + const [imageUrl, setImageUrl] = useState(""); + const [linkUrl, setLinkUrl] = useState(""); + const [linkType, setLinkType] = useState(LINK_TYPE.WEB); + const [extra, setExtra] = useState(""); + + // 필드 ref (스크롤용) + const serviceRef = useRef(null); + const titleRef = useRef(null); + + // 에러 메시지 상태 (shake와 별도로 유지) + const [errors, setErrors] = useState>({}); + + // 확인 모달 상태 + const [showConfirm, setShowConfirm] = useState(false); + + // 필수 필드 검증 + const validate = (): boolean => { + const newErrors: Record = {}; + const shakeFields: string[] = []; + + if (!service) { + newErrors.service = "필수 입력 항목입니다."; + shakeFields.push("service"); + } + if (!title.trim()) { + newErrors.title = "필수 입력 항목입니다."; + shakeFields.push("title"); + } + + setErrors(newErrors); + if (shakeFields.length > 0) { + triggerShake(shakeFields); + // 첫 번째 에러 필드로 스크롤 + 포커스 + const firstRef = shakeFields[0] === "service" ? serviceRef : titleRef; + firstRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); + firstRef.current?.focus({ preventScroll: true }); + return false; + } + return true; + }; + + // 저장 버튼 클릭 + const handleSave = () => { + if (!validate()) return; + setShowConfirm(true); + }; + + // 모달 확인 → 저장 실행 + const handleConfirmSave = () => { + setShowConfirm(false); + toast.success("저장이 완료되었습니다."); + setTimeout(() => navigate("/messages"), 600); + }; + + // 입력 시 해당 필드 에러 제거 + const clearError = (field: string) => { + if (errors[field]) { + setErrors((prev) => { + const next = { ...prev }; + delete next[field]; + return next; + }); + } + }; + return ( -
-

메시지 등록

+
+ + + {/* 12-col 그리드 */} +
+ {/* 좌측: 폼 (col-span-7) */} +
+
+ {/* 카드 헤더 */} +
+ + edit_note + +

새 메시지

+
+ + {/* 폼 */} +
+ {/* 1. 서비스 선택 */} +
+ +
+ + + expand_more + +
+ {errors.service && ( +

+ + error + + {errors.service} +

+ )} +
+ + {/* 2. 제목 */} +
+ + { + setTitle(e.target.value); + clearError("title"); + }} + placeholder="메시지 제목을 입력하세요" + className={`w-full px-4 py-2.5 bg-white border rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB] focus:border-transparent transition-shadow placeholder-gray-400 text-[#0f172a] ${ + errors.title + ? "border-red-500 ring-2 ring-red-500/15" + : "border-gray-300" + } ${cls("title")}`} + /> + {errors.title && ( +

+ + error + + {errors.title} +

+ )} +
+ + {/* 3. 내용 */} +
+ +