feat: 메시지 관리 페이지 구현 (#19)
- 메시지 타입 정의 + 목 데이터 10건 (types.ts) - iOS/Android 푸시 알림 프리뷰 컴포넌트 (MessagePreview) - 슬라이드 패널 상세 보기 + 삭제 기능 (MessageSlidePanel) - 메시지 목록 페이지: 필터(ID/제목 검색, 서비스), 테이블, 페이지네이션 - 메시지 작성 페이지: 12-col 그리드 폼 + 실시간 프리뷰, 필수 검증, 저장 모달 Closes #19 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b1957b1bfc
commit
6653e5da2a
379
react/src/features/message/components/MessagePreview.tsx
Normal file
379
react/src/features/message/components/MessagePreview.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div>
|
||||||
|
{/* 탭 */}
|
||||||
|
<div className="flex border-b border-gray-200 mb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`flex-1 px-3 py-2 text-xs font-medium border-b-2 transition-colors cursor-pointer ${
|
||||||
|
tab === "ios"
|
||||||
|
? "text-[#0f172a] border-[#2563EB]"
|
||||||
|
: "text-gray-400 border-transparent hover:text-[#0f172a]"
|
||||||
|
}`}
|
||||||
|
onClick={() => setTab("ios")}
|
||||||
|
>
|
||||||
|
iOS
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`flex-1 px-3 py-2 text-xs font-medium border-b-2 transition-colors cursor-pointer ${
|
||||||
|
tab === "android"
|
||||||
|
? "text-[#0f172a] border-[#2563EB]"
|
||||||
|
: "text-gray-400 border-transparent hover:text-[#0f172a]"
|
||||||
|
}`}
|
||||||
|
onClick={() => setTab("android")}
|
||||||
|
>
|
||||||
|
Android
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* iOS 프리뷰 */}
|
||||||
|
{tab === "ios" && (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
{/* Banner Notification */}
|
||||||
|
<div className="min-h-[130px]">
|
||||||
|
<p className="text-[10px] uppercase font-bold text-gray-400 tracking-wider mb-2">
|
||||||
|
Banner Notification
|
||||||
|
</p>
|
||||||
|
<div className="bg-white/90 backdrop-blur-md rounded-2xl p-3 shadow-lg border border-gray-200">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="size-8 bg-[#2563EB] rounded-xl flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-white"
|
||||||
|
style={{ fontSize: "16px" }}
|
||||||
|
>
|
||||||
|
shopping_bag
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs font-bold text-[#0f172a] break-words">
|
||||||
|
{title || "메시지 제목을 입력하세요"}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-gray-600 mt-0.5 break-words">
|
||||||
|
{body || "메시지 내용을 입력하세요"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1 flex-shrink-0 self-start">
|
||||||
|
<p className="text-[10px] text-gray-400">now</p>
|
||||||
|
{hasImage && (
|
||||||
|
<div className="w-8 h-8 bg-gray-100 border border-dashed border-gray-300 rounded flex items-center justify-center">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-gray-300"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
image
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Full Screen Preview - iOS */}
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] uppercase font-bold text-gray-400 tracking-wider mb-2">
|
||||||
|
Full Screen Preview
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className={`relative ${phoneWidth}`}>
|
||||||
|
<div
|
||||||
|
className={`${isLarge ? "border-[10px]" : "border-[8px]"} border-gray-800 bg-gray-800 ${isLarge ? "rounded-[2.5rem]" : "rounded-[2rem]"} overflow-hidden shadow-xl`}
|
||||||
|
style={{ aspectRatio: "9/19.5" }}
|
||||||
|
>
|
||||||
|
{/* 노치 */}
|
||||||
|
<div
|
||||||
|
className={`absolute top-0 left-1/2 -translate-x-1/2 ${isLarge ? "w-32 h-7" : "w-24 h-5"} bg-black rounded-b-2xl z-10`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="w-full h-full bg-cover bg-center overflow-hidden relative"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url('${PHONE_BG}')`,
|
||||||
|
filter: "brightness(1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 상태바 */}
|
||||||
|
<div className="px-4 pt-2 pb-1 flex justify-between items-center text-white text-[10px] font-medium bg-black/20">
|
||||||
|
<span>9:41</span>
|
||||||
|
<div className="flex gap-0.5 items-center">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{ fontSize: "12px" }}
|
||||||
|
>
|
||||||
|
signal_cellular_alt
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{ fontSize: "12px" }}
|
||||||
|
>
|
||||||
|
wifi
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{ fontSize: "12px" }}
|
||||||
|
>
|
||||||
|
battery_full
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 알림 카드 */}
|
||||||
|
<div
|
||||||
|
className={`absolute ${isLarge ? "top-16 left-3 right-3" : "top-12 left-2 right-2"} bg-white/80 backdrop-blur-md rounded-lg shadow-lg border border-white/30 overflow-hidden`}
|
||||||
|
>
|
||||||
|
<div className="p-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="size-6 bg-[#2563EB] rounded-md flex items-center justify-center flex-shrink-0">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-white"
|
||||||
|
style={{ fontSize: "12px" }}
|
||||||
|
>
|
||||||
|
shopping_bag
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-[10px] font-bold text-[#0f172a] break-words">
|
||||||
|
{title || "메시지 제목을 입력하세요"}
|
||||||
|
</p>
|
||||||
|
<p className="text-[8px] text-gray-600 mt-0.5 break-words">
|
||||||
|
{truncatedBody || "메시지 내용을 입력하세요"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-[8px] text-gray-400 flex-shrink-0 self-start">
|
||||||
|
now
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{hasImage && (
|
||||||
|
<div
|
||||||
|
className="w-full bg-gray-100 border-t border-dashed border-gray-200 flex items-center justify-center"
|
||||||
|
style={{ height: isLarge ? 150 : 120 }}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-gray-300 text-2xl">
|
||||||
|
image
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* 홈 인디케이터 */}
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-5 flex items-center justify-center">
|
||||||
|
<div className="w-24 h-1 bg-white/60 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Android 프리뷰 */}
|
||||||
|
{tab === "android" && (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
{/* Banner Notification */}
|
||||||
|
<div className="min-h-[130px]">
|
||||||
|
<p className="text-[10px] uppercase font-bold text-gray-400 tracking-wider mb-2">
|
||||||
|
Banner Notification
|
||||||
|
</p>
|
||||||
|
<div className="bg-white rounded-2xl p-3 shadow-lg border border-gray-200">
|
||||||
|
<div className="flex items-center gap-1.5 mb-1.5">
|
||||||
|
<div className="size-3.5 bg-[#2563EB] rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-white"
|
||||||
|
style={{ fontSize: "8px" }}
|
||||||
|
>
|
||||||
|
shopping_bag
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-gray-500 font-medium">{appName}</p>
|
||||||
|
<span className="text-[10px] text-gray-300 mx-0.5">·</span>
|
||||||
|
<p className="text-[10px] text-gray-400">now</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs font-bold text-[#0f172a] break-words">
|
||||||
|
{title || "메시지 제목을 입력하세요"}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-gray-600 mt-0.5 break-words">
|
||||||
|
{body || "메시지 내용을 입력하세요"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{hasImage && (
|
||||||
|
<div className="w-8 h-8 bg-gray-100 border border-dashed border-gray-300 rounded flex items-center justify-center flex-shrink-0">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-gray-300"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
image
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Full Screen Preview - Android */}
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] uppercase font-bold text-gray-400 tracking-wider mb-2">
|
||||||
|
Full Screen Preview
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className={`relative ${phoneWidth}`}>
|
||||||
|
<div
|
||||||
|
className={`${isLarge ? "border-[10px]" : "border-[8px]"} border-gray-800 bg-gray-800 rounded-[2rem] overflow-hidden shadow-xl`}
|
||||||
|
style={{ aspectRatio: "9/19.5" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full h-full bg-cover bg-center overflow-hidden relative"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url('${PHONE_BG}')`,
|
||||||
|
filter: "brightness(1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 상태바 */}
|
||||||
|
<div className="px-4 pt-2 pb-1 flex justify-between items-center text-white text-[10px] font-medium bg-black/20">
|
||||||
|
<span>12:30</span>
|
||||||
|
<div className="flex gap-0.5 items-center">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{ fontSize: "12px" }}
|
||||||
|
>
|
||||||
|
signal_cellular_alt
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{ fontSize: "12px" }}
|
||||||
|
>
|
||||||
|
wifi
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{ fontSize: "12px" }}
|
||||||
|
>
|
||||||
|
battery_full
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 알림 카드 */}
|
||||||
|
<div
|
||||||
|
className={`absolute ${isLarge ? "top-14" : "top-11"} left-2 right-2 bg-white/90 rounded-xl shadow-lg overflow-hidden`}
|
||||||
|
>
|
||||||
|
<div className="px-2.5 pt-2 pb-1.5">
|
||||||
|
<div className="flex items-center gap-1 mb-1">
|
||||||
|
<div className="size-3 bg-[#2563EB] rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-white"
|
||||||
|
style={{ fontSize: "7px" }}
|
||||||
|
>
|
||||||
|
shopping_bag
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[8px] text-gray-500 font-medium">
|
||||||
|
{appName}
|
||||||
|
</p>
|
||||||
|
<span className="text-[8px] text-gray-300 mx-0.5">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<p className="text-[8px] text-gray-400">now</p>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-gray-400 ml-auto"
|
||||||
|
style={{ fontSize: "10px" }}
|
||||||
|
>
|
||||||
|
expand_more
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-[10px] font-bold text-[#0f172a] break-words">
|
||||||
|
{title || "메시지 제목을 입력하세요"}
|
||||||
|
</p>
|
||||||
|
<p className="text-[8px] text-gray-600 mt-0.5 break-words">
|
||||||
|
{truncatedBody || "메시지 내용을 입력하세요"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{hasImage && (
|
||||||
|
<div className="w-8 h-8 bg-gray-100 border border-dashed border-gray-200 rounded flex items-center justify-center flex-shrink-0">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-gray-300"
|
||||||
|
style={{ fontSize: "12px" }}
|
||||||
|
>
|
||||||
|
image
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{hasImage && (
|
||||||
|
<div
|
||||||
|
className="w-full bg-gray-100 border-t border-dashed border-gray-200 flex items-center justify-center"
|
||||||
|
style={{ height: isLarge ? 150 : 120 }}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-gray-300 text-2xl">
|
||||||
|
image
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* 네비게이션 바 */}
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-4 flex items-center justify-center gap-10 bg-black/10">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-white/60"
|
||||||
|
style={{ fontSize: "13px" }}
|
||||||
|
>
|
||||||
|
arrow_back
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-white/60"
|
||||||
|
style={{ fontSize: "13px" }}
|
||||||
|
>
|
||||||
|
circle
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-white/60"
|
||||||
|
style={{ fontSize: "13px" }}
|
||||||
|
>
|
||||||
|
crop_square
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-[10px] text-gray-400 leading-relaxed text-center mt-4">
|
||||||
|
이 미리보기는 예상 디자인입니다.
|
||||||
|
<br />
|
||||||
|
실제 표시는 기기 설정 및 OS 버전에 따라 다를 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
262
react/src/features/message/components/MessageSlidePanel.tsx
Normal file
262
react/src/features/message/components/MessageSlidePanel.tsx
Normal file
|
|
@ -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<HTMLDivElement>(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 (
|
||||||
|
<>
|
||||||
|
{/* 오버레이 */}
|
||||||
|
<div
|
||||||
|
className={`fixed inset-0 bg-black/30 z-50 transition-opacity duration-300 ${
|
||||||
|
isOpen ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||||
|
}`}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 패널 */}
|
||||||
|
<aside
|
||||||
|
className={`fixed top-0 right-0 h-full w-[480px] bg-white shadow-2xl z-50 flex flex-col transition-transform duration-300 ${
|
||||||
|
isOpen ? "translate-x-0" : "translate-x-full"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* 패널 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-5 border-b border-gray-200">
|
||||||
|
<h2 className="text-lg font-bold text-[#0f172a]">메시지 상세</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded-lg hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-xl">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 패널 본문 */}
|
||||||
|
<div ref={bodyRef} className="flex-1 overflow-y-auto p-6">
|
||||||
|
{detail ? (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* 메시지 ID */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider flex-shrink-0">
|
||||||
|
메시지 ID
|
||||||
|
</label>
|
||||||
|
<code className="text-sm font-medium text-[#2563EB] bg-[#2563EB]/5 px-2 py-0.5 rounded">
|
||||||
|
{detail.messageId}
|
||||||
|
</code>
|
||||||
|
<CopyButton text={detail.messageId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px bg-gray-100" />
|
||||||
|
|
||||||
|
{/* 서비스 선택 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-2">
|
||||||
|
서비스 선택
|
||||||
|
</label>
|
||||||
|
<p className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm text-gray-700">
|
||||||
|
{detail.serviceName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 제목 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-2">
|
||||||
|
제목
|
||||||
|
</label>
|
||||||
|
<p className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm text-gray-700">
|
||||||
|
{detail.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 내용 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-2">
|
||||||
|
내용
|
||||||
|
</label>
|
||||||
|
<div className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm text-gray-700 leading-relaxed min-h-[120px]">
|
||||||
|
{detail.body}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 이미지 URL */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-2">
|
||||||
|
이미지 URL
|
||||||
|
</label>
|
||||||
|
<p
|
||||||
|
className={`w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm truncate ${
|
||||||
|
detail.imageUrl
|
||||||
|
? "text-gray-500"
|
||||||
|
: "italic text-gray-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{detail.imageUrl || "등록된 이미지 없음"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 링크 URL */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<label className="text-sm font-medium text-[#0f172a]">
|
||||||
|
링크 URL
|
||||||
|
</label>
|
||||||
|
<span
|
||||||
|
className={`text-[11px] font-medium px-1.5 py-0.5 rounded ${
|
||||||
|
detail.linkType === "deeplink"
|
||||||
|
? "bg-purple-100 text-purple-700"
|
||||||
|
: "bg-blue-100 text-blue-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{detail.linkType === "deeplink" ? "딥링크" : "웹 링크"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm text-gray-500 truncate">
|
||||||
|
{detail.linkUrl || "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 기타 정보 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-2">
|
||||||
|
기타 정보
|
||||||
|
</label>
|
||||||
|
<div className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm text-gray-700 leading-relaxed min-h-[80px] whitespace-pre-line">
|
||||||
|
{detail.extra || "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px bg-gray-100" />
|
||||||
|
|
||||||
|
{/* 프리뷰 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-3">
|
||||||
|
프리뷰
|
||||||
|
</label>
|
||||||
|
<MessagePreview
|
||||||
|
title={detail.title}
|
||||||
|
body={detail.body}
|
||||||
|
hasImage={!!detail.imageUrl}
|
||||||
|
appName={detail.serviceName}
|
||||||
|
variant="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px bg-gray-100" />
|
||||||
|
|
||||||
|
{/* 삭제 버튼 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="w-full flex items-center justify-center border border-red-300 text-red-600 hover:bg-red-50 px-5 py-2.5 rounded text-sm font-medium transition"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-400 text-sm">
|
||||||
|
메시지를 선택해주세요
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* 삭제 확인 모달 */}
|
||||||
|
{showDeleteConfirm && detail && (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40"
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
/>
|
||||||
|
<div className="relative bg-white rounded-xl shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-200">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="size-10 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="material-symbols-outlined text-red-600 text-xl">
|
||||||
|
warning
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-[#0f172a]">메시지 삭제</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[#0f172a] mb-2">
|
||||||
|
<strong>{detail.messageId}</strong> 메시지를 삭제하시겠습니까?
|
||||||
|
</p>
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg px-4 py-3 mb-5">
|
||||||
|
<div className="flex items-center gap-1.5 text-red-700 text-xs font-medium">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
info
|
||||||
|
</span>
|
||||||
|
<span>삭제 후 복구가 불가능합니다.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
toast.success(`${detail.messageId} 메시지가 삭제되었습니다.`);
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-base">
|
||||||
|
delete
|
||||||
|
</span>
|
||||||
|
<span>삭제</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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() {
|
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<string | null>(
|
||||||
|
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 (
|
return (
|
||||||
<div className="p-6">
|
<div>
|
||||||
<h1 className="text-2xl font-bold">메시지 목록</h1>
|
<PageHeader
|
||||||
|
title="메시지 목록"
|
||||||
|
description="시스템에서 발송된 모든 메시지 내역을 관리합니다."
|
||||||
|
action={
|
||||||
|
<Link
|
||||||
|
to="/messages/register"
|
||||||
|
className="flex items-center justify-center gap-2 min-w-[154px] px-5 py-2.5 bg-[#2563EB] hover:bg-[#1d4ed8] text-white rounded-lg text-sm font-medium transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-lg">add</span>
|
||||||
|
새 메시지
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 필터바 */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-6">
|
||||||
|
<div className="flex items-end gap-4">
|
||||||
|
<SearchInput
|
||||||
|
value={search}
|
||||||
|
onChange={setSearch}
|
||||||
|
placeholder="검색어를 입력하세요"
|
||||||
|
label="메시지 ID / 제목"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<FilterDropdown
|
||||||
|
label="서비스 구분"
|
||||||
|
value={serviceFilter}
|
||||||
|
options={SERVICE_FILTER_OPTIONS}
|
||||||
|
onChange={setServiceFilter}
|
||||||
|
className="w-[140px] flex-shrink-0"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<FilterResetButton onClick={handleReset} disabled={loading} />
|
||||||
|
<button
|
||||||
|
onClick={handleQuery}
|
||||||
|
disabled={loading}
|
||||||
|
className="h-[38px] flex-shrink-0 bg-gray-800 hover:bg-gray-900 active:scale-95 text-white rounded-lg px-5 text-sm font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
조회
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
{loading ? (
|
||||||
|
/* 로딩 스켈레톤 */
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
메시지 ID
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
메시지 제목
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
서비스
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
작성일
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<tr
|
||||||
|
key={i}
|
||||||
|
className={i < 4 ? "border-b border-gray-100" : ""}
|
||||||
|
>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div className="h-4 w-20 rounded bg-gray-100 animate-pulse mx-auto" />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="h-4 w-48 rounded bg-gray-100 animate-pulse" />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div className="h-4 w-24 rounded bg-gray-100 animate-pulse mx-auto" />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div className="h-4 w-20 rounded bg-gray-100 animate-pulse mx-auto" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : paged.length > 0 ? (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
메시지 ID
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
메시지 제목
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
서비스
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
작성일
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{paged.map((msg, idx) => (
|
||||||
|
<tr
|
||||||
|
key={msg.messageId}
|
||||||
|
className={`${idx < paged.length - 1 ? "border-b border-gray-100" : ""} hover:bg-gray-50/50 transition-colors cursor-pointer`}
|
||||||
|
onClick={() => handleRowClick(msg.messageId)}
|
||||||
|
>
|
||||||
|
{/* 메시지 ID */}
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center gap-2"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<code className="text-sm text-gray-700 font-medium">
|
||||||
|
{msg.messageId}
|
||||||
|
</code>
|
||||||
|
<CopyButton text={msg.messageId} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{/* 메시지 제목 */}
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className="text-sm text-gray-700 truncate max-w-xs block">
|
||||||
|
{msg.title}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{/* 서비스 */}
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{msg.serviceName}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{/* 작성일 */}
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{formatDate(msg.createdAt)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
totalItems={totalItems}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon="search_off"
|
||||||
|
message="검색 결과가 없습니다"
|
||||||
|
description="다른 검색어를 입력하거나 필터를 변경해보세요."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 슬라이드 패널 */}
|
||||||
|
<MessageSlidePanel
|
||||||
|
isOpen={panelOpen}
|
||||||
|
onClose={() => setPanelOpen(false)}
|
||||||
|
messageId={selectedMessageId}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
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<LinkType>(LINK_TYPE.WEB);
|
||||||
|
const [extra, setExtra] = useState("");
|
||||||
|
|
||||||
|
// 필드 ref (스크롤용)
|
||||||
|
const serviceRef = useRef<HTMLSelectElement>(null);
|
||||||
|
const titleRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// 에러 메시지 상태 (shake와 별도로 유지)
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// 확인 모달 상태
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
const validate = (): boolean => {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
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 (
|
return (
|
||||||
<div className="p-6">
|
<div>
|
||||||
<h1 className="text-2xl font-bold">메시지 등록</h1>
|
<PageHeader
|
||||||
|
title="메시지 작성"
|
||||||
|
description="새로운 푸시 메시지를 작성하고 실시간 미리보기를 확인하세요."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 12-col 그리드 */}
|
||||||
|
<div className="grid grid-cols-12 gap-8">
|
||||||
|
{/* 좌측: 폼 (col-span-7) */}
|
||||||
|
<div className="col-span-7">
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||||
|
{/* 카드 헤더 */}
|
||||||
|
<div className="flex items-center gap-3 px-6 py-4 border-b border-gray-100 bg-gray-50">
|
||||||
|
<span className="material-symbols-outlined text-xl text-[#2563EB]">
|
||||||
|
edit_note
|
||||||
|
</span>
|
||||||
|
<h2 className="text-base font-bold text-[#0f172a]">새 메시지</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 폼 */}
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* 1. 서비스 선택 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-2">
|
||||||
|
서비스 선택 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
ref={serviceRef}
|
||||||
|
value={service}
|
||||||
|
onChange={(e) => {
|
||||||
|
setService(e.target.value);
|
||||||
|
clearError("service");
|
||||||
|
}}
|
||||||
|
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 cursor-pointer appearance-none ${
|
||||||
|
!service ? "text-gray-400" : "text-[#0f172a]"
|
||||||
|
} ${errors.service ? "border-red-500 ring-2 ring-red-500/15" : "border-gray-300"} ${cls("service")}`}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
서비스를 선택하세요
|
||||||
|
</option>
|
||||||
|
{SERVICE_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none"
|
||||||
|
style={{ fontSize: "20px" }}
|
||||||
|
>
|
||||||
|
expand_more
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{errors.service && (
|
||||||
|
<p className="flex items-center gap-1 mt-1.5 text-red-500 text-xs">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
error
|
||||||
|
</span>
|
||||||
|
{errors.service}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. 제목 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-2">
|
||||||
|
제목 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref={titleRef}
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => {
|
||||||
|
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 && (
|
||||||
|
<p className="flex items-center gap-1 mt-1.5 text-red-500 text-xs">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
error
|
||||||
|
</span>
|
||||||
|
{errors.title}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3. 내용 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-2">
|
||||||
|
내용
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
placeholder="메시지 내용을 입력하세요"
|
||||||
|
rows={6}
|
||||||
|
className="w-full px-4 py-2.5 bg-white border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB] focus:border-transparent transition-shadow placeholder-gray-400 text-[#0f172a] resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 4. 이미지 URL */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-2">
|
||||||
|
이미지 URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={imageUrl}
|
||||||
|
onChange={(e) => setImageUrl(e.target.value)}
|
||||||
|
placeholder="https://example.com/image.jpg"
|
||||||
|
className="w-full px-4 py-2.5 bg-white border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB] focus:border-transparent transition-shadow placeholder-gray-400 text-[#0f172a]"
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-gray-400 mt-1.5 flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
info
|
||||||
|
</span>
|
||||||
|
URL을 입력하면 프리뷰에서 이미지 표시 위치를 확인할 수
|
||||||
|
있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 5. 링크 URL + 라디오 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-2">
|
||||||
|
링크 URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={linkUrl}
|
||||||
|
onChange={(e) => setLinkUrl(e.target.value)}
|
||||||
|
placeholder="https://..."
|
||||||
|
className="w-full px-4 py-2.5 bg-white border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB] focus:border-transparent transition-shadow placeholder-gray-400 text-[#0f172a] mb-3"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="linkType"
|
||||||
|
value={LINK_TYPE.WEB}
|
||||||
|
checked={linkType === LINK_TYPE.WEB}
|
||||||
|
onChange={() => setLinkType(LINK_TYPE.WEB)}
|
||||||
|
className="w-4 h-4 text-[#2563EB] cursor-pointer"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-[#0f172a] font-medium">
|
||||||
|
웹 URL
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="linkType"
|
||||||
|
value={LINK_TYPE.DEEPLINK}
|
||||||
|
checked={linkType === LINK_TYPE.DEEPLINK}
|
||||||
|
onChange={() => setLinkType(LINK_TYPE.DEEPLINK)}
|
||||||
|
className="w-4 h-4 text-[#2563EB] cursor-pointer"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-[#0f172a] font-medium">
|
||||||
|
딥링크
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 6. 기타 정보 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-2">
|
||||||
|
기타 정보
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={extra}
|
||||||
|
onChange={(e) => setExtra(e.target.value)}
|
||||||
|
placeholder="추가 정보를 입력하세요"
|
||||||
|
rows={6}
|
||||||
|
className="w-full px-4 py-2.5 bg-white border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB] focus:border-transparent transition-shadow placeholder-gray-400 text-[#0f172a] resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 저장 버튼 */}
|
||||||
|
<div className="pt-4 border-t border-gray-100">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
className="flex items-center justify-center gap-2 bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-6 py-2.5 rounded text-sm font-medium transition shadow-sm shadow-[#2563EB]/30 w-full"
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측: 프리뷰 (col-span-5, sticky) */}
|
||||||
|
<div className="col-span-5">
|
||||||
|
<div
|
||||||
|
className="sticky bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden p-6"
|
||||||
|
style={{ top: "6rem" }}
|
||||||
|
>
|
||||||
|
<MessagePreview
|
||||||
|
title={title}
|
||||||
|
body={body}
|
||||||
|
hasImage={!!imageUrl.trim()}
|
||||||
|
appName={SERVICE_OPTIONS.find((o) => o.value === service)?.label}
|
||||||
|
variant="large"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 확인 모달 */}
|
||||||
|
{showConfirm && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/40 z-[100] flex items-center justify-center"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) setShowConfirm(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="bg-white rounded-xl shadow-2xl max-w-md w-full p-6 border border-gray-200">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="size-10 rounded-full bg-amber-100 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="material-symbols-outlined text-amber-600 text-xl">
|
||||||
|
warning
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-[#0f172a]">저장 확인</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[#0f172a] mb-2">
|
||||||
|
입력한 내용으로 저장하시겠습니까?
|
||||||
|
</p>
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-lg px-4 py-3 mb-5">
|
||||||
|
<div className="flex items-center gap-1.5 text-amber-700 text-xs font-medium">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
info
|
||||||
|
</span>
|
||||||
|
<span>저장 후 수정이 불가능합니다.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowConfirm(false)}
|
||||||
|
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirmSave}
|
||||||
|
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm shadow-[#2563EB]/30 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-base">
|
||||||
|
check
|
||||||
|
</span>
|
||||||
|
<span>확인</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1,138 @@
|
||||||
// Message feature 타입 정의
|
// 링크 타입
|
||||||
|
export const LINK_TYPE = {
|
||||||
|
WEB: "web",
|
||||||
|
DEEPLINK: "deeplink",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type LinkType = (typeof LINK_TYPE)[keyof typeof LINK_TYPE];
|
||||||
|
|
||||||
|
// 메시지 목록용 요약
|
||||||
|
export interface MessageSummary {
|
||||||
|
messageId: string;
|
||||||
|
title: string;
|
||||||
|
serviceName: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메시지 상세
|
||||||
|
export interface MessageDetail extends MessageSummary {
|
||||||
|
body: string;
|
||||||
|
imageUrl: string;
|
||||||
|
linkUrl: string;
|
||||||
|
linkType: LinkType;
|
||||||
|
extra: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메시지 작성 폼 데이터
|
||||||
|
export interface MessageFormData {
|
||||||
|
service: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
imageUrl: string;
|
||||||
|
linkUrl: string;
|
||||||
|
linkType: LinkType;
|
||||||
|
extra: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 서비스 옵션 (작성 폼 select용)
|
||||||
|
export const SERVICE_OPTIONS = [
|
||||||
|
{ value: "spms-shop", label: "SPMS 쇼핑몰" },
|
||||||
|
{ value: "spms-partner", label: "SPMS 파트너 센터" },
|
||||||
|
{ value: "spms-delivery", label: "SPMS 딜리버리" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// 서비스 필터 옵션 (목록 필터용)
|
||||||
|
export const SERVICE_FILTER_OPTIONS = [
|
||||||
|
"전체 서비스",
|
||||||
|
"Main App",
|
||||||
|
"Admin Portal",
|
||||||
|
"API Gateway",
|
||||||
|
];
|
||||||
|
|
||||||
|
// 목 데이터 - 메시지 목록
|
||||||
|
export const MOCK_MESSAGES: MessageSummary[] = [
|
||||||
|
{ messageId: "MSG-005", title: "[공지] 시스템 점검 안내 (2024-05-20)", serviceName: "Main App", createdAt: "2024-05-18" },
|
||||||
|
{ messageId: "MSG-004", title: "신규 가입 환영 웰컴 메시지", serviceName: "Admin Portal", createdAt: "2024-05-19" },
|
||||||
|
{ messageId: "MSG-003", title: "API 인증 오류 리포트 알림", serviceName: "API Gateway", createdAt: "2024-05-19" },
|
||||||
|
{ messageId: "MSG-002", title: "결제 시스템 업데이트 완료 안내", serviceName: "Main App", createdAt: "2024-05-17" },
|
||||||
|
{ messageId: "MSG-001", title: "[광고] 시즌 프로모션 혜택 안내", serviceName: "Main App", createdAt: "2024-05-16" },
|
||||||
|
{ messageId: "MSG-006", title: "배송 지연 안내 긴급 알림", serviceName: "Main App", createdAt: "2024-05-15" },
|
||||||
|
{ messageId: "MSG-007", title: "[이벤트] 출석 체크 보상 안내", serviceName: "Admin Portal", createdAt: "2024-05-14" },
|
||||||
|
{ messageId: "MSG-008", title: "서버 점검 완료 및 정상화 안내", serviceName: "API Gateway", createdAt: "2024-05-13" },
|
||||||
|
{ messageId: "MSG-009", title: "개인정보 처리방침 변경 안내", serviceName: "Main App", createdAt: "2024-05-12" },
|
||||||
|
{ messageId: "MSG-010", title: "[광고] 신규 회원 가입 혜택 안내", serviceName: "Admin Portal", createdAt: "2024-05-11" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 목 데이터 - 메시지 상세 (messageId → 상세 정보)
|
||||||
|
export const MOCK_MESSAGE_DETAILS: Record<string, MessageDetail> = {
|
||||||
|
"MSG-001": {
|
||||||
|
messageId: "MSG-001", title: "[광고] 시즌 프로모션 혜택 안내", serviceName: "Main App", createdAt: "2024-05-16",
|
||||||
|
body: "안녕하세요! 시즌 프로모션이 시작되었습니다. 지금 바로 참여하고 다양한 혜택을 받아보세요. 최대 50% 할인 쿠폰과 추가 적립 포인트가 제공됩니다.",
|
||||||
|
imageUrl: "https://cdn.spms.io/promo/summer-sale-banner.jpg",
|
||||||
|
linkUrl: "https://shop.spms.io/promo/summer2024", linkType: "web",
|
||||||
|
extra: "프로모션 기간: 2024-05-16 ~ 2024-06-30\n대상: 전체 회원\n쿠폰 코드: SUMMER50",
|
||||||
|
},
|
||||||
|
"MSG-002": {
|
||||||
|
messageId: "MSG-002", title: "결제 시스템 업데이트 완료 안내", serviceName: "Main App", createdAt: "2024-05-17",
|
||||||
|
body: "결제 시스템이 최신 버전으로 업데이트되었습니다. 새로운 결제 수단이 추가되었으며, 더 빠르고 안전한 결제 경험을 제공합니다.",
|
||||||
|
imageUrl: "",
|
||||||
|
linkUrl: "https://shop.spms.io/notice/payment-update", linkType: "web",
|
||||||
|
extra: "변경사항: 네이버페이, 카카오페이 간편결제 추가\nPG사: KG이니시스 v2.5 적용",
|
||||||
|
},
|
||||||
|
"MSG-003": {
|
||||||
|
messageId: "MSG-003", title: "API 인증 오류 리포트 알림", serviceName: "API Gateway", createdAt: "2024-05-19",
|
||||||
|
body: "API 인증 과정에서 오류가 감지되었습니다. 일부 클라이언트에서 토큰 갱신 실패가 발생하고 있으니 확인 부탁드립니다.",
|
||||||
|
imageUrl: "",
|
||||||
|
linkUrl: "spms://admin/api-monitor", linkType: "deeplink",
|
||||||
|
extra: "오류 코드: AUTH_TOKEN_EXPIRED\n영향 범위: v2.3 이하 클라이언트\n임시 조치: 수동 토큰 재발급 필요",
|
||||||
|
},
|
||||||
|
"MSG-004": {
|
||||||
|
messageId: "MSG-004", title: "신규 가입 환영 웰컴 메시지", serviceName: "Admin Portal", createdAt: "2024-05-19",
|
||||||
|
body: "환영합니다! SPMS 서비스에 가입해 주셔서 감사합니다. 신규 회원 혜택으로 7일간 프리미엄 기능을 무료로 이용하실 수 있습니다.",
|
||||||
|
imageUrl: "https://cdn.spms.io/welcome/onboarding-guide.png",
|
||||||
|
linkUrl: "spms://onboarding/start", linkType: "deeplink",
|
||||||
|
extra: "혜택 만료일: 가입일 기준 7일\n자동 전환: Basic 플랜",
|
||||||
|
},
|
||||||
|
"MSG-005": {
|
||||||
|
messageId: "MSG-005", title: "[공지] 시스템 점검 안내 (2024-05-20)", serviceName: "Main App", createdAt: "2024-05-18",
|
||||||
|
body: "서비스 안정성 개선을 위해 시스템 점검이 예정되어 있습니다. 점검 시간: 2024-05-20 02:00 ~ 06:00 (약 4시간). 해당 시간에는 서비스 이용이 제한됩니다.",
|
||||||
|
imageUrl: "",
|
||||||
|
linkUrl: "https://status.spms.io", linkType: "web",
|
||||||
|
extra: "점검 유형: 정기 점검\nDB 마이그레이션 포함\n긴급 연락: ops@spms.io",
|
||||||
|
},
|
||||||
|
"MSG-006": {
|
||||||
|
messageId: "MSG-006", title: "배송 지연 안내 긴급 알림", serviceName: "Main App", createdAt: "2024-05-15",
|
||||||
|
body: "고객님의 주문 상품 배송이 지연되고 있습니다. 물류 센터 사정으로 인해 1~2일 추가 소요될 수 있습니다. 불편을 드려 죄송합니다.",
|
||||||
|
imageUrl: "",
|
||||||
|
linkUrl: "spms://order/tracking", linkType: "deeplink",
|
||||||
|
extra: "지연 사유: 물류 센터 과부하\n보상: 500 포인트 자동 지급",
|
||||||
|
},
|
||||||
|
"MSG-007": {
|
||||||
|
messageId: "MSG-007", title: "[이벤트] 출석 체크 보상 안내", serviceName: "Admin Portal", createdAt: "2024-05-14",
|
||||||
|
body: "출석 체크 이벤트에 참여해 주셔서 감사합니다! 7일 연속 출석 보상으로 500 포인트가 지급되었습니다. 마이페이지에서 확인해 주세요.",
|
||||||
|
imageUrl: "https://cdn.spms.io/event/attendance-reward.png",
|
||||||
|
linkUrl: "spms://mypage/point", linkType: "deeplink",
|
||||||
|
extra: "이벤트 기간: 2024-05-01 ~ 2024-05-31\n추가 보너스: 30일 연속 출석 시 5,000P",
|
||||||
|
},
|
||||||
|
"MSG-008": {
|
||||||
|
messageId: "MSG-008", title: "서버 점검 완료 및 정상화 안내", serviceName: "API Gateway", createdAt: "2024-05-13",
|
||||||
|
body: "서버 정기 점검이 완료되었습니다. 모든 서비스가 정상적으로 운영되고 있습니다. 이용에 불편을 드려 죄송합니다.",
|
||||||
|
imageUrl: "",
|
||||||
|
linkUrl: "https://status.spms.io", linkType: "web",
|
||||||
|
extra: "점검 소요 시간: 3시간 42분\n적용 패치: v3.8.2",
|
||||||
|
},
|
||||||
|
"MSG-009": {
|
||||||
|
messageId: "MSG-009", title: "개인정보 처리방침 변경 안내", serviceName: "Main App", createdAt: "2024-05-12",
|
||||||
|
body: "개인정보 처리방침이 변경되었습니다. 주요 변경 사항: 수집 항목 변경, 보유 기간 조정. 자세한 내용은 설정 > 개인정보처리방침에서 확인해 주세요.",
|
||||||
|
imageUrl: "",
|
||||||
|
linkUrl: "https://shop.spms.io/privacy-policy", linkType: "web",
|
||||||
|
extra: "시행일: 2024-06-01\n주요 변경: 마케팅 수집 항목 세분화\n법적 근거: 개인정보보호법 제15조",
|
||||||
|
},
|
||||||
|
"MSG-010": {
|
||||||
|
messageId: "MSG-010", title: "[광고] 신규 회원 가입 혜택 안내", serviceName: "Admin Portal", createdAt: "2024-05-11",
|
||||||
|
body: "신규 회원 가입 시 다양한 혜택을 제공합니다. 가입 즉시 2,000원 할인 쿠폰과 무료 배송 쿠폰을 드립니다.",
|
||||||
|
imageUrl: "https://cdn.spms.io/ad/signup-benefit.jpg",
|
||||||
|
linkUrl: "https://shop.spms.io/signup", linkType: "web",
|
||||||
|
extra: "실패 사유: 수신 대상 토큰 만료\n재시도 예정: 수동 재발송 필요",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user