oliveyoung-restock-notification-popup
Version:
모바일 중심의 재입고 알림 신청 Bottom Sheet 웹컴포넌트
937 lines (818 loc) • 26.1 kB
JavaScript
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;
}
slideUpFromBottom {
from {
transform: translateY(100%);
opacity: 0.9;
}
to {
transform: translateY(0);
opacity: 1;
}
}
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 스타일 */
(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;
}
fadeInScale {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
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;
}
(min-width: 768px) {
.restock-popup-handle {
display: none;
}
}
.restock-popup-content {
padding: 20px 24px 32px;
overflow-y: auto;
max-height: calc(85vh - 40px);
}
(min-width: 768px) {
.restock-popup-content {
padding: 32px;
max-height: calc(90vh - 64px);
}
}
(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);
}
(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);
}
(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;
}
}
(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;
}
(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;
}
(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;
}
(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;
}
}
(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;
}
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);
}
}