From bafdb86d42119fbb4729c08b8b053f7ec8427860 Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 11 Jun 2025 15:02:46 +0900 Subject: [PATCH] utils files add --- src/utils/constants.js | 214 +++++++++++++++++ src/utils/formatters.js | 345 +++++++++++++++++++++++++++ src/utils/validators.js | 517 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1076 insertions(+) create mode 100644 src/utils/constants.js create mode 100644 src/utils/formatters.js create mode 100644 src/utils/validators.js diff --git a/src/utils/constants.js b/src/utils/constants.js new file mode 100644 index 0000000..da78f46 --- /dev/null +++ b/src/utils/constants.js @@ -0,0 +1,214 @@ +/** + * AI 마케팅 서비스 상수 정의 + * + * @description 애플리케이션 전반에서 사용되는 상수들을 정의합니다. + * @author AI Marketing Team + * @version 1.0 + */ + +// API 엔드포인트 상수 +export const API_ENDPOINTS = { + AUTH: '/auth', + MEMBER: '/member', + STORE: '/store', + MENU: '/menu', + CONTENT: '/content', + RECOMMEND: '/recommend', + ANALYSIS: '/analysis' +} + +// 인증 관련 상수 +export const AUTH_CONSTANTS = { + ACCESS_TOKEN_KEY: 'accessToken', + REFRESH_TOKEN_KEY: 'refreshToken', + USER_INFO_KEY: 'userInfo', + TOKEN_EXPIRY_BUFFER: 5 * 60 * 1000, // 5분 (밀리초) + MAX_LOGIN_ATTEMPTS: 5, + LOCKOUT_DURATION: 15 * 60 * 1000 // 15분 (밀리초) +} + +// 콘텐츠 타입 상수 +export const CONTENT_TYPES = [ + { text: 'SNS 포스트', value: 'SNS_POST', icon: 'mdi-instagram' }, + { text: '포스터', value: 'POSTER', icon: 'mdi-image' }, + { text: '블로그 글', value: 'BLOG', icon: 'mdi-post' }, + { text: '광고 문구', value: 'AD_COPY', icon: 'mdi-bullhorn' } +] + +// SNS 플랫폼 상수 +export const PLATFORMS = [ + { text: '인스타그램', value: 'INSTAGRAM', icon: 'mdi-instagram', color: '#E4405F' }, + { text: '페이스북', value: 'FACEBOOK', icon: 'mdi-facebook', color: '#1877F2' }, + { text: '네이버 블로그', value: 'NAVER_BLOG', icon: 'mdi-post', color: '#03C75A' }, + { text: '카카오톡', value: 'KAKAO_TALK', icon: 'mdi-chat', color: '#FEE500' }, + { text: '트위터', value: 'TWITTER', icon: 'mdi-twitter', color: '#1DA1F2' }, + { text: '유튜브', value: 'YOUTUBE', icon: 'mdi-youtube', color: '#FF0000' } +] + +// 콘텐츠 상태 상수 +export const CONTENT_STATUS = [ + { text: '초안', value: 'DRAFT', color: 'grey', icon: 'mdi-file-document-outline' }, + { text: '검토중', value: 'REVIEW', color: 'orange', icon: 'mdi-eye' }, + { text: '승인됨', value: 'APPROVED', color: 'green', icon: 'mdi-check-circle' }, + { text: '게시됨', value: 'PUBLISHED', color: 'blue', icon: 'mdi-publish' }, + { text: '보관됨', value: 'ARCHIVED', color: 'grey-darken-2', icon: 'mdi-archive' } +] + +// 톤앤매너 옵션 +export const TONE_OPTIONS = [ + { text: '친근함', value: '친근함', description: '고객과 가까운 느낌의 편안한 톤' }, + { text: '전문적', value: '전문적', description: '신뢰할 수 있는 전문가 느낌' }, + { text: '유머러스', value: '유머러스', description: '재미있고 유쾌한 분위기' }, + { text: '고급스러움', value: '고급스러움', description: '품격 있고 세련된 느낌' }, + { text: '트렌디', value: '트렌디', description: '최신 트렌드를 반영한 스타일' } +] + +// 감정 강도 옵션 +export const EMOTION_INTENSITY = [ + { text: '차분함', value: '차분함', description: '차분하고 안정적인 톤' }, + { text: '보통', value: '보통', description: '적당한 감정 표현' }, + { text: '활발함', value: '활발함', description: '에너지 넘치는 표현' }, + { text: '열정적', value: '열정적', description: '강렬하고 역동적인 표현' } +] + +// 프로모션 옵션 +export const PROMOTION_OPTIONS = [ + { text: '없음', value: '없음' }, + { text: '할인 이벤트', value: '할인 이벤트' }, + { text: '신메뉴 출시', value: '신메뉴 출시' }, + { text: '시즌 특가', value: '시즌 특가' }, + { text: '회원 혜택', value: '회원 혜택' }, + { text: '기념일 이벤트', value: '기념일 이벤트' } +] + +// 업종 카테고리 +export const BUSINESS_CATEGORIES = [ + { text: '음식점', value: 'RESTAURANT', icon: 'mdi-food' }, + { text: '카페', value: 'CAFE', icon: 'mdi-coffee' }, + { text: '베이커리', value: 'BAKERY', icon: 'mdi-cake' }, + { text: '치킨/피자', value: 'CHICKEN_PIZZA', icon: 'mdi-pizza' }, + { text: '분식', value: 'SNACK_BAR', icon: 'mdi-food-hot-dog' }, + { text: '술집', value: 'BAR', icon: 'mdi-glass-mug' }, + { text: '기타', value: 'OTHER', icon: 'mdi-store' } +] + +// 메뉴 카테고리 +export const MENU_CATEGORIES = [ + { text: '주메뉴', value: 'MAIN', color: 'primary' }, + { text: '사이드', value: 'SIDE', color: 'secondary' }, + { text: '음료', value: 'BEVERAGE', color: 'info' }, + { text: '디저트', value: 'DESSERT', color: 'warning' }, + { text: '세트메뉴', value: 'SET', color: 'success' } +] + +// 매출 분석 기간 옵션 +export const ANALYSIS_PERIODS = [ + { text: '오늘', value: 'today' }, + { text: '어제', value: 'yesterday' }, + { text: '이번 주', value: 'this_week' }, + { text: '지난 주', value: 'last_week' }, + { text: '이번 달', value: 'this_month' }, + { text: '지난 달', value: 'last_month' }, + { text: '지난 3개월', value: 'last_3_months' }, + { text: '직접 선택', value: 'custom' } +] + +// 차트 타입 옵션 +export const CHART_TYPES = [ + { text: '일별', value: 'daily', icon: 'mdi-calendar-today' }, + { text: '주별', value: 'weekly', icon: 'mdi-calendar-week' }, + { text: '월별', value: 'monthly', icon: 'mdi-calendar-month' } +] + +// AI 추천 타입 +export const AI_RECOMMENDATION_TYPES = [ + { text: '마케팅 팁', value: 'MARKETING_TIP', icon: 'mdi-lightbulb' }, + { text: '메뉴 제안', value: 'MENU_SUGGESTION', icon: 'mdi-food' }, + { text: '프로모션 아이디어', value: 'PROMOTION_IDEA', icon: 'mdi-sale' }, + { text: '콘텐츠 아이디어', value: 'CONTENT_IDEA', icon: 'mdi-lightbulb-variant' } +] + +// 날씨 조건 상수 +export const WEATHER_CONDITIONS = [ + { text: '맑음', value: 'SUNNY', icon: 'mdi-weather-sunny' }, + { text: '흐림', value: 'CLOUDY', icon: 'mdi-weather-cloudy' }, + { text: '비', value: 'RAINY', icon: 'mdi-weather-rainy' }, + { text: '눈', value: 'SNOWY', icon: 'mdi-weather-snowy' }, + { text: '바람', value: 'WINDY', icon: 'mdi-weather-windy' } +] + +// 시간대 분류 +export const TIME_SLOTS = [ + { text: '아침 (06:00-11:00)', value: 'MORNING', start: 6, end: 11 }, + { text: '점심 (11:00-14:00)', value: 'LUNCH', start: 11, end: 14 }, + { text: '오후 (14:00-17:00)', value: 'AFTERNOON', start: 14, end: 17 }, + { text: '저녁 (17:00-21:00)', value: 'DINNER', start: 17, end: 21 }, + { text: '밤 (21:00-24:00)', value: 'NIGHT', start: 21, end: 24 }, + { text: '심야 (00:00-06:00)', value: 'LATE_NIGHT', start: 0, end: 6 } +] + +// 페이지네이션 상수 +export const PAGINATION = { + DEFAULT_PAGE_SIZE: 10, + PAGE_SIZE_OPTIONS: [5, 10, 20, 50], + MAX_VISIBLE_PAGES: 5 +} + +// 파일 업로드 상수 +export const FILE_UPLOAD = { + MAX_FILE_SIZE: 10 * 1024 * 1024, // 10MB + ALLOWED_IMAGE_TYPES: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], + ALLOWED_VIDEO_TYPES: ['video/mp4', 'video/avi', 'video/mov'], + MAX_IMAGES_COUNT: 10, + MAX_VIDEOS_COUNT: 3 +} + +// UI 관련 상수 +export const UI_CONSTANTS = { + MOBILE_BREAKPOINT: 768, + TABLET_BREAKPOINT: 1024, + DESKTOP_BREAKPOINT: 1200, + HEADER_HEIGHT: 64, + FOOTER_HEIGHT: 80, + SIDEBAR_WIDTH: 280 +} + +// 알림 타입 +export const NOTIFICATION_TYPES = { + SUCCESS: 'success', + ERROR: 'error', + WARNING: 'warning', + INFO: 'info' +} + +// 로딩 상태 메시지 +export const LOADING_MESSAGES = { + GENERATING_CONTENT: 'AI가 콘텐츠를 생성하고 있습니다...', + UPLOADING_FILE: '파일을 업로드하고 있습니다...', + SAVING_DATA: '데이터를 저장하고 있습니다...', + LOADING_DATA: '데이터를 불러오고 있습니다...', + ANALYZING_DATA: '데이터를 분석하고 있습니다...' +} + +// 에러 메시지 +export const ERROR_MESSAGES = { + NETWORK_ERROR: '네트워크 연결을 확인해주세요.', + SERVER_ERROR: '서버에서 오류가 발생했습니다.', + UNAUTHORIZED: '로그인이 필요합니다.', + FORBIDDEN: '접근 권한이 없습니다.', + NOT_FOUND: '요청한 데이터를 찾을 수 없습니다.', + VALIDATION_ERROR: '입력 정보를 확인해주세요.', + FILE_SIZE_ERROR: '파일 크기가 너무 큽니다.', + FILE_TYPE_ERROR: '지원하지 않는 파일 형식입니다.' +} + +// 성공 메시지 +export const SUCCESS_MESSAGES = { + SAVE_SUCCESS: '성공적으로 저장되었습니다.', + UPDATE_SUCCESS: '성공적으로 수정되었습니다.', + DELETE_SUCCESS: '성공적으로 삭제되었습니다.', + UPLOAD_SUCCESS: '파일이 성공적으로 업로드되었습니다.', + CONTENT_GENERATED: 'AI 콘텐츠가 성공적으로 생성되었습니다.', + LOGIN_SUCCESS: '로그인되었습니다.', + LOGOUT_SUCCESS: '로그아웃되었습니다.' +} \ No newline at end of file diff --git a/src/utils/formatters.js b/src/utils/formatters.js new file mode 100644 index 0000000..9cf9d1d --- /dev/null +++ b/src/utils/formatters.js @@ -0,0 +1,345 @@ +/** + * 데이터 포맷팅 유틸리티 + * + * @description 다양한 데이터 타입을 사용자 친화적인 형태로 포맷팅하는 함수들을 제공합니다. + * @author AI Marketing Team + * @version 1.0 + */ + +/** + * 숫자를 통화 형식으로 포맷팅 + * @param {number} amount - 포맷팅할 금액 + * @param {string} currency - 통화 단위 (기본값: 'KRW') + * @param {boolean} showSymbol - 통화 기호 표시 여부 (기본값: true) + * @returns {string} 포맷팅된 통화 문자열 + */ +export const formatCurrency = (amount, currency = 'KRW', showSymbol = true) => { + if (amount === null || amount === undefined || isNaN(amount)) { + return '0원' + } + + const formatter = new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: currency, + minimumFractionDigits: 0, + maximumFractionDigits: 0 + }) + + if (showSymbol) { + return formatter.format(amount) + } else { + return amount.toLocaleString('ko-KR') + } +} + +/** + * 큰 숫자를 축약된 형태로 포맷팅 (예: 1000 -> 1K) + * @param {number} num - 포맷팅할 숫자 + * @param {number} digits - 소수점 자릿수 (기본값: 1) + * @returns {string} 축약된 숫자 문자열 + */ +export const formatAbbreviatedNumber = (num, digits = 1) => { + if (num === null || num === undefined || isNaN(num)) { + return '0' + } + + const units = [ + { value: 1e9, symbol: 'B' }, + { value: 1e8, symbol: '억' }, + { value: 1e6, symbol: 'M' }, + { value: 1e4, symbol: '만' }, + { value: 1e3, symbol: 'K' } + ] + + for (let unit of units) { + if (Math.abs(num) >= unit.value) { + return (num / unit.value).toFixed(digits) + unit.symbol + } + } + + return num.toString() +} + +/** + * 퍼센트를 포맷팅 + * @param {number} value - 퍼센트 값 + * @param {number} decimals - 소수점 자릿수 (기본값: 1) + * @param {boolean} showSign - 부호 표시 여부 (기본값: false) + * @returns {string} 포맷팅된 퍼센트 문자열 + */ +export const formatPercentage = (value, decimals = 1, showSign = false) => { + if (value === null || value === undefined || isNaN(value)) { + return '0%' + } + + const sign = showSign && value > 0 ? '+' : '' + return `${sign}${value.toFixed(decimals)}%` +} + +/** + * 날짜를 한국어 형식으로 포맷팅 + * @param {Date|string} date - 포맷팅할 날짜 + * @param {string} format - 포맷 형식 ('full', 'date', 'time', 'datetime', 'relative') + * @returns {string} 포맷팅된 날짜 문자열 + */ +export const formatDate = (date, format = 'date') => { + if (!date) return '' + + const dateObj = typeof date === 'string' ? new Date(date) : date + + if (isNaN(dateObj.getTime())) { + return '잘못된 날짜' + } + + const options = { + full: { + year: 'numeric', + month: 'long', + day: 'numeric', + weekday: 'long' + }, + date: { + year: 'numeric', + month: '2-digit', + day: '2-digit' + }, + time: { + hour: '2-digit', + minute: '2-digit', + hour12: false + }, + datetime: { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false + } + } + + if (format === 'relative') { + return formatRelativeTime(dateObj) + } + + const formatOptions = options[format] || options.date + return new Intl.DateTimeFormat('ko-KR', formatOptions).format(dateObj) +} + +/** + * 상대적 시간 포맷팅 (예: 2시간 전, 3일 후) + * @param {Date|string} date - 기준 날짜 + * @param {Date} baseDate - 비교 기준 날짜 (기본값: 현재 시간) + * @returns {string} 상대적 시간 문자열 + */ +export const formatRelativeTime = (date, baseDate = new Date()) => { + if (!date) return '' + + const dateObj = typeof date === 'string' ? new Date(date) : date + const baseDateObj = typeof baseDate === 'string' ? new Date(baseDate) : baseDate + + if (isNaN(dateObj.getTime()) || isNaN(baseDateObj.getTime())) { + return '잘못된 날짜' + } + + const diffInSeconds = Math.floor((baseDateObj - dateObj) / 1000) + const absDiff = Math.abs(diffInSeconds) + + const units = [ + { name: '년', seconds: 31536000 }, + { name: '개월', seconds: 2592000 }, + { name: '일', seconds: 86400 }, + { name: '시간', seconds: 3600 }, + { name: '분', seconds: 60 } + ] + + for (let unit of units) { + const interval = Math.floor(absDiff / unit.seconds) + if (interval >= 1) { + return diffInSeconds > 0 + ? `${interval}${unit.name} 전` + : `${interval}${unit.name} 후` + } + } + + return diffInSeconds > 0 ? '방금 전' : '곧' +} + +/** + * 날짜와 시간을 사용자 친화적 형식으로 포맷팅 + * @param {Date|string} dateTime - 포맷팅할 날짜시간 + * @returns {string} 포맷팅된 날짜시간 문자열 + */ +export const formatDateTime = (dateTime) => { + if (!dateTime) return '' + + const dateObj = typeof dateTime === 'string' ? new Date(dateTime) : dateTime + + if (isNaN(dateObj.getTime())) { + return '잘못된 날짜' + } + + const now = new Date() + const diffInDays = Math.floor((now - dateObj) / (1000 * 60 * 60 * 24)) + + if (diffInDays === 0) { + // 오늘인 경우 시간만 표시 + return formatDate(dateObj, 'time') + } else if (diffInDays === 1) { + // 어제인 경우 + return `어제 ${formatDate(dateObj, 'time')}` + } else if (diffInDays < 7) { + // 일주일 이내인 경우 + const weekday = dateObj.toLocaleDateString('ko-KR', { weekday: 'short' }) + return `${weekday} ${formatDate(dateObj, 'time')}` + } else { + // 일주일 이상인 경우 전체 날짜 표시 + return formatDate(dateObj, 'datetime') + } +} + +/** + * 파일 크기를 사람이 읽기 쉬운 형태로 포맷팅 + * @param {number} bytes - 파일 크기 (바이트) + * @param {number} decimals - 소수점 자릿수 (기본값: 2) + * @returns {string} 포맷팅된 파일 크기 문자열 + */ +export const formatFileSize = (bytes, decimals = 2) => { + if (bytes === 0) return '0 B' + if (!bytes) return '' + + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}` +} + +/** + * 전화번호를 포맷팅 + * @param {string} phoneNumber - 전화번호 문자열 + * @returns {string} 포맷팅된 전화번호 + */ +export const formatPhoneNumber = (phoneNumber) => { + if (!phoneNumber) return '' + + // 숫자만 추출 + const numbers = phoneNumber.replace(/\D/g, '') + + // 휴대폰 번호 (010-xxxx-xxxx) + if (numbers.length === 11 && numbers.startsWith('010')) { + return `${numbers.slice(0, 3)}-${numbers.slice(3, 7)}-${numbers.slice(7)}` + } + + // 일반 전화번호 (02-xxx-xxxx 또는 03x-xxx-xxxx) + if (numbers.length === 10) { + if (numbers.startsWith('02')) { + return `${numbers.slice(0, 2)}-${numbers.slice(2, 6)}-${numbers.slice(6)}` + } else { + return `${numbers.slice(0, 3)}-${numbers.slice(3, 6)}-${numbers.slice(6)}` + } + } + + if (numbers.length === 11 && !numbers.startsWith('010')) { + return `${numbers.slice(0, 3)}-${numbers.slice(3, 7)}-${numbers.slice(7)}` + } + + return phoneNumber +} + +/** + * 사업자등록번호를 포맷팅 + * @param {string} businessNumber - 사업자등록번호 + * @returns {string} 포맷팅된 사업자등록번호 + */ +export const formatBusinessNumber = (businessNumber) => { + if (!businessNumber) return '' + + const numbers = businessNumber.replace(/\D/g, '') + + if (numbers.length === 10) { + return `${numbers.slice(0, 3)}-${numbers.slice(3, 5)}-${numbers.slice(5)}` + } + + return businessNumber +} + +/** + * 텍스트를 지정된 길이로 자르고 말줄임표 추가 + * @param {string} text - 자를 텍스트 + * @param {number} maxLength - 최대 길이 + * @param {string} suffix - 말줄임표 (기본값: '...') + * @returns {string} 잘린 텍스트 + */ +export const truncateText = (text, maxLength, suffix = '...') => { + if (!text) return '' + + if (text.length <= maxLength) { + return text + } + + return text.slice(0, maxLength - suffix.length) + suffix +} + +/** + * 해시태그 배열을 문자열로 포맷팅 + * @param {Array} hashtags - 해시태그 배열 + * @param {string} separator - 구분자 (기본값: ' ') + * @returns {string} 포맷팅된 해시태그 문자열 + */ +export const formatHashtags = (hashtags, separator = ' ') => { + if (!Array.isArray(hashtags) || hashtags.length === 0) { + return '' + } + + return hashtags + .filter(tag => tag && tag.trim()) + .map(tag => tag.startsWith('#') ? tag : `#${tag}`) + .join(separator) +} + +/** + * 주소를 간단한 형태로 포맷팅 + * @param {string} fullAddress - 전체 주소 + * @param {boolean} showDetail - 상세 주소 표시 여부 (기본값: false) + * @returns {string} 포맷팅된 주소 + */ +export const formatAddress = (fullAddress, showDetail = false) => { + if (!fullAddress) return '' + + const parts = fullAddress.split(' ') + + if (showDetail) { + return fullAddress + } + + // 시/도, 구/군만 표시 + if (parts.length >= 2) { + return `${parts[0]} ${parts[1]}` + } + + return fullAddress +} + +/** + * 배열을 문자열로 포맷팅 + * @param {Array} array - 포맷팅할 배열 + * @param {string} separator - 구분자 (기본값: ', ') + * @param {number} maxItems - 최대 표시 항목 수 + * @returns {string} 포맷팅된 문자열 + */ +export const formatArrayToString = (array, separator = ', ', maxItems = null) => { + if (!Array.isArray(array) || array.length === 0) { + return '' + } + + const items = maxItems ? array.slice(0, maxItems) : array + const result = items.join(separator) + + if (maxItems && array.length > maxItems) { + const remaining = array.length - maxItems + return `${result} 외 ${remaining}개` + } + + return result +} \ No newline at end of file diff --git a/src/utils/validators.js b/src/utils/validators.js new file mode 100644 index 0000000..77cce43 --- /dev/null +++ b/src/utils/validators.js @@ -0,0 +1,517 @@ +/** + * 유효성 검증 유틸리티 + * + * @description 다양한 입력값의 유효성을 검증하는 함수들을 제공합니다. + * @author AI Marketing Team + * @version 1.0 + */ + +/** + * 필수 입력값 검증 + * @param {any} value - 검증할 값 + * @param {string} fieldName - 필드명 (에러 메시지용) + * @returns {boolean|string} 유효하면 true, 유효하지 않으면 에러 메시지 + */ +export const validateRequired = (value, fieldName = '필드') => { + if (value === null || value === undefined || value === '') { + return `${fieldName}은(는) 필수 입력항목입니다.` + } + + if (typeof value === 'string' && value.trim() === '') { + return `${fieldName}을(를) 입력해주세요.` + } + + if (Array.isArray(value) && value.length === 0) { + return `${fieldName}을(를) 선택해주세요.` + } + + return true +} + +/** + * 이메일 형식 검증 + * @param {string} email - 검증할 이메일 + * @returns {boolean|string} 유효하면 true, 유효하지 않으면 에러 메시지 + */ +export const validateEmail = (email) => { + if (!email) return '이메일을 입력해주세요.' + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + + if (!emailRegex.test(email)) { + return '올바른 이메일 형식이 아닙니다.' + } + + return true +} + +/** + * 비밀번호 강도 검증 + * @param {string} password - 검증할 비밀번호 + * @param {Object} options - 검증 옵션 + * @returns {boolean|string} 유효하면 true, 유효하지 않으면 에러 메시지 + */ +export const validatePassword = (password, options = {}) => { + const { + minLength = 8, + maxLength = 50, + requireUppercase = true, + requireLowercase = true, + requireNumbers = true, + requireSpecialChars = true + } = options + + if (!password) { + return '비밀번호를 입력해주세요.' + } + + if (password.length < minLength) { + return `비밀번호는 최소 ${minLength}자 이상이어야 합니다.` + } + + if (password.length > maxLength) { + return `비밀번호는 최대 ${maxLength}자까지 가능합니다.` + } + + if (requireUppercase && !/[A-Z]/.test(password)) { + return '비밀번호에 대문자가 포함되어야 합니다.' + } + + if (requireLowercase && !/[a-z]/.test(password)) { + return '비밀번호에 소문자가 포함되어야 합니다.' + } + + if (requireNumbers && !/\d/.test(password)) { + return '비밀번호에 숫자가 포함되어야 합니다.' + } + + if (requireSpecialChars && !/[!@#$%^&*(),.?":{}|<>]/.test(password)) { + return '비밀번호에 특수문자가 포함되어야 합니다.' + } + + return true +} + +/** + * 비밀번호 확인 검증 + * @param {string} password - 원본 비밀번호 + * @param {string} confirmPassword - 확인 비밀번호 + * @returns {boolean|string} 유효하면 true, 유효하지 않으면 에러 메시지 + */ +export const validatePasswordConfirm = (password, confirmPassword) => { + if (!confirmPassword) { + return '비밀번호 확인을 입력해주세요.' + } + + if (password !== confirmPassword) { + return '비밀번호가 일치하지 않습니다.' + } + + return true +} + +/** + * 전화번호 형식 검증 + * @param {string} phoneNumber - 검증할 전화번호 + * @returns {boolean|string} 유효하면 true, 유효하지 않으면 에러 메시지 + */ +export const validatePhoneNumber = (phoneNumber) => { + if (!phoneNumber) { + return '전화번호를 입력해주세요.' + } + + // 숫자만 추출 + const numbers = phoneNumber.replace(/\D/g, '') + + // 휴대폰 번호 (010-xxxx-xxxx) + const mobileRegex = /^010\d{8}$/ + // 일반 전화번호 (02-xxx-xxxx, 03x-xxx-xxxx 등) + const landlineRegex = /^(02|0[3-9]\d)\d{7,8}$/ + + if (!mobileRegex.test(numbers) && !landlineRegex.test(numbers)) { + return '올바른 전화번호 형식이 아닙니다.' + } + + return true +} + +/** + * 사업자등록번호 검증 + * @param {string} businessNumber - 검증할 사업자등록번호 + * @returns {boolean|string} 유효하면 true, 유효하지 않으면 에러 메시지 + */ +export const validateBusinessNumber = (businessNumber) => { + if (!businessNumber) { + return '사업자등록번호를 입력해주세요.' + } + + const numbers = businessNumber.replace(/\D/g, '') + + if (numbers.length !== 10) { + return '사업자등록번호는 10자리 숫자여야 합니다.' + } + + // 사업자등록번호 체크섬 검증 + const checkSum = [1, 3, 7, 1, 3, 7, 1, 3, 5] + let sum = 0 + + for (let i = 0; i < 9; i++) { + sum += parseInt(numbers[i]) * checkSum[i] + } + + sum += parseInt((parseInt(numbers[8]) * 5) / 10) + const remainder = (10 - (sum % 10)) % 10 + + if (remainder !== parseInt(numbers[9])) { + return '올바르지 않은 사업자등록번호입니다.' + } + + return true +} + +/** + * 문자열 길이 검증 + * @param {string} value - 검증할 문자열 + * @param {number} minLength - 최소 길이 + * @param {number} maxLength - 최대 길이 + * @param {string} fieldName - 필드명 + * @returns {boolean|string} 유효하면 true, 유효하지 않으면 에러 메시지 + */ +export const validateLength = (value, minLength, maxLength, fieldName = '입력값') => { + if (!value) { + return `${fieldName}을(를) 입력해주세요.` + } + + if (value.length < minLength) { + return `${fieldName}은(는) 최소 ${minLength}자 이상이어야 합니다.` + } + + if (value.length > maxLength) { + return `${fieldName}은(는) 최대 ${maxLength}자까지 가능합니다.` + } + + return true +} + +/** + * 숫자 범위 검증 + * @param {number} value - 검증할 숫자 + * @param {number} min - 최소값 + * @param {number} max - 최대값 + * @param {string} fieldName - 필드명 + * @returns {boolean|string} 유효하면 true, 유효하지 않으면 에러 메시지 + */ +export const validateNumberRange = (value, min, max, fieldName = '숫자') => { + if (value === null || value === undefined || value === '') { + return `${fieldName}을(를) 입력해주세요.` + } + + const numValue = Number(value) + + if (isNaN(numValue)) { + return `${fieldName}은(는) 숫자여야 합니다.` + } + + if (numValue < min) { + return `${fieldName}은(는) ${min} 이상이어야 합니다.` + } + + if (numValue > max) { + return `${fieldName}은(는) ${max} 이하여야 합니다.` + } + + return true +} + +/** + * 양의 정수 검증 + * @param {any} value - 검증할 값 + * @param {string} fieldName - 필드명 + * @returns {boolean|string} 유효하면 true, 유효하지 않으면 에러 메시지 + */ +export const validatePositiveInteger = (value, fieldName = '숫자') => { + if (value === null || value === undefined || value === '') { + return `${fieldName}을(를) 입력해주세요.` + } + + const numValue = Number(value) + + if (isNaN(numValue) || !Number.isInteger(numValue) || numValue <= 0) { + return `${fieldName}은(는) 양의 정수여야 합니다.` + } + + return true +} + +/** + * 파일 크기 검증 + * @param {File} file - 검증할 파일 + * @param {number} maxSize - 최대 크기 (바이트) + * @returns {boolean|string} 유효하면 true, 유효하지 않으면 에러 메시지 + */ +export const validateFileSize = (file, maxSize) => { + if (!file) { + return '파일을 선택해주세요.' + } + + if (file.size > maxSize) { + const maxSizeMB = Math.round(maxSize / (1024 * 1024)) + return `파일 크기는 ${maxSizeMB}MB 이하여야 합니다.` + } + + return true +} + +/** + * 파일 형식 검증 + * @param {File} file - 검증할 파일 + * @param {Array} allowedTypes - 허용된 MIME 타입 배열 + * @returns {boolean|string} 유효하면 true, 유효하지 않으면 에러 메시지 + */ +export const validateFileType = (file, allowedTypes) => { + if (!file) { + return '파일을 선택해주세요.' + } + + if (!allowedTypes.includes(file.type)) { + const allowedExtensions = allowedTypes + .map(type => type.split('/')[1]) + .join(', ') + return `허용된 파일 형식: ${allowedExtensions}` + } + + return true +} + +/** + * 이미지 파일 검증 + * @param {File} file - 검증할 파일 + * @param {number} maxSize - 최대 크기 (기본값: 10MB) + * @returns {boolean|string} 유효하면 true, 유효하지 않으면 에러 메시지 + */ +export const validateImageFile = (file, maxSize = 10 * 1024 * 1024) => { + const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] + + const sizeValidation = validateFileSize(file, maxSize) + if (sizeValidation !== true) return sizeValidation + + const typeValidation = validateFileType(file, allowedTypes) + if (typeValidation !== true) return typeValidation + + return true +} + +/** + * URL 형식 검증 + * @param {string} url - 검증할 URL + * @param {boolean} requireProtocol - 프로토콜 필수 여부 (기본값: true) + * @returns {boolean|string} 유효하면 true, 유효하지 않으면 에러 메시지 + */ +export const validateUrl = (url, requireProtocol = true) => { + if (!url) { + return 'URL을 입력해주세요.' + } + + try { + const urlObj = new URL(url) + + if (requireProtocol && !['http:', 'https:'].includes(urlObj.protocol)) { + return 'http 또는 https 프로토콜을 사용해주세요.' + } + + return true + } catch (error) { + return '올바른 URL 형식이 아닙니다.' + } +} + +/** + * 날짜 형식 검증 + * @param {string} dateString - 검증할 날짜 문자열 + * @param {string} format - 예상 형식 (기본값: 'YYYY-MM-DD') + * @returns {boolean|string} 유효하면 true, 유효하지 않으면 에러 메시지 + */ +export const validateDate = (dateString, format = 'YYYY-MM-DD') => { + if (!dateString) { + return '날짜를 입력해주세요.' + } + + const date = new Date(dateString) + + if (isNaN(date.getTime())) { + return '올바른 날짜 형식이 아닙니다.' + } + + return true +} + +/** + * 날짜 범위 검증 + * @param {string} startDate - 시작 날짜 + * @param {string} endDate - 종료 날짜 + * @returns {boolean|string} 유효하면 true, 유효하지 않으면 에러 메시지 + */ +export const validateDateRange = (startDate, endDate) => { + if (!startDate || !endDate) { + return '시작날짜와 종료날짜를 모두 입력해주세요.' + } + + const start = new Date(startDate) + const end = new Date(endDate) + + if (isNaN(start.getTime()) || isNaN(end.getTime())) { + return '올바른 날짜 형식이 아닙니다.' + } + + if (start > end) { + return '시작날짜는 종료날짜보다 이전이어야 합니다.' + } + + return true +} + +/** + * 한국어만 허용하는 검증 + * @param {string} value - 검증할 문자열 + * @param {string} fieldName - 필드명 + * @returns {boolean|string} 유효하면 true, 유효하지 않으면 에러 메시지 + */ +export const validateKoreanOnly = (value, fieldName = '입력값') => { + if (!value) { + return `${fieldName}을(를) 입력해주세요.` + } + + const koreanRegex = /^[가-힣\s]+$/ + + if (!koreanRegex.test(value)) { + return `${fieldName}은(는) 한글만 입력 가능합니다.` + } + + return true +} + +/** + * 영문과 숫자만 허용하는 검증 + * @param {string} value - 검증할 문자열 + * @param {string} fieldName - 필드명 + * @returns {boolean|string} 유효하면 true, 유효하지 않으면 에러 메시지 + */ +export const validateAlphanumeric = (value, fieldName = '입력값') => { + if (!value) { + return `${fieldName}을(를) 입력해주세요.` + } + + const alphanumericRegex = /^[a-zA-Z0-9]+$/ + + if (!alphanumericRegex.test(value)) { + return `${fieldName}은(는) 영문과 숫자만 입력 가능합니다.` + } + + return true +} + +/** + * 배열의 최소/최대 항목 수 검증 + * @param {Array} array - 검증할 배열 + * @param {number} min - 최소 항목 수 + * @param {number} max - 최대 항목 수 + * @param {string} fieldName - 필드명 + * @returns {boolean|string} 유효하면 true, 유효하지 않으면 에러 메시지 + */ +export const validateArrayLength = (array, min, max, fieldName = '항목') => { + if (!Array.isArray(array)) { + return `${fieldName}을(를) 선택해주세요.` + } + + if (array.length < min) { + return `${fieldName}은(는) 최소 ${min}개 이상 선택해야 합니다.` + } + + if (array.length > max) { + return `${fieldName}은(는) 최대 ${max}개까지 선택 가능합니다.` + } + + return true +} + +/** + * 해시태그 형식 검증 + * @param {string} hashtag - 검증할 해시태그 + * @returns {boolean|string} 유효하면 true, 유효하지 않으면 에러 메시지 + */ +export const validateHashtag = (hashtag) => { + if (!hashtag) { + return '해시태그를 입력해주세요.' + } + + // #으로 시작하고 공백이 없어야 함 + const hashtagRegex = /^#[a-zA-Z가-힣0-9_]+$/ + + if (!hashtagRegex.test(hashtag)) { + return '해시태그는 #으로 시작하고 공백 없이 작성해주세요.' + } + + if (hashtag.length > 50) { + return '해시태그는 50자를 초과할 수 없습니다.' + } + + return true +} + +/** + * 여러 검증 규칙을 순차적으로 실행 + * @param {any} value - 검증할 값 + * @param {Array} validators - 검증 함수 배열 + * @returns {boolean|string} 모든 검증을 통과하면 true, 실패하면 첫 번째 에러 메시지 + */ +export const validateMultiple = (value, validators) => { + for (const validator of validators) { + const result = validator(value) + if (result !== true) { + return result + } + } + return true +} + +/** + * 객체의 여러 필드를 한번에 검증 + * @param {Object} data - 검증할 데이터 객체 + * @param {Object} rules - 검증 규칙 객체 { fieldName: [validators] } + * @returns {Object} { isValid: boolean, errors: Object } + */ +export const validateForm = (data, rules) => { + const errors = {} + let isValid = true + + for (const [fieldName, validators] of Object.entries(rules)) { + const value = data[fieldName] + const result = validateMultiple(value, validators) + + if (result !== true) { + errors[fieldName] = result + isValid = false + } + } + + return { isValid, errors } +} + +/** + * 실시간 검증을 위한 디바운스된 검증 함수 생성 + * @param {Function} validator - 검증 함수 + * @param {number} delay - 지연 시간 (기본값: 300ms) + * @returns {Function} 디바운스된 검증 함수 + */ +export const createDebouncedValidator = (validator, delay = 300) => { + let timeoutId + + return (value, callback) => { + clearTimeout(timeoutId) + + timeoutId = setTimeout(() => { + const result = validator(value) + callback(result) + }, delay) + } +} \ No newline at end of file