feat(WEB): 동적 게시판, 파트너 관리, 공지 팝업 모달 추가

- 동적 게시판 시스템 구현 (/boards/[boardCode])
- 파트너 관리 페이지 및 폼 추가
- 공지 팝업 모달 컴포넌트 (NoticePopupModal)
  - localStorage 기반 1일간 숨김 기능
  - 테스트 페이지 (/test/popup)
- IntegratedListTemplateV2 개선
- 기타 버그 수정 및 타입 개선

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-12-30 21:56:01 +09:00
parent 7b917fcbcd
commit f8dbc6b2ae
43 changed files with 6395 additions and 113 deletions

View File

@@ -0,0 +1,169 @@
'use client';
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { cn } from '@/components/ui/utils';
/* eslint-disable @next/next/no-img-element */
// ============================================
// 타입 정의
// ============================================
export interface NoticePopupData {
id: string;
title: string;
content: string;
imageUrl?: string;
}
interface NoticePopupModalProps {
popup: NoticePopupData;
open: boolean;
onOpenChange: (open: boolean) => void;
}
// ============================================
// localStorage 유틸리티
// ============================================
const POPUP_DISMISS_PREFIX = 'popup_dismissed_';
/**
* 자정까지의 타임스탬프 계산
*/
function getMidnightTimestamp(): number {
const now = new Date();
const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 0, 0);
return midnight.getTime();
}
/**
* 팝업이 오늘 하루 동안 숨김 상태인지 확인
*/
export function isPopupDismissedForToday(popupId: string): boolean {
if (typeof window === 'undefined') return false;
const dismissedUntil = localStorage.getItem(`${POPUP_DISMISS_PREFIX}${popupId}`);
if (!dismissedUntil) return false;
const dismissedTimestamp = parseInt(dismissedUntil, 10);
return Date.now() < dismissedTimestamp;
}
/**
* 팝업을 오늘 하루 동안 숨김 처리
*/
function dismissPopupForToday(popupId: string): void {
if (typeof window === 'undefined') return;
const midnightTimestamp = getMidnightTimestamp();
localStorage.setItem(`${POPUP_DISMISS_PREFIX}${popupId}`, String(midnightTimestamp));
}
// ============================================
// 컴포넌트
// ============================================
export function NoticePopupModal({ popup, open, onOpenChange }: NoticePopupModalProps) {
const [dontShowToday, setDontShowToday] = React.useState(false);
const handleClose = () => {
if (dontShowToday) {
dismissPopupForToday(popup.id);
}
onOpenChange(false);
};
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal>
{/* 오버레이 */}
<DialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/50',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0'
)}
/>
{/* 팝업 컨텐츠 */}
<DialogPrimitive.Content
className={cn(
'fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]',
'w-full max-w-md bg-background rounded-lg shadow-lg overflow-hidden',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'duration-200'
)}
>
{/* 헤더 */}
<div className="bg-muted px-4 py-3 border-b">
<DialogPrimitive.Title className="text-base font-medium text-center">
</DialogPrimitive.Title>
</div>
{/* 본문 */}
<div className="p-6">
{/* 제목 */}
<h3 className="text-base font-medium mb-4">{popup.title}</h3>
{/* 이미지 영역 */}
{popup.imageUrl ? (
<div className="relative w-full aspect-[4/3] mb-4 rounded-md overflow-hidden border bg-muted">
<img
src={popup.imageUrl}
alt={popup.title}
className="w-full h-full object-cover"
/>
</div>
) : (
<div className="w-full aspect-[4/3] mb-4 rounded-md border bg-muted flex items-center justify-center">
<span className="text-muted-foreground text-sm">IMG</span>
</div>
)}
{/* 내용 */}
<div className="text-sm text-foreground mb-6">
<p className="text-muted-foreground mb-2"></p>
<div
className="prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: popup.content }}
/>
</div>
{/* 구분선 */}
<div className="border-t mb-4" />
{/* 하단 영역 */}
<div className="flex items-center justify-between">
{/* 체크박스 */}
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={dontShowToday}
onCheckedChange={(checked) => setDontShowToday(checked === true)}
/>
<span className="text-sm text-muted-foreground">
1
</span>
</label>
{/* 닫기 버튼 */}
<Button
variant="default"
size="sm"
onClick={handleClose}
className="px-6"
>
</Button>
</div>
</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}

View File

@@ -0,0 +1,2 @@
export { NoticePopupModal, isPopupDismissedForToday } from './NoticePopupModal';
export type { NoticePopupData } from './NoticePopupModal';