'use client'; import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; import { TokenData, ResultDto, ApiError } from '@/types/response/common'; import { CLAIM_NAME_IDENTIFIER, CLAIM_EMAIL, CLAIM_NAME } from '@/constants/common'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } // 클라이언트용 fetch 유틸 (Route Handler 호출용) interface FetchApiOptions extends Omit { body?: unknown; /** true로 설정 시 에러 응답을 throw하지 않고 ResultDto를 그대로 반환 */ silent?: boolean; } // 토큰 갱신 중복 방지 let refreshPromise: Promise | null = null; async function tryRefreshToken(): Promise { if (refreshPromise) { return refreshPromise; } refreshPromise = fetch('/api/auth/refresh-token', { method: 'POST' }) .then(res => res.json()) .then((res: ResultDto) => res.success) .catch(() => false) .finally(() => { refreshPromise = null; }); return refreshPromise; } export async function fetchApi(url: string, options?: FetchApiOptions): Promise> { const isFormData = options?.body instanceof FormData; const hasBody = options?.body !== undefined; const fetchOptions: RequestInit = { ...options, headers: { ...(!isFormData && hasBody ? { 'Content-Type': 'application/json' } : {}), ...options?.headers }, body: isFormData ? options.body as BodyInit : (options?.body ? JSON.stringify(options.body) : undefined) }; let res: ResultDto = await (await fetch(url, fetchOptions)).json(); // 401 시 토큰 갱신 후 재시도 if (!res.success && res.status === 401) { const refreshed = await tryRefreshToken(); if (refreshed) { res = await (await fetch(url, fetchOptions)).json(); } } // 기본: 에러 시 throw (silent 옵션으로 비활성화) if (!options?.silent) { throwError(res); } return res; } // JWT 토큰에서 사용자 정보 추출 export function decodeAccessToken(token: string): TokenData|null { try { const base64URL = token.split('.')[1]; // JWT의 Payload 부분 const base64 = base64URL.replace(/-/g, '+').replace(/_/g, '/'); // Base64 형식 변환 const jsonPayload = decodeURIComponent( atob(base64).split('').map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)).join('') ); const payload = JSON.parse(jsonPayload); return { id: payload[CLAIM_NAME_IDENTIFIER] || null, email: payload[CLAIM_EMAIL] || null, name: payload[CLAIM_NAME] || null }; } catch { return null; } } // 날짜 형식 변환 표시 export function formatDate(dateString: string|null): string { if (!dateString) { return '-'; } const date = new Date(dateString); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffSec = Math.floor(diffMs / 1000); const diffMin = Math.floor(diffSec / 60); const diffHour = Math.floor(diffMin / 60); const diffDay = Math.floor(diffHour / 24); const diffWeek = Math.floor(diffDay / 7); const diffMonth = Math.floor(diffDay / 30); const diffYear = Math.floor(diffDay / 365); if (diffSec < 60) return '방금 전'; if (diffMin < 60) return `${diffMin}분 전`; if (diffHour < 24) return `${diffHour}시간 전`; if (diffDay < 7) return `${diffDay}일 전`; if (diffWeek < 5) return `${diffWeek}주 전`; if (diffMonth < 12) return `${diffMonth}개월 전`; return `${diffYear}년 전`; } // 2025.01.01. 12:00:00 으로 조회 export function getDateTime(dateString: string|null) { if (!dateString) { return '-'; } const date = new Date(dateString); return date.toLocaleString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\. /g, '.').replace(/\.(\d{2}:)/, ' $1'); } // 파일 이름 생성 export function getDateFilename(prefix: string): string { if (!prefix) { return ''; } const now = new Date(); const yyyy = now.getFullYear(); const MM = String(now.getMonth() + 1).padStart(2, '0'); const dd = String(now.getDate()).padStart(2, '0'); const hh = String(now.getHours()).padStart(2, '0'); const mm = String(now.getMinutes()).padStart(2, '0'); const ss = String(now.getSeconds()).padStart(2, '0'); return `${prefix}-${yyyy}${MM}${dd}${hh}${mm}${ss}.png`; } // HTML 태그 제거 export function stripHtmlTags(str?: string|null): string { if (!str) { return ''; } const div = document.createElement('div'); div.innerHTML = str; return div.textContent || div.innerText || ''; } // 비밀번호 정책 안내 문구 생성성 export function getPasswordPolicyMessage(policy: { passwordUppercaseLength: number; passwordNumbersLength: number; passwordSpecialcharsLength: number; }) { const parts: string[] = []; if (policy.passwordUppercaseLength > 0) { parts.push(`영문 대문자 ${policy.passwordUppercaseLength}자 이상`); } if (policy.passwordNumbersLength > 0) { parts.push(`숫자 ${policy.passwordNumbersLength}자 이상`); } if (policy.passwordSpecialcharsLength > 0) { parts.push(`특수문자 ${policy.passwordSpecialcharsLength}자 이상`); } if (parts.length > 0) { return `비밀번호는 ${parts.join(', ')}를 포함해야 합니다.`; } return ''; } // 특수문자 제거 export function filterSpecialCharacters(str: string) { return str.replace(/[^\p{L}\p{N}\s]/gu, ''); } // ResultDto에서 첫 번째 오류 조회 export function getError(errors: ApiError[]|null|undefined, index = 0): string|undefined { if (!errors || errors.length === 0) { return undefined; } return errors[index]?.description; } // 서버 응답 메시지 분석 export function throwError(res: ResultDto): void { if (res.success) { return; } let message: string|null|undefined = getError(res.errors); if (!message) { message = res.message; } if (!message) { switch (res.status) { case 400: message = '잘못된 요청입니다.'; break; case 401: message = '로그인 후 이용해 주세요.'; // 인증 만료 이벤트 발행 window.dispatchEvent(new CustomEvent('auth:unauthorized'));; break; case 403: message = '권한이 없습니다.'; break; case 404: message = '요청하신 페이지를 찾을 수 없습니다.'; break; case 500: message = '서버 오류가 발생했습니다. 다시 시도해 주세요.'; break; } } if (message) { throw new Error(message); } } // 1000회 이상 조회된 게시글인지 확인 export function isHotPost(showHotIcon: boolean, post: { views: number; createdAt: string }): boolean { return (showHotIcon && post.views >= 1000 && Date.now() - new Date(post.createdAt).getTime() < 7 * 24 * 60 * 60 * 1000); } // 24시간 이내에 작성된 게시글인지 확인 export function isNewPost(showNewIcon: boolean, post: { createdAt: string }): boolean { return (showNewIcon && Date.now() - new Date(post.createdAt).getTime() < 24 * 60 * 60 * 1000); } // a 태그에 target="_blank" 추가 export function forceTargetBlank(html: string) { return html.replace(/]*target=)([^>]*href=["'][^"']+["'])/gi, ' limitDate; }