- Webhook 신청 및 관리 UI 전면 개편 - 신규 가이드 반영

- Webhook 수정/삭제 기능 UX 개선 - 단계별 디자인 업데이트
- Webhook 등록/수정 CSS 및 JS 로직 최적화 및 재사용 코드 추가
This commit is contained in:
Rinjae
2026-07-20 19:53:54 +09:00
parent 9f4874fe44
commit 41391c8df0
21 changed files with 1107 additions and 1923 deletions
+81 -12
View File
@@ -1368,7 +1368,7 @@ body.design-survey-active .global-header {
border: none;
cursor: pointer;
padding: 8px;
z-index: 501;
z-index: 1051;
}
.btn-mobilemenu span {
display: block;
@@ -1413,7 +1413,7 @@ body.design-survey-active .global-header {
right: 0;
bottom: 0;
background: #FFFFFF;
z-index: 500;
z-index: 1050;
right: -100%;
transition: none;
overflow-y: auto;
@@ -4844,7 +4844,7 @@ select.form-control {
right: 0;
bottom: 0;
background: #385670;
z-index: 400;
z-index: 1040;
opacity: 0.9;
visibility: hidden;
transition: all 0.3s ease;
@@ -4860,7 +4860,7 @@ select.form-control {
left: 0;
width: 100%;
height: 100vh;
z-index: 500;
z-index: 1050;
display: flex;
align-items: center;
justify-content: center;
@@ -4920,7 +4920,7 @@ select.form-control {
display: flex;
align-items: center;
justify-content: center;
z-index: 500;
z-index: 1050;
transition: all 0.3s ease;
box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15);
}
@@ -4958,7 +4958,7 @@ select.form-control {
flex-direction: column;
transform: scale(0.95);
transition: transform 0.3s ease;
z-index: 500;
z-index: 1050;
margin-left: auto;
margin-right: auto;
}
@@ -10348,7 +10348,7 @@ body.index-page-body {
transform: translateX(-100%);
transition: transform 0.3s ease;
box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15);
z-index: 500;
z-index: 1050;
height: calc(100vh - 60px);
margin: 0;
border-radius: 0;
@@ -10503,7 +10503,7 @@ body.index-page-body {
font-size: 24px;
cursor: pointer;
box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15);
z-index: 501;
z-index: 1051;
transition: all 0.3s ease;
background-color: #0049b4;
}
@@ -10755,7 +10755,7 @@ body.index-page-body {
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 499;
z-index: 1049;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
@@ -13186,7 +13186,7 @@ body.index-page-body {
transform: translateX(-100%);
transition: transform 0.3s ease;
box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15);
z-index: 500;
z-index: 1050;
height: calc(100vh - 60px);
max-height: none;
margin: 0;
@@ -13292,7 +13292,7 @@ body.index-page-body {
color: #FFFFFF;
font-size: 24px;
cursor: pointer;
z-index: 501;
z-index: 1051;
transition: all 0.3s ease;
}
.apikey-mobile-toggle:hover {
@@ -13318,7 +13318,7 @@ body.index-page-body {
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 499;
z-index: 1049;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
@@ -24547,6 +24547,75 @@ input[type=checkbox]:checked + .custom-checkbox {
font-family: monospace;
}
.step1-wrap .s1-form-card .webhook-card-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.step1-wrap .s1-form-card .webhook-card-head h3 {
font-size: 18px;
}
.step1-wrap .s1-form-card .webhook-card-head .webhook-created {
color: #6b7280;
font-size: 13px;
}
.step1-wrap .s1-form-card .webhook-card-row {
display: flex;
gap: 16px;
padding: 14px 0;
border-top: 1px solid #e2e6ee;
}
.step1-wrap .s1-form-card .webhook-card-row .row-label {
width: 110px;
color: #6b7280;
font-weight: 600;
flex-shrink: 0;
}
.step1-wrap .s1-form-card .webhook-card-row .row-value {
flex: 1;
word-break: break-all;
}
.step1-wrap .s1-form-card .webhook-card-row .secret-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.step1-wrap .s1-form-card .webhook-card-row code {
background: #f5f7fb;
padding: 4px 8px;
border-radius: 6px;
}
.step1-wrap .s1-form-card .eventtype-badge,
.step1-wrap .s1-form-card .api-badge {
display: inline-block;
padding: 4px 10px;
margin: 2px 4px 2px 0;
border-radius: 999px;
font-size: 12px;
background: rgba(11, 95, 255, 0.1);
color: #0b5fff;
}
.step1-wrap .s1-form-card .api-badge {
background: #f5f7fb;
color: #6b7280;
}
.step1-wrap .s1-form-card .webhook-empty {
background: none;
border: none;
padding: 24px 0;
}
.step1-wrap .s1-actions .btn-webhook-cancel,
.step1-wrap .s1-actions .btn-webhook-danger,
.step2-wrap .s1-actions .btn-webhook-cancel,
.step2-wrap .s1-actions .btn-webhook-danger,
.step3-wrap .s1-actions .btn-webhook-cancel,
.step3-wrap .s1-actions .btn-webhook-danger {
margin-right: auto;
}
.webhook-guide-link {
margin-top: 20px;
text-align: center;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+96 -93
View File
@@ -1,15 +1,15 @@
/**
* API 선택 공용 모듈 (fragment/api_selector.html 전용)
* API 선택 공용 모듈 (fragment/api_selector.html 전용, figma s2 디자인)
*
* 사용처: 앱(API Key) 신청/수정 step2, Webhook 신청/수정 step2
*
* 계약:
* - 폼: #apiSelectorForm (data-save-action = "이전" 저장 POST 경로)
* - 기선택: window.API_SELECTOR_SELECTED (fragment 인라인 스크립트가 주입)
* - 기선택: window.API_SELECTOR_SELECTED / 목록 URL: window.API_SELECTOR_LIST_URL (fragment 인라인 주입)
* - "이전" 버튼: 호출 페이지의 #btnPrevStep (없으면 스킵)
* - API 목록: GET /apis/for_request[?groupIds=] (ROLE_API_KEY_REQUEST)
* - 카트/모달: fragment `apiSelectorPopups` 를 pagePopups 슬롯에서 호출(body 직속)
*
* 원본(apiKeyRegisterStep2 인라인 스크립트) 대비 패치 3건:
* design(figma s2) 인라인 스크립트 대비 패치 3건:
* 1) 모달 열 때마다 updateModalList() 재빌드 — 세션 복원 직후(카드 렌더 전) 빈 모달 방지
* 2) 모달 리스트를 DOM 체크박스가 아닌 selectedApis Set 기준으로 생성 — 미렌더/타 카테고리 누락 방지
* 3) 제출/이전 시 DOM에 없는 선택분을 hidden input으로 주입 — 카테고리 필터 상태 전송 유실 방지
@@ -22,10 +22,7 @@ document.addEventListener('DOMContentLoaded', function() {
// DOM Elements
const searchInput = document.getElementById('apiSearch');
const sidebar = document.getElementById('apiSidebar');
const mobileToggle = document.getElementById('mobileToggle');
const mobileOverlay = document.getElementById('mobileOverlay');
const menuTitles = document.querySelectorAll('.menu-title');
const menuTitles = document.querySelectorAll('.s2-category-tab');
const apiCardGrid = document.getElementById('apiCardGrid');
const loadingState = document.getElementById('loadingState');
const emptyState = document.getElementById('emptyState');
@@ -48,9 +45,10 @@ document.addEventListener('DOMContentLoaded', function() {
loadingState.style.display = 'block';
emptyState.style.display = 'none';
document.querySelectorAll('.api-selection-card').forEach(card => card.remove());
document.querySelectorAll('.s2-api-card').forEach(card => card.remove());
let url = '/apis/for_request';
const baseUrl = window.API_SELECTOR_LIST_URL || '/apis/for_request';
let url = baseUrl;
if (groupId) {
url += '?groupIds=' + encodeURIComponent(groupId);
}
@@ -61,9 +59,11 @@ document.addEventListener('DOMContentLoaded', function() {
if (apis.length === 0) {
emptyState.style.display = 'block';
document.getElementById('apiResultCount').textContent = '0';
return;
}
document.getElementById('apiResultCount').textContent = apis.length;
renderApiCards(apis);
updateSelectAllUI();
}).catch(error => {
@@ -72,6 +72,7 @@ document.addEventListener('DOMContentLoaded', function() {
emptyState.querySelector('h3').textContent = 'API 로드 실패';
emptyState.querySelector('p').textContent = '다시 시도해주세요.';
emptyState.style.display = 'block';
document.getElementById('apiResultCount').textContent = '0';
});
}
@@ -87,10 +88,10 @@ document.addEventListener('DOMContentLoaded', function() {
attachCardEventListeners();
}
// Create API card element
// Create API card element (figma s2 card)
function createApiCard(api) {
const card = document.createElement('div');
card.className = 'api-selection-card';
card.className = 's2-api-card';
card.setAttribute('data-group', api.apiGroupId || '');
card.setAttribute('data-name', (api.apiName || '').toLowerCase());
card.setAttribute('data-desc', (api.apiSimpleDescription || '').toLowerCase());
@@ -106,27 +107,26 @@ document.addEventListener('DOMContentLoaded', function() {
: `<i class="fas fa-cube"></i>`;
card.innerHTML = `
<div class="api-card-content">
<div class="api-card-header">
<span class="api-card-category">${api.service || '카테고리'}</span>
<div class="checkbox-wrapper">
<div class="s2-api-card-badge">
<span>${api.apiGroupName || api.service || '카테고리'}</span>
</div>
<!-- Checkbox container with visible custom design -->
<label class="s2-checkbox-wrapper">
<input type="checkbox"
name="selectedApis"
value="${api.apiId}"
id="api-${api.apiId}"
class="api-checkbox visually-hidden"
class="s2-api-checkbox visually-hidden"
${isSelected ? 'checked' : ''}>
<span class="custom-checkbox"></span>
</div>
</div>
<span class="s2-checkbox-custom"></span>
</label>
<h3 class="api-name">${api.apiName || 'API 이름'}</h3>
<p class="api-description">${api.apiSimpleDescription || 'API 설명이 없습니다.'}</p>
<h3 class="s2-api-card-title">${api.apiName || 'API 이름'}</h3>
<p class="s2-api-card-desc">${api.apiSimpleDescription || 'API 설명이 없습니다.'}</p>
<div class="api-card-icon">
<div class="s2-api-card-image">
${mainIconHtml}
</div>
</div>
`;
return card;
@@ -134,22 +134,11 @@ document.addEventListener('DOMContentLoaded', function() {
// Attach event listeners to cards
function attachCardEventListeners() {
const checkboxes = document.querySelectorAll('.api-checkbox');
const apiCards = document.querySelectorAll('.api-selection-card');
checkboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {
updateCardSelection(this);
updateSelectedCount();
});
});
const apiCards = document.querySelectorAll('.s2-api-card');
apiCards.forEach(card => {
card.addEventListener('click', function(e) {
if (e.target.classList.contains('api-checkbox')) {
return;
}
const checkbox = card.querySelector('.api-checkbox');
const checkbox = card.querySelector('.s2-api-checkbox');
if (checkbox) {
checkbox.checked = !checkbox.checked;
updateCardSelection(checkbox);
@@ -157,11 +146,27 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
});
// Prevent double toggle when clicking the checkbox wrapper
const checkboxWrappers = document.querySelectorAll('.s2-checkbox-wrapper');
checkboxWrappers.forEach(wrapper => {
wrapper.addEventListener('click', function(e) {
e.stopPropagation(); // Stop click from bubbling to card!
});
const checkbox = wrapper.querySelector('.s2-api-checkbox');
if (checkbox) {
checkbox.addEventListener('change', function() {
updateCardSelection(this);
updateSelectedCount();
});
}
});
}
// Update card visual state
function updateCardSelection(checkbox) {
const card = checkbox.closest('.api-selection-card');
const card = checkbox.closest('.s2-api-card');
if (checkbox.checked) {
card.classList.add('selected');
selectedApis.add(checkbox.value);
@@ -174,7 +179,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Update selected count
function updateSelectedCount() {
const floatingCartBtn = document.getElementById('floatingCartBtn');
const cartCount = document.querySelector('.cart-count');
const cartCount = document.querySelector('.s2-cart-count');
if (selectedApis.size > 0) {
floatingCartBtn.style.display = 'flex';
@@ -205,7 +210,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Update select all checkbox state based on visible cards
function updateSelectAllCheckboxState() {
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const visibleCards = Array.from(document.querySelectorAll('.api-selection-card')).filter(card => card.style.display !== 'none');
const visibleCards = Array.from(document.querySelectorAll('.s2-api-card')).filter(card => card.style.display !== 'none');
if (visibleCards.length === 0) {
selectAllCheckbox.checked = false;
@@ -213,7 +218,7 @@ document.addEventListener('DOMContentLoaded', function() {
return;
}
const visibleCheckboxes = visibleCards.map(card => card.querySelector('.api-checkbox'));
const visibleCheckboxes = visibleCards.map(card => card.querySelector('.s2-api-checkbox'));
const checkedCount = visibleCheckboxes.filter(cb => cb.checked).length;
if (checkedCount === 0) {
@@ -234,33 +239,28 @@ document.addEventListener('DOMContentLoaded', function() {
modalSelectedList.innerHTML = '';
if (selectedApis.size === 0) {
modalSelectedList.innerHTML = '<p class="empty-message">선택된 API가 없습니다.</p>';
modalSelectedList.innerHTML = '<p class="s2-empty-message">선택된 API가 없습니다.</p>';
return;
}
selectedApis.forEach(function(apiId) {
const card = document.querySelector('.api-selection-card[data-api-id="' + apiId + '"]');
const apiName = card ? card.querySelector('.api-name').textContent : apiId;
const card = document.querySelector('.s2-api-card[data-api-id="' + apiId + '"]');
const apiName = card ? card.querySelector('.s2-api-card-title').textContent : apiId;
const apiPill = document.createElement('div');
apiPill.className = 'api-pill';
apiPill.className = 's2-api-pill';
apiPill.innerHTML = `
<span class="api-pill-name">${apiName}</span>
<button type="button" class="api-pill-remove" data-value="${apiId}" aria-label="Remove ${apiName}">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
<span class="s2-api-pill-name">${apiName}</span>
<button type="button" class="s2-api-pill-remove" data-value="${apiId}" aria-label="Remove ${apiName}">✕</button>
`;
modalSelectedList.appendChild(apiPill);
});
document.querySelectorAll('.api-pill-remove').forEach(function(btn) {
document.querySelectorAll('.s2-api-pill-remove').forEach(function(btn) {
btn.addEventListener('click', function() {
const value = this.getAttribute('data-value');
selectedApis.delete(value);
const checkbox = document.querySelector('.api-checkbox[value="' + value + '"]');
const checkbox = document.querySelector('.s2-api-checkbox[value="' + value + '"]');
if (checkbox) {
checkbox.checked = false;
updateCardSelection(checkbox);
@@ -275,18 +275,27 @@ document.addEventListener('DOMContentLoaded', function() {
searchInput.addEventListener('input', function() {
const searchTerm = this.value.toLowerCase();
document.querySelectorAll('.api-selection-card').forEach(function(card) {
const apiCards = document.querySelectorAll('.s2-api-card');
let visibleCount = 0;
apiCards.forEach(function(card) {
const apiName = card.getAttribute('data-name');
const apiDesc = card.getAttribute('data-desc');
const matchesSearch = apiName.includes(searchTerm) || apiDesc.includes(searchTerm);
card.style.display = matchesSearch ? '' : 'none';
if (matchesSearch) {
card.style.display = '';
visibleCount++;
} else {
card.style.display = 'none';
}
});
document.getElementById('apiResultCount').textContent = visibleCount;
updateSelectAllCheckboxState();
});
}
// Sidebar category selection
// Category tab selection
menuTitles.forEach(function(title) {
title.addEventListener('click', function(e) {
e.preventDefault();
@@ -296,53 +305,47 @@ document.addEventListener('DOMContentLoaded', function() {
const groupId = this.getAttribute('data-group');
currentFilter = groupId;
currentServiceName = this.textContent.trim().split('\n')[0].trim();
currentServiceName = this.textContent.trim();
loadApis(groupId);
if (searchInput) {
searchInput.value = '';
}
if (window.innerWidth <= 768 && sidebar.classList.contains('mobile-open')) {
closeMobileMenu();
}
});
});
// Mobile menu toggle
function closeMobileMenu() {
sidebar.classList.remove('mobile-open');
mobileOverlay.classList.remove('active');
mobileToggle.innerHTML = '☰';
mobileToggle.setAttribute('aria-label', '메뉴 열기');
}
// Category Carousel Scroll
const categoryListWrapper = document.getElementById('categoryListWrapper');
const btnPrevCategory = document.getElementById('btnPrevCategory');
const btnNextCategory = document.getElementById('btnNextCategory');
if (mobileToggle && sidebar && mobileOverlay) {
mobileToggle.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
if (categoryListWrapper && btnPrevCategory && btnNextCategory) {
const scrollAmount = 200;
sidebar.classList.toggle('mobile-open');
mobileOverlay.classList.toggle('active');
if (sidebar.classList.contains('mobile-open')) {
this.innerHTML = '✕';
this.setAttribute('aria-label', '메뉴 닫기');
} else {
this.innerHTML = '☰';
this.setAttribute('aria-label', '메뉴 열기');
}
btnPrevCategory.addEventListener('click', function() {
categoryListWrapper.scrollBy({ left: -scrollAmount, behavior: 'smooth' });
});
mobileOverlay.addEventListener('click', closeMobileMenu);
btnNextCategory.addEventListener('click', function() {
categoryListWrapper.scrollBy({ left: scrollAmount, behavior: 'smooth' });
});
// Toggle buttons visibility/disabled state based on scroll position
function updateCarouselButtons() {
const scrollLeft = categoryListWrapper.scrollLeft;
const maxScrollLeft = categoryListWrapper.scrollWidth - categoryListWrapper.clientWidth;
btnPrevCategory.disabled = scrollLeft <= 0;
btnNextCategory.disabled = scrollLeft >= maxScrollLeft - 1;
}
window.addEventListener('resize', function() {
if (window.innerWidth > 768 && sidebar.classList.contains('mobile-open')) {
closeMobileMenu();
categoryListWrapper.addEventListener('scroll', updateCarouselButtons);
window.addEventListener('resize', updateCarouselButtons);
// Initial check after loading categories
setTimeout(updateCarouselButtons, 150);
}
});
// Modal control
const floatingCartBtn = document.getElementById('floatingCartBtn');
@@ -353,7 +356,7 @@ document.addEventListener('DOMContentLoaded', function() {
function openModal() {
updateModalList(); // 열 때마다 최신 선택 상태로 재빌드 (패치 1)
selectedApisModal.style.display = 'block';
selectedApisModal.style.display = 'flex'; // .s2-modal은 flex 중앙정렬 → block 금지
setTimeout(function() {
if (modalOverlay) {
modalOverlay.classList.add('show');
@@ -390,7 +393,7 @@ document.addEventListener('DOMContentLoaded', function() {
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && selectedApisModal.style.display === 'block') {
if (e.key === 'Escape' && selectedApisModal.style.display === 'flex') {
closeModal();
}
});
@@ -400,10 +403,10 @@ document.addEventListener('DOMContentLoaded', function() {
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', function() {
const isChecked = this.checked;
const visibleCards = Array.from(document.querySelectorAll('.api-selection-card')).filter(card => card.style.display !== 'none');
const visibleCards = Array.from(document.querySelectorAll('.s2-api-card')).filter(card => card.style.display !== 'none');
visibleCards.forEach(function(card) {
const checkbox = card.querySelector('.api-checkbox');
const checkbox = card.querySelector('.s2-api-checkbox');
if (checkbox) {
checkbox.checked = isChecked;
updateCardSelection(checkbox);
@@ -418,7 +421,7 @@ document.addEventListener('DOMContentLoaded', function() {
function syncHiddenSelected() {
document.querySelectorAll('input.hidden-selected-api').forEach(el => el.remove());
selectedApis.forEach(function(apiId) {
if (!document.querySelector('.api-checkbox[value="' + apiId + '"]')) {
if (!document.querySelector('.s2-api-checkbox[value="' + apiId + '"]')) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'selectedApis';
@@ -101,8 +101,9 @@ $z-index-dropdown: 100;
$z-index-sticky: 200;
$z-index-fixed: 300;
$z-index-header: 350;
$z-index-modal-backdrop: 400;
$z-index-modal: 500;
// 모달은 헤더(.global-header z-index:1000 하드코딩)보다 항상 위에 떠야 한다
$z-index-modal-backdrop: 1040;
$z-index-modal: 1050;
$z-index-popover: 600;
$z-index-tooltip: 700;
$z-index-notification: 800;
@@ -243,6 +243,54 @@ $wh-bg-soft: #f5f7fb;
}
}
// ---------- figma s1-form-card 내부 재사용 ----------
// step1-wrap(s1 디자인) 카드 안에서 등록 카드 요소를 쓸 때
// 루트 카드 스타일은 .s1-form-card 것을 쓰고 내부 row/배지만 매핑한다.
.step1-wrap .s1-form-card {
.webhook-card-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 20px;
h3 { font-size: 18px; }
.webhook-created { color: $wh-muted; font-size: 13px; }
}
.webhook-card-row {
display: flex;
gap: 16px;
padding: 14px 0;
border-top: 1px solid $wh-border;
.row-label { width: 110px; color: $wh-muted; font-weight: 600; flex-shrink: 0; }
.row-value { flex: 1; word-break: break-all; }
.secret-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
code { background: $wh-bg-soft; padding: 4px 8px; border-radius: 6px; }
}
.eventtype-badge,
.api-badge {
display: inline-block;
padding: 4px 10px; margin: 2px 4px 2px 0;
border-radius: 999px; font-size: 12px;
background: rgba($wh-primary, 0.1); color: $wh-primary;
}
.api-badge { background: $wh-bg-soft; color: $wh-muted; }
// 빈 상태: s1 카드 안에서는 이중 박스 제거
.webhook-empty {
background: none;
border: none;
padding: 24px 0;
}
}
// s1-actions 안 취소/이전/삭제 버튼: 좌측 배치
.step1-wrap .s1-actions,
.step2-wrap .s1-actions,
.step3-wrap .s1-actions {
.btn-webhook-cancel,
.btn-webhook-danger { margin-right: auto; }
}
// ---------- 개발가이드 링크 ----------
.webhook-guide-link {
margin-top: 20px;
@@ -19,6 +19,7 @@
<!-- Content Area -->
<div class="app-management-content">
<!-- 모든 스타일은 step2-wrap 안에서만 적용 -->
<div class="step2-wrap">
<!-- Title -->
@@ -27,7 +28,7 @@
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<!-- Step 1: 앱 정보입력 (completed) -->
<!-- Step 1: 앱 정보수정 (completed) -->
<div class="s1-step">
<div class="s1-step-circle">
<!-- 입력폼 아이콘 -->
@@ -66,7 +67,7 @@
<div class="s1-step-line"></div>
<!-- Step 3: 앱 생성 완료 (rocket icon) -->
<!-- Step 3: 앱 수정 완료 (rocket icon) -->
<div class="s1-step">
<div class="s1-step-circle">
<!-- 로켓 아이콘 -->
@@ -90,85 +91,19 @@
</div>
</div>
<!-- Category Carousel Tab Container -->
<div class="s2-category-carousel-container">
<button type="button" class="s2-carousel-btn s2-carousel-btn--prev" id="btnPrevCategory" aria-label="이전 카테고리">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<div class="s2-category-list-wrapper" id="categoryListWrapper">
<div class="s2-category-list" id="categoryList">
<button type="button" class="s2-category-tab active" data-group="">
전체
</button>
<button type="button" class="s2-category-tab" th:each="service : ${apiServices}" th:data-group="${service.id}" th:text="${service.groupName}">
서비스명
</button>
</div>
</div>
<button type="button" class="s2-carousel-btn s2-carousel-btn--next" id="btnNextCategory" aria-label="다음 카테고리">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div>
<!-- API 선택 공용 모듈 -->
<th:block th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${apiKeyModification.selectedApis}, '/myapikey/modify/step2', '/myapikey/modify/step2/save')}"/>
<!-- Main Content Area -->
<div class="s2-selection-container">
<main class="s2-content-area">
<form id="modifyStep2Form" method="post" th:action="@{/myapikey/modify/step2}" th:object="${apiKeyModification}" class="s2-form">
<!-- Hidden field for clientId -->
<input type="hidden" th:field="*{clientId}"/>
<!-- Header filter: Search and Select All -->
<div class="s2-filter-header">
<div class="s2-select-all" id="selectAllWrapper" style="display: none;">
<label class="s2-select-all-label">
<input type="checkbox" id="selectAllCheckbox" class="visually-hidden">
<span class="s2-checkbox-custom"></span>
<span id="selectAllText">전체 선택</span>
</label>
</div>
<div class="s2-result-count">
<strong id="apiResultCount">0</strong>
</div>
<div class="s2-search-box">
<input type="text" id="apiSearch" class="s2-search-input" placeholder="API 검색...">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.23047 0C14.3285 0 18.4618 4.0539 18.4619 9.05469C18.4619 11.1083 17.7634 13.0014 16.5889 14.5205C16.5913 14.5229 16.5942 14.5249 16.5967 14.5273L19.5498 17.4238C20.1502 18.0131 20.1501 18.9683 19.5498 19.5576C18.949 20.147 17.9748 20.147 17.374 19.5576L14.4209 16.6621C14.3966 16.6383 14.3749 16.6119 14.3525 16.5869C12.8868 17.5478 11.1257 18.1094 9.23047 18.1094C4.13258 18.1092 0 14.0554 0 9.05469C0.000110268 4.05404 4.13265 0.000222701 9.23047 0ZM9.23047 3.01855C5.83201 3.01878 3.07726 5.721 3.07715 9.05469C3.07715 12.3885 5.83194 15.0916 9.23047 15.0918C12.6292 15.0918 15.3848 12.3886 15.3848 9.05469C15.3847 5.72086 12.6291 3.01855 9.23047 3.01855Z" fill="#515961"/>
</svg>
</div>
</div>
<!-- API Cards Grid -->
<div class="s2-cards-grid" id="apiCardGrid">
<!-- Loading state -->
<div class="s2-loading" id="loadingState" style="grid-column: 1 / -1;">
<div class="s2-spinner"></div>
<p>API 목록을 불러오는 중...</p>
</div>
<!-- Empty state -->
<div class="s2-empty" id="emptyState" style="display: none; grid-column: 1 / -1;">
<div class="s2-empty-icon">📦</div>
<h3>API를 선택해주세요</h3>
<p>상단 카테고리에서 서비스를 선택하면 해당 API 목록이 표시됩니다.</p>
</div>
</div>
</form>
</main>
</div>
<!-- clientId 는 모듈 폼에 form 속성으로 주입 -->
<input type="hidden" name="clientId" th:value="${apiKeyModification.clientId}" form="apiSelectorForm"/>
<!-- Bottom Navigation Actions -->
<div class="s2-actions">
<button type="button" id="btnPrevStep" class="s2-btn-prev">
이전
</button>
<button type="submit" form="modifyStep2Form" class="s2-btn-save">
다음
<button type="submit" form="apiSelectorForm" class="s2-btn-save">
저장
</button>
</div>
@@ -180,548 +115,7 @@
<!-- 화면 전체 오버레이/플로팅은 body 직속(pagePopups)으로 렌더 → wrapper transform·overflow 영향 없이 뷰포트 기준 중앙 정렬 -->
<section layout:fragment="pagePopups">
<!-- Floating Cart Button -->
<button type="button" class="s2-floating-cart" id="floatingCartBtn" style="display: none;">
<span class="s2-cart-label">선택된 API</span>
<span class="s2-cart-badge" id="cartBadge">
<span class="s2-cart-count">0</span>
<span class="s2-cart-unit"></span>
</span>
</button>
<!-- Selected APIs Modal -->
<div class="s2-modal" id="selectedApisModal" style="display: none;">
<div class="s2-modal-backdrop" id="modalOverlay"></div>
<div class="s2-modal-dialog">
<div class="s2-modal-header">
<h3 class="s2-modal-title">선택된 API 목록</h3>
<button type="button" class="s2-modal-close" id="modalCloseBtn"></button>
</div>
<div class="s2-modal-body">
<div class="s2-selected-list" id="modalSelectedList">
<!-- Dynamically populated -->
</div>
</div>
<div class="s2-modal-footer">
<button type="button" class="s2-btn-close-modal" id="modalCancelBtn">닫기</button>
</div>
</div>
</div>
<th:block th:replace="~{fragment/api_selector :: apiSelectorPopups}"/>
</section>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() {
// DOM Elements
const form = document.getElementById('modifyStep2Form');
const searchInput = document.getElementById('apiSearch');
const menuTitles = document.querySelectorAll('.s2-category-tab');
const apiCardGrid = document.getElementById('apiCardGrid');
const loadingState = document.getElementById('loadingState');
const emptyState = document.getElementById('emptyState');
let currentFilter = ''; // Empty string means "all"
let currentServiceName = '전체'; // Store current service name
let allApis = []; // Store loaded APIs
let allLoadedApis = []; // Store ALL APIs from server for modal display
let selectedApis = new Set(); // Track selected API IDs
// Restore selected APIs from session
const sessionSelectedApis = /*[[${apiKeyModification.selectedApis}]]*/ [];
if (sessionSelectedApis && Array.isArray(sessionSelectedApis)) {
sessionSelectedApis.forEach(function(apiId) {
selectedApis.add(apiId);
});
}
// Load all APIs for modal display (used when APIs from different categories are selected)
function loadAllApisForModal() {
const baseUrl = /*[[@{/apis/for_request}]]*/ '/apis/for_request';
fetch(baseUrl)
.then(response => response.json())
.then(apis => {
allLoadedApis = apis;
})
.catch(error => {
console.error('Failed to load all APIs for modal:', error);
});
}
// Load APIs via AJAX
function loadApis(groupId) {
// Show loading state
loadingState.style.display = 'block';
emptyState.style.display = 'none';
// Remove existing API cards
document.querySelectorAll('.s2-api-card').forEach(card => card.remove());
// Build URL with optional groupId filter (Thymeleaf contextPath safe)
const baseUrl = /*[[@{/apis/for_request}]]*/ '/apis/for_request';
let url = baseUrl;
if (groupId) {
url += '?groupIds=' + encodeURIComponent(groupId);
}
fetch(url).then(response => response.json()).then(apis => {
allApis = apis;
loadingState.style.display = 'none';
if (apis.length === 0) {
emptyState.style.display = 'block';
document.getElementById('apiResultCount').textContent = '0';
return;
}
document.getElementById('apiResultCount').textContent = apis.length;
renderApiCards(apis);
updateSelectAllUI();
}).catch(error => {
console.error('Failed to load APIs:', error);
loadingState.style.display = 'none';
emptyState.querySelector('h3').textContent = 'API 로드 실패';
emptyState.querySelector('p').textContent = '다시 시도해주세요.';
emptyState.style.display = 'block';
document.getElementById('apiResultCount').textContent = '0';
});
}
// Render API cards
function renderApiCards(apis) {
const fragment = document.createDocumentFragment();
apis.forEach(api => {
const card = createApiCard(api);
fragment.appendChild(card);
});
apiCardGrid.appendChild(fragment);
// Re-attach event listeners
attachCardEventListeners();
// Update modal list after cards are rendered
updateModalList();
}
// Create API card element
function createApiCard(api) {
const card = document.createElement('div');
card.className = 's2-api-card';
card.setAttribute('data-group', api.apiGroupId || '');
card.setAttribute('data-name', (api.apiName || '').toLowerCase());
card.setAttribute('data-desc', (api.apiSimpleDescription || '').toLowerCase());
card.setAttribute('data-api-id', api.apiId);
const isSelected = selectedApis.has(api.apiId);
if (isSelected) {
card.classList.add('selected');
}
const mainIconHtml = api.mainIcon
? `<img src="${api.mainIcon}" alt="${api.apiName}" onerror="this.style.display='none'; this.nextElementSibling.style.display='block'"><i class="fas fa-cube" style="display:none"></i>`
: `<i class="fas fa-cube"></i>`;
card.innerHTML = `
<div class="s2-api-card-badge">
<span>${api.apiGroupName || '카테고리'}</span>
</div>
<!-- Checkbox container with visible custom design -->
<label class="s2-checkbox-wrapper">
<input type="checkbox"
name="selectedApis"
value="${api.apiId}"
id="api-${api.apiId}"
class="s2-api-checkbox visually-hidden"
${isSelected ? 'checked' : ''}>
<span class="s2-checkbox-custom"></span>
</label>
<h3 class="s2-api-card-title">${api.apiName || 'API 이름'}</h3>
<p class="s2-api-card-desc">${api.apiSimpleDescription || 'API 설명이 없습니다.'}</p>
<div class="s2-api-card-image">
${mainIconHtml}
</div>
`;
return card;
}
// Attach event listeners to cards
function attachCardEventListeners() {
const apiCards = document.querySelectorAll('.s2-api-card');
apiCards.forEach(card => {
card.addEventListener('click', function(e) {
const checkbox = card.querySelector('.s2-api-checkbox');
if (checkbox) {
checkbox.checked = !checkbox.checked;
updateCardSelection(checkbox);
updateSelectedCount();
}
});
});
// Prevent double toggle when clicking the checkbox wrapper
const checkboxWrappers = document.querySelectorAll('.s2-checkbox-wrapper');
checkboxWrappers.forEach(wrapper => {
wrapper.addEventListener('click', function(e) {
e.stopPropagation(); // Stop click from bubbling to card!
});
// Listen to checkbox change event inside it
const checkbox = wrapper.querySelector('.s2-api-checkbox');
if (checkbox) {
checkbox.addEventListener('change', function() {
updateCardSelection(this);
updateSelectedCount();
});
}
});
}
// Update card visual state
function updateCardSelection(checkbox) {
const card = checkbox.closest('.s2-api-card');
if (checkbox.checked) {
card.classList.add('selected');
selectedApis.add(checkbox.value);
} else {
card.classList.remove('selected');
selectedApis.delete(checkbox.value);
}
}
// Update selected count
function updateSelectedCount() {
// Update floating cart button
const floatingCartBtn = document.getElementById('floatingCartBtn');
const cartCount = document.querySelector('.s2-cart-count');
if (selectedApis.size > 0) {
floatingCartBtn.style.display = 'flex';
cartCount.textContent = selectedApis.size;
} else {
floatingCartBtn.style.display = 'none';
}
// Update modal list
updateModalList();
// Update select all checkbox state
updateSelectAllCheckboxState();
}
// Update select all UI visibility and text
function updateSelectAllUI() {
const selectAllWrapper = document.getElementById('selectAllWrapper');
const selectAllText = document.getElementById('selectAllText');
if (currentFilter === '') {
// Hide select all when "전체" is selected
selectAllWrapper.style.display = 'none';
} else {
// Show select all with service name
selectAllWrapper.style.display = 'flex';
selectAllText.textContent = currentServiceName + ' API 전체 선택';
}
updateSelectAllCheckboxState();
}
// Update select all checkbox state based on visible cards
function updateSelectAllCheckboxState() {
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const visibleCards = Array.from(document.querySelectorAll('.s2-api-card')).filter(card => card.style.display !== 'none');
if (visibleCards.length === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
return;
}
const visibleCheckboxes = visibleCards.map(card => card.querySelector('.s2-api-checkbox'));
const checkedCount = visibleCheckboxes.filter(cb => cb.checked).length;
if (checkedCount === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
} else if (checkedCount === visibleCheckboxes.length) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
} else {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true;
}
}
// Update modal selected APIs list
function updateModalList() {
const modalSelectedList = document.getElementById('modalSelectedList');
modalSelectedList.innerHTML = '';
if (selectedApis.size === 0) {
modalSelectedList.innerHTML = '<p class="s2-empty-message">선택된 API가 없습니다.</p>';
return;
}
// Build list using selectedApis Set for consistency
selectedApis.forEach(function(apiId) {
// Try to find the checkbox in the DOM first
const checkbox = document.querySelector('.s2-api-checkbox[value="' + apiId + '"]');
let apiName = apiId; // Default to ID if we can't find the name
if (checkbox) {
// If checkbox exists in DOM, get the name from the card
const card = checkbox.closest('.s2-api-card');
if (card) {
const nameElement = card.querySelector('.s2-api-card-title');
if (nameElement) {
apiName = nameElement.textContent;
}
}
} else {
// If checkbox not in DOM (different category is showing),
// try to find the API in our loaded data
let api = allApis.find(a => a.apiId === apiId);
if (!api && allLoadedApis && allLoadedApis.length > 0) {
api = allLoadedApis.find(a => a.apiId === apiId);
}
if (api && api.apiName) {
apiName = api.apiName;
}
}
const apiPill = document.createElement('div');
apiPill.className = 's2-api-pill';
apiPill.innerHTML = `
<span class="s2-api-pill-name">${apiName}</span>
<button type="button" class="s2-api-pill-remove" data-value="${apiId}" aria-label="Remove ${apiName}">✕</button>
`;
modalSelectedList.appendChild(apiPill);
});
// Add remove handlers
document.querySelectorAll('.s2-api-pill-remove').forEach(function(btn) {
btn.addEventListener('click', function() {
const value = this.getAttribute('data-value');
const checkbox = document.querySelector('.s2-api-checkbox[value="' + value + '"]');
if (checkbox) {
checkbox.checked = false;
updateCardSelection(checkbox);
updateSelectedCount();
} else {
// If checkbox not found (maybe different category is showing),
// still remove from selection
selectedApis.delete(value);
updateSelectedCount();
}
});
});
}
// Search functionality
if (searchInput) {
searchInput.addEventListener('input', function() {
const searchTerm = this.value.toLowerCase();
const apiCards = document.querySelectorAll('.s2-api-card');
let visibleCount = 0;
apiCards.forEach(function(card) {
const apiName = card.getAttribute('data-name');
const apiDesc = card.getAttribute('data-desc');
// Check if matches search
const matchesSearch = apiName.includes(searchTerm) || apiDesc.includes(searchTerm);
if (matchesSearch) {
card.style.display = '';
visibleCount++;
} else {
card.style.display = 'none';
}
});
document.getElementById('apiResultCount').textContent = visibleCount;
// Update select all checkbox state after search
updateSelectAllCheckboxState();
});
}
// Category tab selection
menuTitles.forEach(function(title) {
title.addEventListener('click', function(e) {
e.preventDefault();
// Remove active from all
menuTitles.forEach(t => t.classList.remove('active'));
// Add active to clicked
this.classList.add('active');
const groupId = this.getAttribute('data-group');
currentFilter = groupId;
// Store service name for select all label
currentServiceName = this.textContent.trim();
// Load APIs for selected service
loadApis(groupId);
// Clear search when changing category
if (searchInput) {
searchInput.value = '';
}
});
});
// Category Carousel Scroll
const categoryListWrapper = document.getElementById('categoryListWrapper');
const btnPrevCategory = document.getElementById('btnPrevCategory');
const btnNextCategory = document.getElementById('btnNextCategory');
if (categoryListWrapper && btnPrevCategory && btnNextCategory) {
const scrollAmount = 200;
btnPrevCategory.addEventListener('click', function() {
categoryListWrapper.scrollBy({ left: -scrollAmount, behavior: 'smooth' });
});
btnNextCategory.addEventListener('click', function() {
categoryListWrapper.scrollBy({ left: scrollAmount, behavior: 'smooth' });
});
// Toggle buttons visibility/disabled state based on scroll position
function updateCarouselButtons() {
const scrollLeft = categoryListWrapper.scrollLeft;
const maxScrollLeft = categoryListWrapper.scrollWidth - categoryListWrapper.clientWidth;
btnPrevCategory.disabled = scrollLeft <= 0;
btnNextCategory.disabled = scrollLeft >= maxScrollLeft - 1;
}
categoryListWrapper.addEventListener('scroll', updateCarouselButtons);
window.addEventListener('resize', updateCarouselButtons);
// Initial check after loading categories
setTimeout(updateCarouselButtons, 150);
}
// Modal control
const floatingCartBtn = document.getElementById('floatingCartBtn');
const selectedApisModal = document.getElementById('selectedApisModal');
const modalOverlay = document.getElementById('modalOverlay');
const modalCloseBtn = document.getElementById('modalCloseBtn');
const modalCancelBtn = document.getElementById('modalCancelBtn');
// Open modal
function openModal() {
selectedApisModal.style.display = 'flex'; // .s2-modal은 flex 중앙정렬 → block 금지
// Add show class to backdrop and modal for visibility
setTimeout(function() {
if (modalOverlay) {
modalOverlay.classList.add('show');
}
selectedApisModal.classList.add('show');
}, 10);
document.body.style.overflow = 'hidden'; // Prevent background scroll
}
// Close modal
function closeModal() {
// Remove show class first for transition
if (modalOverlay) {
modalOverlay.classList.remove('show');
}
selectedApisModal.classList.remove('show');
// Hide modal after transition
setTimeout(function() {
selectedApisModal.style.display = 'none';
}, 300);
document.body.style.overflow = ''; // Restore scroll
}
// Floating cart button click
if (floatingCartBtn) {
floatingCartBtn.addEventListener('click', openModal);
}
// Modal overlay click
if (modalOverlay) {
modalOverlay.addEventListener('click', closeModal);
}
// Modal close button click
if (modalCloseBtn) {
modalCloseBtn.addEventListener('click', closeModal);
}
// Modal cancel button click
if (modalCancelBtn) {
modalCancelBtn.addEventListener('click', closeModal);
}
// Close modal on ESC key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && selectedApisModal.style.display === 'flex') {
closeModal();
}
});
// Select All checkbox event
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', function() {
const isChecked = this.checked;
const visibleCards = Array.from(document.querySelectorAll('.s2-api-card')).filter(card => card.style.display !== 'none');
visibleCards.forEach(function(card) {
const checkbox = card.querySelector('.s2-api-checkbox');
if (checkbox) {
checkbox.checked = isChecked;
updateCardSelection(checkbox);
}
});
updateSelectedCount();
});
}
// Previous step button - save current selections before going back
const btnPrevStep = document.getElementById('btnPrevStep');
if (btnPrevStep) {
btnPrevStep.addEventListener('click', function(e) {
e.preventDefault();
// Change form action to save endpoint (Thymeleaf contextPath safe)
form.action = /*[[@{/myapikey/modify/step2/save}]]*/ '/myapikey/modify/step2/save';
// Submit the form to save selections
form.submit();
});
}
// Form validation
form.addEventListener('submit', function(e) {
if (selectedApis.size === 0) {
e.preventDefault();
customPopups.showAlert('최소 1개 이상의 API를 선택해주세요.');
return false;
}
});
// Initialize
loadAllApisForModal();
updateSelectedCount(); // This will show count from restored session data
// Load all APIs automatically on page load (empty string = all APIs)
loadApis('');
});
</script>
</th:block>
</body>
</html>
@@ -91,81 +91,15 @@
</div>
</div>
<!-- Category Carousel Tab Container -->
<div class="s2-category-carousel-container">
<button type="button" class="s2-carousel-btn s2-carousel-btn--prev" id="btnPrevCategory" aria-label="이전 카테고리">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<div class="s2-category-list-wrapper" id="categoryListWrapper">
<div class="s2-category-list" id="categoryList">
<button type="button" class="s2-category-tab active" data-group="">
전체
</button>
<button type="button" class="s2-category-tab" th:each="service : ${apiServices}" th:data-group="${service.id}" th:text="${service.groupName}">
서비스명
</button>
</div>
</div>
<button type="button" class="s2-carousel-btn s2-carousel-btn--next" id="btnNextCategory" aria-label="다음 카테고리">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div>
<!-- Main Content Area -->
<div class="s2-selection-container">
<main class="s2-content-area">
<form id="registerStep2Form" method="post" th:action="@{/myapikey/register/step2}" class="s2-form">
<!-- Header filter: Search and Select All -->
<div class="s2-filter-header">
<div class="s2-select-all" id="selectAllWrapper" style="display: none;">
<label class="s2-select-all-label">
<input type="checkbox" id="selectAllCheckbox" class="visually-hidden">
<span class="s2-checkbox-custom"></span>
<span id="selectAllText">전체 선택</span>
</label>
</div>
<div class="s2-result-count">
<strong id="apiResultCount">0</strong>
</div>
<div class="s2-search-box">
<input type="text" id="apiSearch" class="s2-search-input" placeholder="API 검색...">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.23047 0C14.3285 0 18.4618 4.0539 18.4619 9.05469C18.4619 11.1083 17.7634 13.0014 16.5889 14.5205C16.5913 14.5229 16.5942 14.5249 16.5967 14.5273L19.5498 17.4238C20.1502 18.0131 20.1501 18.9683 19.5498 19.5576C18.949 20.147 17.9748 20.147 17.374 19.5576L14.4209 16.6621C14.3966 16.6383 14.3749 16.6119 14.3525 16.5869C12.8868 17.5478 11.1257 18.1094 9.23047 18.1094C4.13258 18.1092 0 14.0554 0 9.05469C0.000110268 4.05404 4.13265 0.000222701 9.23047 0ZM9.23047 3.01855C5.83201 3.01878 3.07726 5.721 3.07715 9.05469C3.07715 12.3885 5.83194 15.0916 9.23047 15.0918C12.6292 15.0918 15.3848 12.3886 15.3848 9.05469C15.3847 5.72086 12.6291 3.01855 9.23047 3.01855Z" fill="#515961"/>
</svg>
</div>
</div>
<!-- API Cards Grid -->
<div class="s2-cards-grid" id="apiCardGrid">
<!-- Loading state -->
<div class="s2-loading" id="loadingState" style="grid-column: 1 / -1;">
<div class="s2-spinner"></div>
<p>API 목록을 불러오는 중...</p>
</div>
<!-- Empty state -->
<div class="s2-empty" id="emptyState" style="display: none; grid-column: 1 / -1;">
<div class="s2-empty-icon">📦</div>
<h3>API를 선택해주세요</h3>
<p>상단 카테고리에서 서비스를 선택하면 해당 API 목록이 표시됩니다.</p>
</div>
</div>
</form>
</main>
</div>
<!-- API 선택 공용 모듈 -->
<th:block th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${apiKeyRegistration.selectedApis}, '/myapikey/register/step2', '/myapikey/register/step2/save')}"/>
<!-- Bottom Navigation Actions -->
<div class="s2-actions">
<button type="button" id="btnPrevStep" class="s2-btn-prev">
이전
</button>
<button type="submit" form="registerStep2Form" class="s2-btn-save">
<button type="submit" form="apiSelectorForm" class="s2-btn-save">
다음
</button>
</div>
@@ -178,517 +112,7 @@
<!-- 화면 전체 오버레이/플로팅은 body 직속(pagePopups)으로 렌더 → wrapper transform·overflow 영향 없이 뷰포트 기준 중앙 정렬 -->
<section layout:fragment="pagePopups">
<!-- Floating Cart Button -->
<button type="button" class="s2-floating-cart" id="floatingCartBtn" style="display: none;">
<span class="s2-cart-label">선택된 API</span>
<span class="s2-cart-badge" id="cartBadge">
<span class="s2-cart-count">0</span>
<span class="s2-cart-unit"></span>
</span>
</button>
<!-- Selected APIs Modal -->
<div class="s2-modal" id="selectedApisModal" style="display: none;">
<div class="s2-modal-backdrop" id="modalOverlay"></div>
<div class="s2-modal-dialog">
<div class="s2-modal-header">
<h3 class="s2-modal-title">선택된 API 목록</h3>
<button type="button" class="s2-modal-close" id="modalCloseBtn"></button>
</div>
<div class="s2-modal-body">
<div class="s2-selected-list" id="modalSelectedList">
<!-- Dynamically populated -->
</div>
</div>
<div class="s2-modal-footer">
<button type="button" class="s2-btn-close-modal" id="modalCancelBtn">닫기</button>
</div>
</div>
</div>
<th:block th:replace="~{fragment/api_selector :: apiSelectorPopups}"/>
</section>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() {
// DOM Elements
const form = document.getElementById('registerStep2Form');
const searchInput = document.getElementById('apiSearch');
const menuTitles = document.querySelectorAll('.s2-category-tab');
const apiCardGrid = document.getElementById('apiCardGrid');
const loadingState = document.getElementById('loadingState');
const emptyState = document.getElementById('emptyState');
let currentFilter = ''; // Empty string means "all"
let currentServiceName = '전체'; // Store current service name
let allApis = []; // Store loaded APIs
let selectedApis = new Set(); // Track selected API IDs
// Restore selected APIs from session
const sessionSelectedApis = /*[[${apiKeyRegistration.selectedApis}]]*/ [];
if (sessionSelectedApis && Array.isArray(sessionSelectedApis)) {
sessionSelectedApis.forEach(function(apiId) {
selectedApis.add(apiId);
});
}
// Load APIs via AJAX
function loadApis(groupId) {
// Show loading state
loadingState.style.display = 'block';
emptyState.style.display = 'none';
// Remove existing API cards
document.querySelectorAll('.s2-api-card').forEach(card => card.remove());
// Build URL with optional groupId filter (Thymeleaf contextPath safe)
const baseUrl = /*[[@{/apis/for_request}]]*/ '/apis/for_request';
let url = baseUrl;
if (groupId) {
url += '?groupIds=' + encodeURIComponent(groupId);
}
fetch(url).then(response => response.json()).then(apis => {
allApis = apis;
loadingState.style.display = 'none';
if (apis.length === 0) {
emptyState.style.display = 'block';
document.getElementById('apiResultCount').textContent = '0';
return;
}
document.getElementById('apiResultCount').textContent = apis.length;
renderApiCards(apis);
updateSelectAllUI();
}).catch(error => {
console.error('Failed to load APIs:', error);
loadingState.style.display = 'none';
emptyState.querySelector('h3').textContent = 'API 로드 실패';
emptyState.querySelector('p').textContent = '다시 시도해주세요.';
emptyState.style.display = 'block';
document.getElementById('apiResultCount').textContent = '0';
});
}
// Render API cards
function renderApiCards(apis) {
const fragment = document.createDocumentFragment();
apis.forEach(api => {
const card = createApiCard(api);
fragment.appendChild(card);
});
apiCardGrid.appendChild(fragment);
// Re-attach event listeners
attachCardEventListeners();
}
// Create API card element (Matches mainApiList.html exactly, card clicks toggle selection)
function createApiCard(api) {
const card = document.createElement('div');
card.className = 's2-api-card';
card.setAttribute('data-group', api.apiGroupId || '');
card.setAttribute('data-name', (api.apiName || '').toLowerCase());
card.setAttribute('data-desc', (api.apiSimpleDescription || '').toLowerCase());
card.setAttribute('data-api-id', api.apiId);
const isSelected = selectedApis.has(api.apiId);
if (isSelected) {
card.classList.add('selected');
}
const mainIconHtml = api.mainIcon
? `<img src="${api.mainIcon}" alt="${api.apiName}" onerror="this.style.display='none'; this.nextElementSibling.style.display='block'"><i class="fas fa-cube" style="display:none"></i>`
: `<i class="fas fa-cube"></i>`;
card.innerHTML = `
<div class="s2-api-card-badge">
<span>${api.apiGroupName || '카테고리'}</span>
</div>
<!-- Checkbox container with visible custom design -->
<label class="s2-checkbox-wrapper">
<input type="checkbox"
name="selectedApis"
value="${api.apiId}"
id="api-${api.apiId}"
class="s2-api-checkbox visually-hidden"
${isSelected ? 'checked' : ''}>
<span class="s2-checkbox-custom"></span>
</label>
<h3 class="s2-api-card-title">${api.apiName || 'API 이름'}</h3>
<p class="s2-api-card-desc">${api.apiSimpleDescription || 'API 설명이 없습니다.'}</p>
<div class="s2-api-card-image">
${mainIconHtml}
</div>
`;
return card;
}
// Attach event listeners to cards
function attachCardEventListeners() {
const apiCards = document.querySelectorAll('.s2-api-card');
apiCards.forEach(card => {
card.addEventListener('click', function(e) {
const checkbox = card.querySelector('.s2-api-checkbox');
if (checkbox) {
checkbox.checked = !checkbox.checked;
updateCardSelection(checkbox);
updateSelectedCount();
}
});
});
// Prevent double toggle when clicking the checkbox wrapper
const checkboxWrappers = document.querySelectorAll('.s2-checkbox-wrapper');
checkboxWrappers.forEach(wrapper => {
wrapper.addEventListener('click', function(e) {
e.stopPropagation(); // Stop click from bubbling to card!
});
// Listen to checkbox change event inside it
const checkbox = wrapper.querySelector('.s2-api-checkbox');
if (checkbox) {
checkbox.addEventListener('change', function() {
updateCardSelection(this);
updateSelectedCount();
});
}
});
}
// Update card visual state
function updateCardSelection(checkbox) {
const card = checkbox.closest('.s2-api-card');
if (checkbox.checked) {
card.classList.add('selected');
selectedApis.add(checkbox.value);
} else {
card.classList.remove('selected');
selectedApis.delete(checkbox.value);
}
}
// Update selected count
function updateSelectedCount() {
// Update floating cart button
const floatingCartBtn = document.getElementById('floatingCartBtn');
const cartCount = document.querySelector('.s2-cart-count');
if (selectedApis.size > 0) {
floatingCartBtn.style.display = 'flex';
cartCount.textContent = selectedApis.size;
} else {
floatingCartBtn.style.display = 'none';
}
// Update modal list
updateModalList();
// Update select all checkbox state
updateSelectAllCheckboxState();
}
// Update select all UI visibility and text
function updateSelectAllUI() {
const selectAllWrapper = document.getElementById('selectAllWrapper');
const selectAllText = document.getElementById('selectAllText');
if (currentFilter === '') {
// Hide select all when "전체" is selected
selectAllWrapper.style.display = 'none';
} else {
// Show select all with service name
selectAllWrapper.style.display = 'flex';
selectAllText.textContent = currentServiceName + ' API 전체 선택';
}
updateSelectAllCheckboxState();
}
// Update select all checkbox state based on visible cards
function updateSelectAllCheckboxState() {
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const visibleCards = Array.from(document.querySelectorAll('.s2-api-card')).filter(card => card.style.display !== 'none');
if (visibleCards.length === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
return;
}
const visibleCheckboxes = visibleCards.map(card => card.querySelector('.s2-api-checkbox'));
const checkedCount = visibleCheckboxes.filter(cb => cb.checked).length;
if (checkedCount === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
} else if (checkedCount === visibleCheckboxes.length) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
} else {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true;
}
}
// Update modal selected APIs list
function updateModalList() {
const modalSelectedList = document.getElementById('modalSelectedList');
modalSelectedList.innerHTML = '';
if (selectedApis.size === 0) {
modalSelectedList.innerHTML = '<p class="s2-empty-message">선택된 API가 없습니다.</p>';
return;
}
// Get all checked checkboxes
const checkedBoxes = document.querySelectorAll('.s2-api-checkbox:checked');
checkedBoxes.forEach(function(checkbox) {
const card = checkbox.closest('.s2-api-card');
const apiName = card.querySelector('.s2-api-card-title').textContent;
const apiPill = document.createElement('div');
apiPill.className = 's2-api-pill';
apiPill.innerHTML = `
<span class="s2-api-pill-name">${apiName}</span>
<button type="button" class="s2-api-pill-remove" data-value="${checkbox.value}" aria-label="Remove ${apiName}">✕</button>
`;
modalSelectedList.appendChild(apiPill);
});
// Add remove handlers
document.querySelectorAll('.s2-api-pill-remove').forEach(function(btn) {
btn.addEventListener('click', function() {
const value = this.getAttribute('data-value');
const checkbox = document.querySelector('.s2-api-checkbox[value="' + value + '"]');
if (checkbox) {
checkbox.checked = false;
updateCardSelection(checkbox);
updateSelectedCount();
}
});
});
}
// Search functionality
if (searchInput) {
searchInput.addEventListener('input', function() {
const searchTerm = this.value.toLowerCase();
const apiCards = document.querySelectorAll('.s2-api-card');
let visibleCount = 0;
apiCards.forEach(function(card) {
const apiName = card.getAttribute('data-name');
const apiDesc = card.getAttribute('data-desc');
// Check if matches search
const matchesSearch = apiName.includes(searchTerm) || apiDesc.includes(searchTerm);
if (matchesSearch) {
card.style.display = '';
visibleCount++;
} else {
card.style.display = 'none';
}
});
document.getElementById('apiResultCount').textContent = visibleCount;
// Update select all checkbox state after search
updateSelectAllCheckboxState();
});
}
// Category tab selection
menuTitles.forEach(function(title) {
title.addEventListener('click', function(e) {
e.preventDefault();
// Remove active from all
menuTitles.forEach(t => t.classList.remove('active'));
// Add active to clicked
this.classList.add('active');
const groupId = this.getAttribute('data-group');
currentFilter = groupId;
// Store service name for select all label
currentServiceName = this.textContent.trim();
// Load APIs for selected service
loadApis(groupId);
// Clear search when changing category
if (searchInput) {
searchInput.value = '';
}
});
});
// Category Carousel Scroll
const categoryListWrapper = document.getElementById('categoryListWrapper');
const btnPrevCategory = document.getElementById('btnPrevCategory');
const btnNextCategory = document.getElementById('btnNextCategory');
if (categoryListWrapper && btnPrevCategory && btnNextCategory) {
const scrollAmount = 200;
btnPrevCategory.addEventListener('click', function() {
categoryListWrapper.scrollBy({ left: -scrollAmount, behavior: 'smooth' });
});
btnNextCategory.addEventListener('click', function() {
categoryListWrapper.scrollBy({ left: scrollAmount, behavior: 'smooth' });
});
// Toggle buttons visibility/disabled state based on scroll position
function updateCarouselButtons() {
const scrollLeft = categoryListWrapper.scrollLeft;
const maxScrollLeft = categoryListWrapper.scrollWidth - categoryListWrapper.clientWidth;
btnPrevCategory.disabled = scrollLeft <= 0;
btnNextCategory.disabled = scrollLeft >= maxScrollLeft - 1;
}
categoryListWrapper.addEventListener('scroll', updateCarouselButtons);
window.addEventListener('resize', updateCarouselButtons);
// Initial check after loading categories
setTimeout(updateCarouselButtons, 150);
}
// Modal control
const floatingCartBtn = document.getElementById('floatingCartBtn');
const selectedApisModal = document.getElementById('selectedApisModal');
const modalOverlay = document.getElementById('modalOverlay');
const modalCloseBtn = document.getElementById('modalCloseBtn');
const modalCancelBtn = document.getElementById('modalCancelBtn');
// Open modal
function openModal() {
selectedApisModal.style.display = 'flex'; // .s2-modal은 flex 중앙정렬 → block 금지
// Add show class to backdrop and modal for visibility
setTimeout(function() {
if (modalOverlay) {
modalOverlay.classList.add('show');
}
selectedApisModal.classList.add('show');
}, 10);
document.body.style.overflow = 'hidden'; // Prevent background scroll
}
// Close modal
function closeModal() {
// Remove show class first for transition
if (modalOverlay) {
modalOverlay.classList.remove('show');
}
selectedApisModal.classList.remove('show');
// Hide modal after transition
setTimeout(function() {
selectedApisModal.style.display = 'none';
}, 300);
document.body.style.overflow = ''; // Restore scroll
}
// Floating cart button click
if (floatingCartBtn) {
floatingCartBtn.addEventListener('click', openModal);
}
// Modal overlay click
if (modalOverlay) {
modalOverlay.addEventListener('click', closeModal);
}
// Modal close button click
if (modalCloseBtn) {
modalCloseBtn.addEventListener('click', closeModal);
}
// Modal cancel button click
if (modalCancelBtn) {
modalCancelBtn.addEventListener('click', closeModal);
}
// Close modal on ESC key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && selectedApisModal.style.display === 'flex') {
closeModal();
}
});
// Select All checkbox event
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', function() {
const isChecked = this.checked;
const visibleCards = Array.from(document.querySelectorAll('.s2-api-card')).filter(card => card.style.display !== 'none');
visibleCards.forEach(function(card) {
const checkbox = card.querySelector('.s2-api-checkbox');
if (checkbox) {
checkbox.checked = isChecked;
updateCardSelection(checkbox);
}
});
updateSelectedCount();
});
}
// Previous step button - save current selections before going back
const btnPrevStep = document.getElementById('btnPrevStep');
if (btnPrevStep) {
btnPrevStep.addEventListener('click', function(e) {
e.preventDefault();
// Change form action to save endpoint (Thymeleaf contextPath safe)
form.action = /*[[@{/myapikey/register/step2/save}]]*/ '/myapikey/register/step2/save';
// Submit the form to save selections
form.submit();
});
}
// Form validation
form.addEventListener('submit', function(e) {
if (selectedApis.size === 0) {
e.preventDefault();
customPopups.showAlert('최소 1개 이상의 API를 선택해주세요.');
return false;
}
// Store selected APIs in sessionStorage
sessionStorage.setItem('registration_selectedApis', JSON.stringify(Array.from(selectedApis)));
});
// Initialize
updateSelectedCount(); // This will show count from restored session data
// Load all APIs automatically on page load (empty string = all APIs)
loadApis('');
// Load data from step 1 (if needed for display)
const appName = sessionStorage.getItem('registration_appName');
if (appName) {
console.log('App Name from Step 1:', appName);
}
// Log restored selections for debugging
if (selectedApis.size > 0) {
console.log('Restored ' + selectedApis.size + ' selected APIs from session:', Array.from(selectedApis));
}
});
</script>
</th:block>
</body>
</html>
@@ -9,6 +9,7 @@
<!-- Section 1: Hero -->
<header class="oauth2-2legged__hero">
<div class="oauth2-2legged__hero-inner">
<div class="oauth2-2legged__hero-body">
<span class="oauth2-2legged__hero-eyebrow">
<span class="oauth2-2legged__hero-eyebrow-dot"></span>
@@ -58,8 +59,15 @@
<text x="140" y="172" text-anchor="middle" font-size="11" font-weight="600" fill="#64748B">Signed Webhook Delivery</text>
</svg>
</div>
</div>
</header>
<div class="container service-main">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhookGuide')}"></th:block>
<div class="service-content">
<!-- Section 2: 개요 -->
<section class="oauth2-2legged__prereq" aria-labelledby="whsig-prereq-title">
<span class="oauth2-2legged__eyebrow">OVERVIEW</span>
@@ -458,6 +466,9 @@ HTTP/1.1 <span class="o2leg-g">400 Bad Request</span>
<span class="oauth2-2legged__cta-deco oauth2-2legged__cta-deco--sm" aria-hidden="true"></span>
</a>
</div>
</div>
</section>
</th:block>
@@ -1,42 +1,62 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/djbank_title_layout}">
<body>
<section layout:fragment="title">
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>Webhook 관리</h1>
</div>
</section>
</section>
<th:block layout:fragment="contentFragment">
<section class="webhook-container">
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
<!-- Content Area -->
<div class="app-management-content">
<div class="step1-wrap">
<!-- Title -->
<h2 class="s1-title">Webhook 관리</h2>
<!-- Empty Card -->
<div class="s1-form-card">
<div class="webhook-empty">
<div class="empty-icon">🔔</div>
<h3>등록된 Webhook이 없습니다</h3>
<p>API 서비스의 점검·지연·장애 알림을 받을 Webhook을 신청해보세요.</p>
<div class="webhook-empty-action" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')">
<button type="button" class="btn-webhook-create" id="requestWebhook">
Webhook 신청
</button>
</div>
<p class="field-help" sec:authorize="!hasRole('ROLE_API_KEY_REQUEST')">
Webhook 신청은 법인 관리자만 가능합니다. 기관 관리자에게 문의하세요.
</p>
</div>
</div>
<!-- 액션 -->
<div class="s1-actions" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')">
<button type="button" class="s1-btn-next" id="requestWebhook">Webhook 신청</button>
</div>
<p class="webhook-guide-link">
<a th:href="@{/service/webhook-dev-guide}">
📘 웹훅 개발가이드 — 수신·서명검증·응답 규칙 보러가기
</a>
</p>
</div>
</section>
</th:block>
<th:block layout:fragment="contentScript">
</div><!-- /step1-wrap -->
</div>
</div>
</div>
</th:block>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function () {
var btn = document.getElementById('requestWebhook');
@@ -47,6 +67,6 @@
}
});
</script>
</th:block>
</th:block>
</body>
</html>
@@ -1,19 +1,33 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/djbank_title_layout}">
<body>
<section layout:fragment="title">
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>Webhook 관리</h1>
</div>
</section>
</section>
<th:block layout:fragment="contentFragment">
<section class="webhook-container">
<div class="webhook-card" th:object="${webhook}">
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
<!-- Content Area -->
<div class="app-management-content">
<div class="step1-wrap">
<!-- Title -->
<h2 class="s1-title">Webhook 관리</h2>
<!-- Registered Webhook Card -->
<div class="s1-form-card" th:object="${webhook}">
<div class="webhook-card-head">
<h3>등록된 Webhook</h3>
@@ -50,11 +64,12 @@
</th:block>
</span>
</div>
<div class="webhook-card-actions" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')">
<a class="btn-webhook-next" th:href="@{/webhook/modify/step1}">수정</a>
<button type="button" class="btn-webhook-danger" id="btnDeleteWebhook">삭제</button>
</div>
<!-- 액션 -->
<div class="s1-actions" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')">
<button type="button" class="btn-webhook-danger" id="btnDeleteWebhook">삭제</button>
<a class="s1-btn-next" th:href="@{/webhook/modify/step1}">수정</a>
</div>
<p class="webhook-guide-link">
@@ -62,7 +77,11 @@
📘 웹훅 개발가이드 — 수신·서명검증·응답 규칙 보러가기
</a>
</p>
</section>
</div><!-- /step1-wrap -->
</div>
</div>
</div>
<!-- 비밀번호 재인증 모달 -->
<div class="webhook-modal" id="pwModal" style="display:none;">
@@ -77,9 +96,9 @@
</div>
</div>
</div>
</th:block>
</th:block>
<th:block layout:fragment="contentScript">
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function () {
var csrfToken = document.querySelector('meta[name="_csrf"]');
@@ -166,6 +185,6 @@
});
});
</script>
</th:block>
</th:block>
</body>
</html>
@@ -1,42 +1,88 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/djbank_title_layout}">
<body>
<section layout:fragment="title">
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>Webhook 수정</h1>
<h1>Webhook 관리</h1>
</div>
</section>
</section>
<th:block layout:fragment="contentFragment">
<section class="webhook-container webhook-register">
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<div class="webhook-steps" th:with="cur=${currentStep}">
<div class="webhook-step" th:classappend="${cur >= 1} ? 'active'">
<span class="step-no">1</span><span class="step-label">기본 정보</span>
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
<!-- Content Area -->
<div class="app-management-content">
<div class="step1-wrap">
<!-- Title -->
<h2 class="s1-title">Webhook 수정</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<!-- Step 1 Active: 기본 정보 수정 -->
<div class="s1-step s1-step--active">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step1.png}" alt="기본 정보 수정" width="36" height="36">
</div>
<div class="webhook-step" th:classappend="${cur >= 2} ? 'active'">
<span class="step-no">2</span><span class="step-label">API 선택</span>
<span class="s1-step-num">1단계</span>
<span class="s1-step-name">기본 정보 수정</span>
</div>
<div class="s1-step-line"></div>
<!-- Step 2: API 선택 -->
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
</div>
<span class="s1-step-num">2단계</span>
<span class="s1-step-name">API 선택</span>
</div>
<div class="s1-step-line"></div>
<!-- Step 3: 수정 완료 -->
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step3.png}" alt="수정 완료" width="36" height="36">
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">수정 완료</span>
</div>
<div class="webhook-step" th:classappend="${cur >= 3} ? 'active'">
<span class="step-no">3</span><span class="step-label">완료</span>
</div>
</div>
<form id="modifyStep1Form" method="post" th:action="@{/webhook/modify/step1}" th:object="${webhookModification}">
<!-- Error -->
<div th:if="${error}" class="s1-alert">
<span th:text="${error}"></span>
</div>
<div class="webhook-field">
<label for="targetUrl">Webhook 수신 URL <span class="req">*</span></label>
<input type="text" id="targetUrl" th:field="*{targetUrl}"
placeholder="https://example.com/webhook" autocomplete="off">
<!-- Form Card -->
<div class="s1-form-card">
<form id="webhookStep1Form" method="post" th:action="@{/webhook/modify/step1}"
th:object="${webhookModification}">
<!-- Webhook 수신 URL -->
<div class="s1-field">
<label class="s1-label">Webhook 수신 URL <span class="s1-required">*</span></label>
<input type="text" id="targetUrl" th:field="*{targetUrl}" class="s1-input"
placeholder="https://example.com/webhook" autocomplete="off" maxlength="255">
<p class="field-error" th:if="${#fields.hasErrors('targetUrl')}" th:errors="*{targetUrl}">URL 오류</p>
<p class="field-help">이벤트 발생 시 이 URL로 서명된 POST 요청이 전송됩니다.</p>
</div>
<div class="webhook-field">
<label>알림 받을 이벤트 <span class="req">*</span></label>
<!-- 알림 이벤트 -->
<div class="s1-field">
<label class="s1-label">알림 받을 이벤트 <span class="s1-required">*</span></label>
<div class="webhook-eventtype-list">
<label class="eventtype-item" th:each="et : ${eventTypes}">
<input type="checkbox" name="eventTypes" th:value="${et.code}"
@@ -48,12 +94,19 @@
<p class="field-error" th:if="${#fields.hasErrors('eventTypes')}" th:errors="*{eventTypes}">이벤트 오류</p>
</div>
<div class="webhook-actions">
<a class="btn-webhook-cancel" th:href="@{/webhook/modify/cancel}">취소</a>
<button type="submit" class="btn-webhook-next">다음</button>
</div>
</form>
</section>
</th:block>
</div>
<!-- 액션 -->
<div class="s1-actions">
<a class="btn-webhook-cancel" th:href="@{/webhook/modify/cancel}">취소</a>
<button type="submit" form="webhookStep1Form" class="s1-btn-next">다음</button>
</div>
</div><!-- /step1-wrap -->
</div>
</div>
</div>
</th:block>
</body>
</html>
@@ -1,80 +1,94 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/djbank_title_layout}">
<body>
<section layout:fragment="title">
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>Webhook 관리</h1>
</div>
</section>
<th:block layout:fragment="contentFragment">
<section class="apikey-register-container">
</section>
<!-- Title Bar -->
<div class="common-title-bar">
<h2 class="common-title">Webhook 수정</h2>
<span class="common-subtitle">알림 받을 API 구성을 변경해주세요.</span>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
<!-- Content Area -->
<div class="app-management-content">
<div class="step2-wrap">
<!-- Title -->
<h2 class="s1-title">Webhook 수정</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<!-- Step 1: 기본 정보 수정 -->
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step1.png}" alt="기본 정보 수정" width="36" height="36">
</div>
<span class="s1-step-num">1단계</span>
<span class="s1-step-name">기본 정보 수정</span>
</div>
<!-- Progress Indicator -->
<div class="register-progress">
<div class="progress-steps">
<div class="progress-step-item">
<div class="step-icon-wrapper">
<div class="step-icon">
<img th:src="@{/img/apikey_step1.png}" alt="기본 정보" width="36" height="36">
</div>
</div>
<div class="step-label">기본 정보</div>
</div>
<div class="s1-step-line"></div>
<div class="step-dots">
<span></span><span></span><span></span>
</div>
<div class="progress-step-item active">
<div class="step-icon-wrapper">
<div class="step-icon">
<!-- Step 2 Active: API 선택 -->
<div class="s1-step s1-step--active">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
</div>
</div>
<div class="step-label">API 선택</div>
<span class="s1-step-num">2단계</span>
<span class="s1-step-name">API 선택</span>
</div>
<div class="step-dots">
<span></span><span></span><span></span>
</div>
<div class="s1-step-line"></div>
<div class="progress-step-item">
<div class="step-icon-wrapper">
<div class="step-icon">
<!-- Step 3: 수정 완료 -->
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step3.png}" alt="수정 완료" width="36" height="36">
</div>
</div>
<div class="step-label">수정 완료</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">수정 완료</span>
</div>
</div>
</div>
<p class="webhook-field-error" th:if="${error}" th:text="${error}"></p>
<!-- Error -->
<div th:if="${error}" class="s1-alert">
<span th:text="${error}"></span>
</div>
<!-- API 선택 공용 모듈 -->
<th:block th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${webhookModification.selectedApis}, '/webhook/modify/step2', '/webhook/modify/step2/save')}"/>
<!-- Form Actions -->
<div class="form-actions">
<button type="button" id="btnPrevStep" class="btn-submit btn-secondary">
<!-- Bottom Navigation Actions -->
<div class="s2-actions">
<button type="button" id="btnPrevStep" class="s2-btn-prev">
이전
</button>
<button type="submit" form="apiSelectorForm" class="btn-submit btn-primary">
<button type="submit" form="apiSelectorForm" class="s2-btn-save">
수정 완료
</button>
</div>
</div><!-- /step2-wrap -->
</div>
</div>
</div>
</th:block>
<!-- 플로팅 카트/모달: body 직속 렌더 (wrapper transform 영향 회피) -->
<section layout:fragment="pagePopups">
<th:block th:replace="~{fragment/api_selector :: apiSelectorPopups}"/>
</section>
</th:block>
</body>
</html>
@@ -1,34 +1,82 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/djbank_title_layout}">
<body>
<section layout:fragment="title">
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>Webhook 수정 완료</h1>
<h1>Webhook 관리</h1>
</div>
</section>
</section>
<th:block layout:fragment="contentFragment">
<section class="webhook-container webhook-register">
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<div class="webhook-steps">
<div class="webhook-step active"><span class="step-no">1</span><span class="step-label">기본 정보</span></div>
<div class="webhook-step active"><span class="step-no">2</span><span class="step-label">API 선택</span></div>
<div class="webhook-step active"><span class="step-no">3</span><span class="step-label">완료</span></div>
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
<!-- Content Area -->
<div class="app-management-content">
<div class="step3-wrap">
<!-- Title -->
<h2 class="s1-title">Webhook 수정</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step1.png}" alt="기본 정보 수정" width="36" height="36">
</div>
<span class="s1-step-num">1단계</span>
<span class="s1-step-name">기본 정보 수정</span>
</div>
<div class="s1-step-line"></div>
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
</div>
<span class="s1-step-num">2단계</span>
<span class="s1-step-name">API 선택</span>
</div>
<div class="s1-step-line"></div>
<!-- Step 3 Active: 수정 완료 -->
<div class="s1-step s1-step--active">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step3.png}" alt="수정 완료" width="36" height="36">
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">수정 완료</span>
</div>
</div>
</div>
<!-- Complete Card -->
<div class="s1-form-card">
<div class="webhook-complete">
<div class="complete-icon"></div>
<h3>Webhook 설정이 수정되었습니다</h3>
<p class="field-help">Secret Key는 변경되지 않았습니다.</p>
</div>
</div>
<div class="webhook-actions center">
<a class="btn-webhook-next" th:href="@{/webhook}">완료</a>
<!-- 액션 -->
<div class="s1-actions">
<a class="s1-btn-next" th:href="@{/webhook}">완료</a>
</div>
</div><!-- /step3-wrap -->
</div>
</div>
</section>
</th:block>
</div>
</th:block>
</body>
</html>
@@ -1,43 +1,88 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/djbank_title_layout}">
<body>
<section layout:fragment="title">
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>Webhook 신청</h1>
<h1>Webhook 관리</h1>
</div>
</section>
</section>
<th:block layout:fragment="contentFragment">
<section class="webhook-container webhook-register">
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<!-- 진행 표시 -->
<div class="webhook-steps" th:with="cur=${currentStep}">
<div class="webhook-step" th:classappend="${cur >= 1} ? 'active'">
<span class="step-no">1</span><span class="step-label">기본 정보</span>
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
<!-- Content Area -->
<div class="app-management-content">
<div class="step1-wrap">
<!-- Title -->
<h2 class="s1-title">Webhook 신청</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<!-- Step 1 Active: 기본 정보 입력 -->
<div class="s1-step s1-step--active">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step1.png}" alt="기본 정보 입력" width="36" height="36">
</div>
<div class="webhook-step" th:classappend="${cur >= 2} ? 'active'">
<span class="step-no">2</span><span class="step-label">API 선택</span>
<span class="s1-step-num">1단계</span>
<span class="s1-step-name">기본 정보 입력</span>
</div>
<div class="s1-step-line"></div>
<!-- Step 2: API 선택 -->
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
</div>
<span class="s1-step-num">2단계</span>
<span class="s1-step-name">API 선택</span>
</div>
<div class="s1-step-line"></div>
<!-- Step 3: 신청 완료 -->
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step3.png}" alt="신청 완료" width="36" height="36">
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">신청 완료</span>
</div>
<div class="webhook-step" th:classappend="${cur >= 3} ? 'active'">
<span class="step-no">3</span><span class="step-label">완료</span>
</div>
</div>
<form id="registerStep1Form" method="post" th:action="@{/webhook/register/step1}" th:object="${webhookRegistration}">
<!-- Error -->
<div th:if="${error}" class="s1-alert">
<span th:text="${error}"></span>
</div>
<div class="webhook-field">
<label for="targetUrl">Webhook 수신 URL <span class="req">*</span></label>
<input type="text" id="targetUrl" th:field="*{targetUrl}"
placeholder="https://example.com/webhook" autocomplete="off">
<!-- Form Card -->
<div class="s1-form-card">
<form id="webhookStep1Form" method="post" th:action="@{/webhook/register/step1}"
th:object="${webhookRegistration}">
<!-- Webhook 수신 URL -->
<div class="s1-field">
<label class="s1-label">Webhook 수신 URL <span class="s1-required">*</span></label>
<input type="text" id="targetUrl" th:field="*{targetUrl}" class="s1-input"
placeholder="https://example.com/webhook" autocomplete="off" maxlength="255">
<p class="field-error" th:if="${#fields.hasErrors('targetUrl')}" th:errors="*{targetUrl}">URL 오류</p>
<p class="field-help">이벤트 발생 시 이 URL로 서명된 POST 요청이 전송됩니다.</p>
</div>
<div class="webhook-field">
<label>알림 받을 이벤트 <span class="req">*</span></label>
<!-- 알림 이벤트 -->
<div class="s1-field">
<label class="s1-label">알림 받을 이벤트 <span class="s1-required">*</span></label>
<div class="webhook-eventtype-list">
<label class="eventtype-item" th:each="et : ${eventTypes}">
<input type="checkbox" name="eventTypes" th:value="${et.code}"
@@ -49,12 +94,19 @@
<p class="field-error" th:if="${#fields.hasErrors('eventTypes')}" th:errors="*{eventTypes}">이벤트 오류</p>
</div>
<div class="webhook-actions">
<a class="btn-webhook-cancel" th:href="@{/webhook/register/cancel}">취소</a>
<button type="submit" class="btn-webhook-next">다음</button>
</div>
</form>
</section>
</th:block>
</div>
<!-- 액션 -->
<div class="s1-actions">
<a class="btn-webhook-cancel" th:href="@{/webhook/register/cancel}">취소</a>
<button type="submit" form="webhookStep1Form" class="s1-btn-next">다음</button>
</div>
</div><!-- /step1-wrap -->
</div>
</div>
</div>
</th:block>
</body>
</html>
@@ -1,80 +1,94 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/djbank_title_layout}">
<body>
<section layout:fragment="title">
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>Webhook 관리</h1>
</div>
</section>
<th:block layout:fragment="contentFragment">
<section class="apikey-register-container">
</section>
<!-- Title Bar -->
<div class="common-title-bar">
<h2 class="common-title">Webhook 신청</h2>
<span class="common-subtitle">상태 알림을 받을 API를 선택해주세요.</span>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
<!-- Content Area -->
<div class="app-management-content">
<div class="step2-wrap">
<!-- Title -->
<h2 class="s1-title">Webhook 신청</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<!-- Step 1: 기본 정보 입력 -->
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step1.png}" alt="기본 정보 입력" width="36" height="36">
</div>
<span class="s1-step-num">1단계</span>
<span class="s1-step-name">기본 정보 입력</span>
</div>
<!-- Progress Indicator -->
<div class="register-progress">
<div class="progress-steps">
<div class="progress-step-item">
<div class="step-icon-wrapper">
<div class="step-icon">
<img th:src="@{/img/apikey_step1.png}" alt="기본 정보" width="36" height="36">
</div>
</div>
<div class="step-label">기본 정보</div>
</div>
<div class="s1-step-line"></div>
<div class="step-dots">
<span></span><span></span><span></span>
</div>
<div class="progress-step-item active">
<div class="step-icon-wrapper">
<div class="step-icon">
<!-- Step 2 Active: API 선택 -->
<div class="s1-step s1-step--active">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
</div>
</div>
<div class="step-label">API 선택</div>
<span class="s1-step-num">2단계</span>
<span class="s1-step-name">API 선택</span>
</div>
<div class="step-dots">
<span></span><span></span><span></span>
</div>
<div class="s1-step-line"></div>
<div class="progress-step-item">
<div class="step-icon-wrapper">
<div class="step-icon">
<!-- Step 3: 신청 완료 -->
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step3.png}" alt="신청 완료" width="36" height="36">
</div>
</div>
<div class="step-label">신청 완료</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">신청 완료</span>
</div>
</div>
</div>
<p class="webhook-field-error" th:if="${error}" th:text="${error}"></p>
<!-- Error -->
<div th:if="${error}" class="s1-alert">
<span th:text="${error}"></span>
</div>
<!-- API 선택 공용 모듈 -->
<th:block th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${webhookRegistration.selectedApis}, '/webhook/register/step2', '/webhook/register/step2/save')}"/>
<!-- Form Actions -->
<div class="form-actions">
<button type="button" id="btnPrevStep" class="btn-submit btn-secondary">
<!-- Bottom Navigation Actions -->
<div class="s2-actions">
<button type="button" id="btnPrevStep" class="s2-btn-prev">
이전
</button>
<button type="submit" form="apiSelectorForm" class="btn-submit btn-primary">
<button type="submit" form="apiSelectorForm" class="s2-btn-save">
신청 완료
</button>
</div>
</div><!-- /step2-wrap -->
</div>
</div>
</div>
</th:block>
<!-- 플로팅 카트/모달: body 직속 렌더 (wrapper transform 영향 회피) -->
<section layout:fragment="pagePopups">
<th:block th:replace="~{fragment/api_selector :: apiSelectorPopups}"/>
</section>
</th:block>
</body>
</html>
@@ -1,74 +1,82 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/djbank_title_layout}">
<body>
<section layout:fragment="title">
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>Webhook 관리</h1>
</div>
</section>
</section>
<th:block layout:fragment="contentFragment">
<section class="apikey-register-container">
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<!-- Title Bar -->
<div class="common-title-bar">
<h2 class="common-title">Webhook 신청</h2>
<span class="common-subtitle">Webhook 등록이 완료되었습니다.</span>
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
<!-- Content Area -->
<div class="app-management-content">
<div class="step3-wrap">
<!-- Title -->
<h2 class="s1-title">Webhook 신청</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step1.png}" alt="기본 정보 입력" width="36" height="36">
</div>
<span class="s1-step-num">1단계</span>
<span class="s1-step-name">기본 정보 입력</span>
</div>
<!-- Progress Indicator -->
<div class="register-progress">
<div class="progress-steps">
<div class="progress-step-item">
<div class="step-icon-wrapper">
<div class="step-icon">
<img th:src="@{/img/apikey_step1.png}" alt="기본 정보" width="36" height="36">
</div>
</div>
<div class="step-label">기본 정보</div>
</div>
<div class="s1-step-line"></div>
<div class="step-dots">
<span></span><span></span><span></span>
</div>
<div class="progress-step-item">
<div class="step-icon-wrapper">
<div class="step-icon">
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
</div>
</div>
<div class="step-label">API 선택</div>
<span class="s1-step-num">2단계</span>
<span class="s1-step-name">API 선택</span>
</div>
<div class="step-dots">
<span></span><span></span><span></span>
</div>
<div class="s1-step-line"></div>
<div class="progress-step-item active">
<div class="step-icon-wrapper">
<div class="step-icon">
<!-- Step 3 Active: 신청 완료 -->
<div class="s1-step s1-step--active">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step3.png}" alt="신청 완료" width="36" height="36">
</div>
</div>
<div class="step-label">신청 완료</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">신청 완료</span>
</div>
</div>
</div>
<!-- Complete Card -->
<div class="s1-form-card">
<div class="webhook-complete">
<div class="complete-icon"></div>
<h3>Webhook이 등록되었습니다</h3>
<p class="field-help">Secret Key는 [Webhook 관리]에서 비밀번호 확인 후 조회할 수 있습니다.</p>
</div>
</div>
<div class="webhook-actions center">
<a class="btn-webhook-next" th:href="@{/webhook}">완료</a>
<!-- 액션 -->
<div class="s1-actions">
<a class="s1-btn-next" th:href="@{/webhook}">완료</a>
</div>
</div><!-- /step3-wrap -->
</div>
</div>
</section>
</th:block>
</div>
</th:block>
</body>
</html>
@@ -2,80 +2,76 @@
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<body>
<!--
API 선택 공용 모듈 (사이드바 카테고리 + 검색 + AJAX 카드그리드 + 플로팅 카트 + 선택목록 모달)
API 선택 공용 모듈 (figma s2 디자인: 카테고리 캐러셀 탭 + 카드그리드 + 플로팅 카트 + 선택목록 모달)
사용처: 앱(API Key) 신청/수정 step2, Webhook 신청/수정 step2
파라미터:
apiServices : List<ApiServiceDTO> — 사이드바 카테고리 (컨트롤러 model "apiServices")
apiServices : List<ApiServiceDTO> — 캐러셀 카테고리 (컨트롤러 model "apiServices")
selectedApis: List<String> — 세션에 보존된 기선택 API ID 목록
formAction : String — 제출(POST) 경로 (예: '/webhook/register/step2')
saveAction : String — "이전" 버튼 저장(POST) 경로 (예: '/webhook/register/step2/save')
호출 :
호출 (contentFragment 안):
<th:block th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${webhookRegistration.selectedApis}, '/webhook/register/step2', '/webhook/register/step2/save')}"/>
주의:
- 폼 id는 고정 "apiSelectorForm" — 제출 버튼은 form="apiSelectorForm" 으로 연결.
- "이전" 버튼은 호출 페이지에 id="btnPrevStep" 으로 두면 모듈 JS가 saveAction 으로 저장 후 이동.
- 추가 hidden 필드가 필요하면 호출 페이지에서 form="apiSelectorForm" 속성으로 주입.
- API 목록은 GET /apis/for_request (ROLE_API_KEY_REQUEST) AJAX 로 로드.
- 스타일은 기존 _apikey-register.scss (.register-form-container / .api-selection-card-grid / .modal-dialog .api-pill) 재사용.
호출 (pagePopups fragment 안 — 필수! 플로팅 카트/모달은 wrapper transform 영향을 피해 body 직속 렌더):
<section layout:fragment="pagePopups">
<th:block th:replace="~{fragment/api_selector :: apiSelectorPopups}"/>
</section>
계약:
- 폼 id 고정 "apiSelectorForm" — 제출 버튼은 form="apiSelectorForm" 으로 연결.
- "이전" 버튼은 호출 페이지에 id="btnPrevStep" — 모듈 JS가 data-save-action 경로로 저장 POST 후 step1 복귀.
- 추가 hidden 필드는 호출 페이지에서 form="apiSelectorForm" 속성으로 주입(예: apikey 수정 clientId).
- API 목록: GET /apis/for_request (ROLE_API_KEY_REQUEST) AJAX.
- 스타일: design s2-* (_apikey-register.scss step2 재작업분) 재사용.
-->
<th:block th:fragment="apiSelector(apiServices, selectedApis, formAction, saveAction)">
<!-- Form Content with Sidebar Layout -->
<div class="register-form-container with-sidebar">
<!-- Sidebar Navigation -->
<aside class="apikey-register-sidebar" id="apiSidebar">
<nav class="apikey-sidebar-nav">
<div class="sidebar-title">API 서비스 목록</div>
<!-- All APIs -->
<div class="menu-section">
<div class="menu-title active" data-group="">
<span class="menu-text">전체</span>
<span class="api-count"></span>
</div>
</div>
<!-- Service Categories -->
<div class="menu-section" th:each="service : ${apiServices}">
<div class="menu-title" th:data-group="${service.id}">
<span class="menu-text" th:text="${service.groupName}">서비스명</span>
<span class="api-count"></span>
</div>
</div>
</nav>
</aside>
<!-- Mobile Menu Toggle -->
<button class="apikey-mobile-toggle" id="mobileToggle" aria-label="메뉴 열기" type="button">
<!-- Category Carousel Tab Container -->
<div class="s2-category-carousel-container">
<button type="button" class="s2-carousel-btn s2-carousel-btn--prev" id="btnPrevCategory" aria-label="이전 카테고리">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<div class="s2-category-list-wrapper" id="categoryListWrapper">
<div class="s2-category-list" id="categoryList">
<button type="button" class="s2-category-tab active" data-group="">
전체
</button>
<button type="button" class="s2-category-tab" th:each="service : ${apiServices}" th:data-group="${service.id}" th:text="${service.groupName}">
서비스명
</button>
</div>
</div>
<button type="button" class="s2-carousel-btn s2-carousel-btn--next" id="btnNextCategory" aria-label="다음 카테고리">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div>
<!-- Mobile Overlay -->
<div class="apikey-mobile-overlay" id="mobileOverlay"></div>
<!-- Main Content -->
<main class="apikey-register-content">
<form id="apiSelectorForm" method="post" th:action="@{${formAction}}" class="register-form"
<!-- Main Content Area -->
<div class="s2-selection-container">
<main class="s2-content-area">
<form id="apiSelectorForm" method="post" th:action="@{${formAction}}" class="s2-form"
th:attr="data-save-action=@{${saveAction}}">
<!-- Search Box with Select All -->
<div class="api-filter-header">
<div class="select-all-wrapper" id="selectAllWrapper" style="display: none;">
<label class="select-all-label">
<input type="checkbox" id="selectAllCheckbox" class="select-all-checkbox visually-hidden">
<span class="custom-checkbox"></span>
<!-- Header filter: Search and Select All -->
<div class="s2-filter-header">
<div class="s2-select-all" id="selectAllWrapper" style="display: none;">
<label class="s2-select-all-label">
<input type="checkbox" id="selectAllCheckbox" class="visually-hidden">
<span class="s2-checkbox-custom"></span>
<span id="selectAllText">전체 선택</span>
</label>
</div>
<div class="api-search-box">
<input type="text"
id="apiSearch"
class="search-input"
placeholder="API 검색...">
<div class="s2-result-count">
<strong id="apiResultCount">0</strong>
</div>
<div class="s2-search-box">
<input type="text" id="apiSearch" class="s2-search-input" placeholder="API 검색...">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.23047 0C14.3285 0 18.4618 4.0539 18.4619 9.05469C18.4619 11.1083 17.7634 13.0014 16.5889 14.5205C16.5913 14.5229 16.5942 14.5249 16.5967 14.5273L19.5498 17.4238C20.1502 18.0131 20.1501 18.9683 19.5498 19.5576C18.949 20.147 17.9748 20.147 17.374 19.5576L14.4209 16.6621C14.3966 16.6383 14.3749 16.6119 14.3525 16.5869C12.8868 17.5478 11.1257 18.1094 9.23047 18.1094C4.13258 18.1092 0 14.0554 0 9.05469C0.000110268 4.05404 4.13265 0.000222701 9.23047 0ZM9.23047 3.01855C5.83201 3.01878 3.07726 5.721 3.07715 9.05469C3.07715 12.3885 5.83194 15.0916 9.23047 15.0918C12.6292 15.0918 15.3848 12.3886 15.3848 9.05469C15.3847 5.72086 12.6291 3.01855 9.23047 3.01855Z" fill="#515961"/>
</svg>
@@ -83,60 +79,62 @@
</div>
<!-- API Cards Grid -->
<div class="api-selection-card-grid" id="apiCardGrid">
<div class="loading-state" id="loadingState" style="grid-column: 1 / -1;">
<div class="loading-spinner"></div>
<div class="s2-cards-grid" id="apiCardGrid">
<!-- Loading state -->
<div class="s2-loading" id="loadingState" style="grid-column: 1 / -1;">
<div class="s2-spinner"></div>
<p>API 목록을 불러오는 중...</p>
</div>
<div class="empty-api-state" id="emptyState" style="display: none; grid-column: 1 / -1;">
<div class="empty-icon">📦</div>
<!-- Empty state -->
<div class="s2-empty" id="emptyState" style="display: none; grid-column: 1 / -1;">
<div class="s2-empty-icon">📦</div>
<h3>API를 선택해주세요</h3>
<p>왼쪽 메뉴에서 서비스를 선택하면 해당 API 목록이 표시됩니다.</p>
<p>상단 카테고리에서 서비스를 선택하면 해당 API 목록이 표시됩니다.</p>
</div>
</div>
</form>
</main>
</div>
<!-- Floating Cart Button -->
<button type="button" class="floating-cart-btn" id="floatingCartBtn" style="display: none;">
<span class="cart-label">선택된 API</span>
<span class="cart-badge" id="cartBadge">
<span class="cart-count">0</span>
<span class="cart-unit"></span>
</span>
</button>
<!-- Selected APIs Modal -->
<div class="modal" id="selectedApisModal" style="display: none;">
<div class="modal-backdrop" id="modalOverlay"></div>
<div class="modal-dialog">
<div class="modal-header">
<h3 class="modal-title">선택된 API 목록</h3>
<button type="button" class="modal-close" id="modalCloseBtn">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="modal-body">
<div class="selected-apis-list" id="modalSelectedList"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="modalCancelBtn">닫기</button>
</div>
</div>
</div>
<!-- 모듈 초기 데이터 + 스크립트 -->
<script th:inline="javascript">
window.API_SELECTOR_SELECTED = /*[[${selectedApis}]]*/ [];
window.API_SELECTOR_LIST_URL = /*[[@{/apis/for_request}]]*/ '/apis/for_request';
</script>
<script th:src="@{/js/api-selector.js}"></script>
</th:block>
<!-- 플로팅 카트 + 선택목록 모달 (pagePopups 슬롯에서 호출 — body 직속 렌더) -->
<th:block th:fragment="apiSelectorPopups">
<!-- Floating Cart Button -->
<button type="button" class="s2-floating-cart" id="floatingCartBtn" style="display: none;">
<span class="s2-cart-label">선택된 API</span>
<span class="s2-cart-badge" id="cartBadge">
<span class="s2-cart-count">0</span>
<span class="s2-cart-unit"></span>
</span>
</button>
<!-- Selected APIs Modal -->
<div class="s2-modal" id="selectedApisModal" style="display: none;">
<div class="s2-modal-backdrop" id="modalOverlay"></div>
<div class="s2-modal-dialog">
<div class="s2-modal-header">
<h3 class="s2-modal-title">선택된 API 목록</h3>
<button type="button" class="s2-modal-close" id="modalCloseBtn"></button>
</div>
<div class="s2-modal-body">
<div class="s2-selected-list" id="modalSelectedList">
<!-- Dynamically populated -->
</div>
</div>
<div class="s2-modal-footer">
<button type="button" class="s2-btn-close-modal" id="modalCancelBtn">닫기</button>
</div>
</div>
</div>
</th:block>
</body>
</html>
@@ -93,8 +93,8 @@
</li>
<li sec:authorize="hasRole('ROLE_APP')">
<a th:href="@{/myapikey}"><i class="fas fa-key"></i>인증 키 관리</a>
<a th:href="@{/statistics/api}"><i class="fas fa-chart-bar"></i>이용 통계</a>
<a th:href="@{/webhook}"><i class="fas fa-bell"></i>Webhook 관리</a>
<a th:href="@{/statistics/api}"><i class="fas fa-chart-bar"></i>이용 통계</a>
</li>
<li><a th:href="@{/mypage}"><i class="fas fa-user-circle"></i>내 정보 관리</a></li>
<li><a th:href="@{/change_password}"><i class="fas fa-lock"></i>비밀번호 변경</a></li>
@@ -250,8 +250,8 @@
<ul class="drawer-submenu">
<li sec:authorize="hasRole('ROLE_USER_MANAGER')"><a th:href="@{/users}">이용자 관리</a></li>
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/myapikey}">인증 키 관리</a></li>
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/statistics/api}">이용 통계</a></li>
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/webhook}">Webhook 관리</a></li>
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/statistics/api}">이용 통계</a></li>
<li><a th:href="@{/mypage}">내 정보 관리</a></li>
<li><a th:href="@{/change_password}">비밀번호 변경</a></li>
</ul>
@@ -5,7 +5,7 @@
<aside class="service-sidebar" th:fragment="sidebar(activeMenu)">
<nav class="service-nav">
<!-- 서비스 소개 그룹 (Service) -->
<th:block th:if="${activeMenu == 'intro' or activeMenu == 'guide' or activeMenu == 'oauth2'}">
<th:block th:if="${activeMenu == 'intro' or activeMenu == 'guide' or activeMenu == 'oauth2' or activeMenu == 'webhookGuide'}">
<a th:href="@{/service/intro}"
th:classappend="${activeMenu == 'intro'} ? 'service-nav__item--active' : ''"
class="service-nav__item">API 포탈 소개</a>
@@ -15,6 +15,9 @@
<a th:href="@{/service/oauth2-guide}"
th:classappend="${activeMenu == 'oauth2'} ? 'service-nav__item--active' : ''"
class="service-nav__item">OAuth2 개발가이드</a>
<a th:href="@{/service/webhook-dev-guide}"
th:classappend="${activeMenu == 'webhookGuide'} ? 'service-nav__item--active' : ''"
class="service-nav__item">웹훅 개발가이드</a>
</th:block>
<!-- 고객지원 그룹 (Customer Support) -->
@@ -34,7 +37,7 @@
</th:block>
<!-- 마이페이지 그룹 (My Page) -->
<th:block th:if="${activeMenu == 'users' or activeMenu == 'apiKey' or activeMenu == 'statistics' or activeMenu == 'profile' or activeMenu == 'password'}">
<th:block th:if="${activeMenu == 'users' or activeMenu == 'apiKey' or activeMenu == 'statistics' or activeMenu == 'webhook' or activeMenu == 'profile' or activeMenu == 'password'}">
<!-- Profile Section -->
<div class="service-sidebar__profile">
<div class="avatar">
@@ -56,6 +59,11 @@
th:classappend="${activeMenu == 'apiKey'} ? 'service-nav__item--active' : ''"
class="service-nav__item">인증 키 관리</a>
<a th:href="@{/webhook}"
th:classappend="${activeMenu == 'webhook'} ? 'service-nav__item--active' : ''"
class="service-nav__item"
sec:authorize="hasRole('ROLE_APP')">Webhook 관리</a>
<a th:href="@{/statistics/api}"
th:classappend="${activeMenu == 'statistics'} ? 'service-nav__item--active' : ''"
class="service-nav__item"