UNPKG

oliveyoung-restock-notification-popup

Version:

모바일 중심의 재입고 알림 신청 Bottom Sheet 웹컴포넌트

937 lines (818 loc) 26.1 kB
class RestockNotificationPopup extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); this.isLoading = false; this.isSuccess = false; } static get observedAttributes() { return [ 'product-id', 'product-name', 'product-price', 'product-image', 'api-endpoint', 'show' ]; } connectedCallback() { // 초기에는 숨김 상태로 설정 this.style.display = 'none'; this.render(); this.addEventListeners(); this.loadStyles(); } disconnectedCallback() { this.removeEventListeners(); } attributeChangedCallback(name, oldValue, newValue) { // 같은 값으로 변경되는 경우 무시 (무한 루프 방지) if (oldValue === newValue) return; if (name === 'show' && newValue === 'true') { this.show(); } else if (name === 'show' && newValue === 'false') { this.hide(); } if (this.shadowRoot) { this.render(); } } async loadStyles() { try { // 스타일이 이미 로드되어 있는지 확인 if (this.shadowRoot.querySelector('style')) { return; } // 일단 인라인 스타일을 사용 (더 안정적) const cssText = this.getInlineStyles(); const style = document.createElement('style'); style.textContent = cssText; this.shadowRoot.appendChild(style); } catch (error) { console.warn('스타일 로드 실패:', error); } } getInlineStyles() { // 완전한 인라인 스타일 - Mobile First Bottom Sheet return ` .restock-popup-overlay { position: fixed !important; top: 0 !important; left: 0 !important; right: 0 !important; bottom: 0 !important; width: 100vw !important; height: 100vh !important; background: rgba(0, 0, 0, 0.4); z-index: 999999 !important; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; display: flex !important; align-items: flex-end !important; justify-content: center !important; margin: 0 !important; padding: 0 !important; box-sizing: border-box !important; } /* Mobile Bottom Sheet */ .restock-popup-container { background: white; width: 100% !important; max-height: 85vh; border-top-left-radius: 20px; border-top-right-radius: 20px; border-bottom-left-radius: 0; border-bottom-right-radius: 0; box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.12); position: relative; animation: slideUpFromBottom 0.25s ease-out; overflow: hidden; margin: 0 !important; transform: translateY(0) !important; } @keyframes slideUpFromBottom { from { transform: translateY(100%); opacity: 0.9; } to { transform: translateY(0); opacity: 1; } } @keyframes slideDownToBottom { from { transform: translateY(0); opacity: 1; } to { transform: translateY(100%); opacity: 0.9; } } .restock-popup-container.closing { animation: slideDownToBottom 0.2s ease-in forwards; } /* PC/Desktop 스타일 */ @media (min-width: 768px) { .restock-popup-overlay { align-items: center !important; justify-content: center !important; } .restock-popup-container { width: 90% !important; max-width: 480px; max-height: 90vh; border-radius: 16px !important; border-top-left-radius: 16px !important; border-top-right-radius: 16px !important; border-bottom-left-radius: 16px !important; border-bottom-right-radius: 16px !important; animation: fadeInScale 0.2s ease-out !important; margin: 0 !important; transform: none !important; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12) !important; } .restock-popup-container.closing { animation: fadeOutScale 0.15s ease-in forwards; } @keyframes fadeInScale { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } } @keyframes fadeOutScale { from { opacity: 1; transform: scale(1); } to { opacity: 0; transform: scale(0.95); } } } /* Bottom Sheet Handle */ .restock-popup-handle { width: 32px; height: 4px; background: #ddd; border-radius: 2px; margin: 12px auto 0; display: block; } @media (min-width: 768px) { .restock-popup-handle { display: none; } } .restock-popup-content { padding: 20px 24px 32px; overflow-y: auto; max-height: calc(85vh - 40px); } @media (min-width: 768px) { .restock-popup-content { padding: 32px; max-height: calc(90vh - 64px); } } @media (min-width: 768px) { .restock-popup-content { padding: 32px; max-height: calc(90vh - 64px); } } .restock-popup-close { position: absolute; top: 20px; right: 20px; background: rgba(255, 255, 255, 0.9); border: 1px solid #e0e0e0; font-size: 18px; cursor: pointer; color: #666; width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; transition: all 0.2s; z-index: 10; backdrop-filter: blur(4px); } .restock-popup-close:hover { background: #f5f5f5; border-color: #ccc; transform: scale(1.05); } @media (min-width: 768px) { .restock-popup-close { top: 16px; right: 16px; width: 32px; height: 32px; font-size: 24px; background: none; border: none; backdrop-filter: none; } } .restock-popup-header { text-align: center; margin-bottom: 24px; } .restock-popup-title { font-size: 24px; font-weight: 600; color: #333; margin: 0 0 8px 0; } .restock-popup-subtitle { font-size: 14px; color: #666; margin: 0; } .restock-popup-product { background: #f8f9fa; border-radius: 8px; padding: 16px; margin-bottom: 24px; display: flex; align-items: center; gap: 12px; } .restock-popup-product-image { width: 60px; height: 60px; border-radius: 8px; object-fit: cover; background: #e9ecef; } .restock-popup-product-info { flex: 1; } .restock-popup-product-name { font-weight: 600; color: #333; margin: 0 0 4px 0; font-size: 16px; line-height: 1.3; } .restock-popup-product-price { color: #666; margin: 0; font-size: 14px; } .restock-popup-form { display: flex; flex-direction: column; gap: 16px; } .restock-popup-input-group { display: flex; flex-direction: column; gap: 6px; } .restock-popup-label { font-weight: 500; color: #333; font-size: 14px; } .restock-popup-input { padding: 16px 20px; border: 2px solid #e0e0e0; border-radius: 12px; font-size: 16px; transition: all 0.2s; width: 100%; box-sizing: border-box; background: #fafafa; } .restock-popup-input:focus { outline: none; border-color: #28a745; background: white; box-shadow: 0 0 0 4px rgba(40, 167, 69, 0.1); transform: translateY(-2px); } @media (min-width: 768px) { .restock-popup-input { padding: 12px 16px; border-radius: 8px; background: white; } .restock-popup-input:focus { border-color: #007bff; box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1); transform: none; } } @media (min-width: 768px) { .restock-popup-input { padding: 12px 16px; border-radius: 8px; background: white; } .restock-popup-input:focus { border-color: #007bff; box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1); transform: none; } } .restock-popup-checkbox-group { display: flex; align-items: flex-start; gap: 8px; font-size: 14px; color: #666; } .restock-popup-checkbox { margin-top: 2px; } .restock-popup-actions { display: flex; flex-direction: column; gap: 12px; margin-top: 32px; } @media (min-width: 768px) { .restock-popup-actions { flex-direction: row; margin-top: 24px; } } .restock-popup-button { width: 100%; padding: 16px 24px; border: none; border-radius: 12px; font-size: 16px; font-weight: 600; cursor: pointer; transition: all 0.2s; min-height: 52px; display: flex; align-items: center; justify-content: center; } @media (min-width: 768px) { .restock-popup-button { flex: 1; padding: 12px 24px; border-radius: 8px; min-height: auto; } } .restock-popup-button-primary { background: #28a745; color: white; order: 1; } .restock-popup-button-primary:hover { background: #218838; transform: translateY(-2px); box-shadow: 0 4px 12px rgba(40, 167, 69, 0.3); } .restock-popup-button-secondary { background: transparent; color: #666; border: 1px solid #ddd; order: 2; } .restock-popup-button-secondary:hover { background: #f8f9fa; border-color: #ccc; } @media (min-width: 768px) { .restock-popup-button-primary { order: 2; } .restock-popup-button-secondary { order: 1; background: #f8f9fa; } .restock-popup-button-primary:hover { transform: translateY(-1px); box-shadow: none; } } @media (min-width: 768px) { .restock-popup-button-primary { order: 2; } .restock-popup-button-secondary { order: 1; background: #f8f9fa; } .restock-popup-button-primary:hover { transform: translateY(-1px); box-shadow: none; } } .restock-popup-loading { display: flex; align-items: center; justify-content: center; gap: 8px; } .restock-popup-spinner { width: 16px; height: 16px; border: 2px solid #ffffff50; border-top: 2px solid #ffffff; border-radius: 50%; animation: spin 1s linear infinite; } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .restock-popup-error { color: #dc3545; font-size: 14px; margin-top: 8px; } .restock-popup-success { text-align: center; padding: 20px; } .restock-popup-success-icon { font-size: 48px; color: #28a745; margin-bottom: 16px; } .restock-popup-success-title { font-size: 20px; font-weight: 600; color: #333; margin: 0 0 8px 0; } .restock-popup-success-message { color: #666; margin: 0 0 24px 0; line-height: 1.4; } `; } render() { if (this.isSuccess) { this.shadowRoot.innerHTML = this.getSuccessTemplate(); } else { this.shadowRoot.innerHTML = this.getFormTemplate(); } this.loadStyles(); // 렌더링 후 스타일 로드 } getFormTemplate() { const productName = this.getAttribute('product-name') || '상품명'; const productPrice = this.getAttribute('product-price') || '가격 정보 없음'; const productImage = this.getAttribute('product-image') || ''; return ` <div class="restock-popup-overlay"> <div class="restock-popup-container"> <div class="restock-popup-handle"></div> <button class="restock-popup-close" type="button">×</button> <div class="restock-popup-content"> <div class="restock-popup-header"> <h2 class="restock-popup-title">재입고 알림 신청</h2> <p class="restock-popup-subtitle">상품이 재입고되면 알려드릴게요</p> </div> <div class="restock-popup-product"> ${productImage ? `<img src="${productImage}" alt="${productName}" class="restock-popup-product-image">` : '<div class="restock-popup-product-image"></div>'} <div class="restock-popup-product-info"> <h3 class="restock-popup-product-name">${productName}</h3> <p class="restock-popup-product-price">${productPrice}</p> </div> </div> <form class="restock-popup-form"> <div class="restock-popup-input-group"> <label class="restock-popup-label">휴대폰 번호</label> <input type="tel" class="restock-popup-input" name="phone" placeholder="010-0000-0000" maxlength="13" > <div class="restock-popup-error" id="phone-error"></div> </div> <div class="restock-popup-input-group"> <label class="restock-popup-label">이메일 (선택)</label> <input type="email" class="restock-popup-input" name="email" placeholder="example@email.com" > <div class="restock-popup-error" id="email-error"></div> </div> <div class="restock-popup-checkbox-group"> <input type="checkbox" class="restock-popup-checkbox" name="agree" required> <label>재입고 알림 서비스 이용약관에 동의합니다. (필수)</label> </div> <div class="restock-popup-actions"> <button type="button" class="restock-popup-button restock-popup-button-secondary" id="cancel-btn"> 취소 </button> <button type="submit" class="restock-popup-button restock-popup-button-primary" id="submit-btn"> ${this.isLoading ? '<div class="restock-popup-loading"><span class="restock-popup-spinner"></span> 신청 중...</div>' : '알림 신청하기'} </button> </div> </form> </div> </div> </div> `; } getSuccessTemplate() { return ` <div class="restock-popup-overlay"> <div class="restock-popup-container"> <div class="restock-popup-handle"></div> <button class="restock-popup-close" type="button">×</button> <div class="restock-popup-content"> <div class="restock-popup-success"> <div class="restock-popup-success-icon">✓</div> <h2 class="restock-popup-success-title">신청 완료!</h2> <p class="restock-popup-success-message"> 재입고 알림이 성공적으로 등록되었습니다.<br> 상품이 재입고되면 알려드릴게요. </p> <button type="button" class="restock-popup-button restock-popup-button-primary" id="close-success-btn"> 확인 </button> </div> </div> </div> </div> `; } addEventListeners() { this.shadowRoot.addEventListener('click', this.handleClick.bind(this)); this.shadowRoot.addEventListener('submit', this.handleSubmit.bind(this)); this.shadowRoot.addEventListener('input', this.handleInput.bind(this)); // 모바일 터치 이벤트 (스와이프 다운으로 닫기) this.addSwipeEvents(); // ESC 키로 팝업 닫기 this.escKeyHandler = (e) => { if (e.key === 'Escape') { this.hide(); } }; document.addEventListener('keydown', this.escKeyHandler); } addSwipeEvents() { let startY = 0; let currentY = 0; let isDragging = false; const container = () => this.shadowRoot.querySelector('.restock-popup-container'); const handle = () => this.shadowRoot.querySelector('.restock-popup-handle'); const handleStart = (e) => { if (window.innerWidth >= 768) return; // PC에서는 스와이프 비활성화 const touch = e.touches ? e.touches[0] : e; startY = touch.clientY; isDragging = true; if (container()) { container().style.transition = 'none'; } }; const handleMove = (e) => { if (!isDragging || window.innerWidth >= 768) return; const touch = e.touches ? e.touches[0] : e; currentY = touch.clientY; const deltaY = currentY - startY; if (deltaY > 0) { // 아래로 드래그할 때만 if (container()) { container().style.transform = `translateY(${deltaY}px)`; } } }; const handleEnd = () => { if (!isDragging || window.innerWidth >= 768) return; isDragging = false; const deltaY = currentY - startY; if (container()) { container().style.transition = 'transform 0.25s ease-out'; if (deltaY > 100) { // 100px 이상 드래그하면 닫기 this.hide(); } else { container().style.transform = 'translateY(0)'; } } }; // Handle 영역에 터치 이벤트 추가 this.shadowRoot.addEventListener('touchstart', (e) => { const target = e.target; if (target.classList.contains('restock-popup-handle') || target.classList.contains('restock-popup-container')) { handleStart(e); } }, { passive: false }); this.shadowRoot.addEventListener('touchmove', handleMove, { passive: false }); this.shadowRoot.addEventListener('touchend', handleEnd); } removeEventListeners() { if (this.escKeyHandler) { document.removeEventListener('keydown', this.escKeyHandler); } } handleClick(e) { const target = e.target; if (target.classList.contains('restock-popup-close') || target.id === 'cancel-btn' || target.id === 'close-success-btn') { this.hide(); } // 오버레이 클릭시 닫기 if (target.classList.contains('restock-popup-overlay')) { this.hide(); } } handleInput(e) { const target = e.target; if (target.name === 'phone') { // 휴대폰 번호 자동 포맷팅 target.value = this.formatPhoneNumber(target.value); this.clearError('phone-error'); } else if (target.name === 'email') { this.clearError('email-error'); } } async handleSubmit(e) { e.preventDefault(); if (this.isLoading) return; const formData = new FormData(e.target); const data = { productId: this.getAttribute('product-id'), phone: formData.get('phone'), email: formData.get('email'), agree: formData.get('agree') === 'on' }; // 유효성 검사 if (!this.validateForm(data)) { return; } // 로딩 상태 설정 this.isLoading = true; this.render(); try { // API 호출 await this.submitRestockNotification(data); // 성공 상태로 변경 this.isSuccess = true; this.isLoading = false; this.render(); // 성공 이벤트 발송 this.dispatchEvent(new CustomEvent('restock-notification-success', { detail: data, bubbles: true })); } catch (error) { this.isLoading = false; this.render(); // 에러 처리 this.showError('submit-error', error.message || '신청 중 오류가 발생했습니다.'); // 에러 이벤트 발송 this.dispatchEvent(new CustomEvent('restock-notification-error', { detail: { error, data }, bubbles: true })); } } validateForm(data) { let isValid = true; // 휴대폰 번호 검증 if (!data.phone) { this.showError('phone-error', '휴대폰 번호를 입력해주세요.'); isValid = false; } else if (!this.isValidPhone(data.phone)) { this.showError('phone-error', '올바른 휴대폰 번호를 입력해주세요.'); isValid = false; } // 이메일 검증 (선택사항이지만 입력했을 경우) if (data.email && !this.isValidEmail(data.email)) { this.showError('email-error', '올바른 이메일 주소를 입력해주세요.'); isValid = false; } // 약관 동의 검증 if (!data.agree) { alert('이용약관에 동의해주세요.'); isValid = false; } return isValid; } async submitRestockNotification(data) { const apiEndpoint = this.getAttribute('api-endpoint'); if (!apiEndpoint) { throw new Error('API 엔드포인트가 설정되지 않았습니다.'); } const response = await fetch(apiEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(data) }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.message || '서버 오류가 발생했습니다.'); } return response.json(); } formatPhoneNumber(value) { const cleaned = value.replace(/\D/g, ''); const match = cleaned.match(/^(\d{3})(\d{4})(\d{4})$/); if (match) { return `${match[1]}-${match[2]}-${match[3]}`; } return cleaned.replace(/(\d{3})(\d{0,4})(\d{0,4})/, (_, p1, p2, p3) => { if (p3) return `${p1}-${p2}-${p3}`; if (p2) return `${p1}-${p2}`; return p1; }); } isValidPhone(phone) { const phoneRegex = /^010-\d{4}-\d{4}$/; return phoneRegex.test(phone); } isValidEmail(email) { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(email); } showError(elementId, message) { const errorElement = this.shadowRoot.getElementById(elementId); if (errorElement) { errorElement.textContent = message; } } clearError(elementId) { const errorElement = this.shadowRoot.getElementById(elementId); if (errorElement) { errorElement.textContent = ''; } } show() { // Next.js 환경 고려 - DOM이 완전히 로드되었는지 확인 if (typeof window === 'undefined') return; this.style.display = 'block'; this.style.position = 'fixed'; this.style.top = '0'; this.style.left = '0'; this.style.width = '100vw'; this.style.height = '100vh'; this.style.zIndex = '999999'; // 바디 스크롤 방지 if (document.body) { document.body.style.overflow = 'hidden'; } // 표시 이벤트 발송 this.dispatchEvent(new CustomEvent('restock-notification-show', { bubbles: true })); } hide() { // Next.js 환경 고려 if (typeof window === 'undefined') return; const container = this.shadowRoot && this.shadowRoot.querySelector('.restock-popup-container'); if (container) { // 닫기 애니메이션 추가 container.classList.add('closing'); // PC vs 모바일에 따라 애니메이션 시간 다르게 설정 const animationDuration = (typeof window !== 'undefined' && window.innerWidth >= 768) ? 150 : 200; // 애니메이션이 끝난 후 실제로 숨기기 setTimeout(() => { this.style.display = 'none'; container.classList.remove('closing'); // 바디 스크롤 복원 if (document.body) { document.body.style.overflow = ''; } // 상태 초기화 this.isLoading = false; this.isSuccess = false; this.render(); // 숨김 이벤트 발송 this.dispatchEvent(new CustomEvent('restock-notification-hide', { bubbles: true })); }, animationDuration); } else { // fallback: container가 없으면 바로 숨기기 this.style.display = 'none'; if (document.body) { document.body.style.overflow = ''; } this.isLoading = false; this.isSuccess = false; this.render(); this.dispatchEvent(new CustomEvent('restock-notification-hide', { bubbles: true })); } } } // 웹컴포넌트 등록 - Next.js 환경 고려 if (typeof window !== 'undefined' && typeof customElements !== 'undefined') { // 이미 정의된 경우 중복 정의 방지 if (!customElements.get('restock-notification-popup')) { customElements.define('restock-notification-popup', RestockNotificationPopup); } }