diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index 6bc3063b..3f7d1f9d 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/CHANGELOG.md @@ -10,6 +10,7 @@ - 사이트맵에 담을 게시판·게시글 수의 안전 상한을 지원합니다. 상한을 설정하면 게시판 목록·게시판·게시글 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다. - 게시글·게시판을 공개/비공개로 바꾸거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 게시글이 많은 사이트에서도 빠르게 최신 상태가 유지됩니다. +- 게시판 알림 설정 화면에서도 코어와 동일하게 카카오 알림톡 템플릿을 연결할 수 있습니다(비즈뿌리오 플러그인 설치 시). ### Changed diff --git a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/notificationRowFooterExtensionPoint.test.tsx b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/notificationRowFooterExtensionPoint.test.tsx new file mode 100644 index 00000000..fcd56397 --- /dev/null +++ b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/notificationRowFooterExtensionPoint.test.tsx @@ -0,0 +1,78 @@ +// e2e:allow 레이아웃 트리 위치 검증(정적 구조 회귀 가드)이라 vitest 로 충분. 실제 화면 렌더는 +// Chrome MCP 로 확인·수정까지 완료(2026-07-28). 알림설정 화면 Playwright 인프라 부재 (#28) +/** + * 게시판 알림 정의 행 하단 확장 슬롯(notification_definition_row_footer) 위치 회귀 가드 (#28) + * + * @description + * 회귀 시나리오: extension_point 를 행 정보 컬럼(`flex-1 min-w-0`) 밖에 형제로 잘못 넣으면, + * 비즈뿌리오 플러그인이 주입하는 [연결/변경] 버튼이 정보 컬럼과 토글·편집 버튼 컬럼 + * (`flex-center gap-3 flex-shrink-0`) 사이의 3번째 flex 아이템이 되어 옆으로 붙어 보인다 + * (변수 뱃지 아래 새 줄이 아니라 오른쪽 버튼 옆으로 밀림). + * + * extension_point 는 반드시 정보 컬럼의 children 배열 마지막 원소(variables 뱃지의 형제)여야 + * 하며, 토글/편집 버튼을 담는 형제 Div 의 children 에는 나타나지 않아야 한다. + * + * @vitest-environment node + */ + +import { describe, it, expect } from 'vitest'; + +import tab from '../../../layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json'; + +type AnyNode = Record & { + id?: string; + type?: string; + name?: string; + props?: { className?: string }; + children?: AnyNode[]; +}; + +function findAll(node: AnyNode | undefined, pred: (n: AnyNode) => boolean, results: AnyNode[] = []): AnyNode[] { + if (!node) return results; + if (pred(node)) results.push(node); + if (Array.isArray(node.children)) { + for (const c of node.children) findAll(c, pred, results); + } + return results; +} + +describe('게시판 알림 행 footer extension_point 위치 (#28)', () => { + it('extension_point 는 정보 컬럼(flex-1 min-w-0)의 children 이다', () => { + const infoColumns = findAll(tab as AnyNode, (n) => n.props?.className === 'flex-1 min-w-0'); + expect(infoColumns.length).toBeGreaterThan(0); + + const infoColumnHasEp = infoColumns.some((col) => + (col.children ?? []).some((c) => c.type === 'extension_point' && c.name === 'notification_definition_row_footer'), + ); + expect(infoColumnHasEp).toBe(true); + }); + + it('extension_point 는 토글·편집 버튼 컬럼(flex-center gap-3 flex-shrink-0)의 children 이 아니다', () => { + const buttonColumns = findAll( + tab as AnyNode, + (n) => n.props?.className === 'flex-center gap-3 flex-shrink-0', + ); + expect(buttonColumns.length).toBeGreaterThan(0); + + const buttonColumnHasEp = buttonColumns.some((col) => + (col.children ?? []).some((c) => c.type === 'extension_point'), + ); + expect(buttonColumnHasEp).toBe(false); + }); + + it('extension_point 가 정보 컬럼과 버튼 컬럼의 공통 부모(행 wrapper)에 직접 걸려 있지 않다', () => { + const rowWrappers = findAll( + tab as AnyNode, + (n) => + Array.isArray(n.children) && + n.children.some((c) => c.props?.className === 'flex-1 min-w-0') && + n.children.some((c) => c.props?.className === 'flex-center gap-3 flex-shrink-0'), + ); + expect(rowWrappers.length).toBeGreaterThan(0); + + for (const wrapper of rowWrappers) { + const directEp = (wrapper.children ?? []).some((c) => c.type === 'extension_point'); + expect(directEp, 'extension_point 가 행 wrapper 의 3번째 flex 형제가 되면 옆으로 붙는 회귀').toBe(false); + } + }); +}); \ No newline at end of file diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json index 9f800b06..f15da32b 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json @@ -196,26 +196,16 @@ ] }, { - "id": "board_notif_list_info", + "id": "board_notif_channel_content", "type": "basic", "name": "Div", - "props": { - "className": "flex-between py-3" - }, "children": [ { - "type": "basic", - "name": "Span", - "props": { - "className": "text-label-subtle" - }, - "text": "$t:sirsoft-board.admin.settings.notification_definitions.total_count|count={{boardNotificationDefinitions?.data?.pagination?.total ?? 0}}" - }, - { + "id": "board_notif_list_info", "type": "basic", "name": "Div", "props": { - "className": "flex-center gap-2" + "className": "flex-between py-3" }, "children": [ { @@ -224,55 +214,70 @@ "props": { "className": "text-label-subtle" }, - "text": "$t:sirsoft-board.admin.settings.notification_definitions.per_page_label" + "text": "$t:sirsoft-board.admin.settings.notification_definitions.total_count|count={{boardNotificationDefinitions?.data?.pagination?.total ?? 0}}" }, { - "type": "composite", - "name": "Select", + "type": "basic", + "name": "Div", "props": { - "name": "notifPerPage", - "className": "w-20 px-3 py-1.5 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm", - "value": "{{String(query.per_page || 20)}}", - "options": [ - { - "value": "10", - "label": "10" - }, - { - "value": "20", - "label": "20" - }, - { - "value": "50", - "label": "50" - } - ] + "className": "flex-center gap-2" }, - "actions": [ + "children": [ { - "type": "change", - "handler": "navigate", - "params": { - "path": "/admin/boards/settings", - "replace": true, - "mergeQuery": true, - "query": { - "per_page": "{{$event.target.value}}", - "page": 1 + "type": "basic", + "name": "Span", + "props": { + "className": "text-label-subtle" + }, + "text": "$t:sirsoft-board.admin.settings.notification_definitions.per_page_label" + }, + { + "type": "composite", + "name": "Select", + "props": { + "name": "notifPerPage", + "className": "w-20 px-3 py-1.5 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm", + "value": "{{String(query.per_page || 20)}}", + "options": [ + { + "value": "10", + "label": "10" + }, + { + "value": "20", + "label": "20" + }, + { + "value": "50", + "label": "50" + } + ] + }, + "actions": [ + { + "type": "change", + "handler": "navigate", + "params": { + "path": "/admin/boards/settings", + "replace": true, + "mergeQuery": true, + "query": { + "per_page": "{{$event.target.value}}", + "page": 1 + } + } } - } + ] } ] } ] - } - ] - }, - { - "id": "board_notif_definitions_list", - "type": "basic", - "name": "Div", - "children": [ + }, + { + "id": "board_notif_definitions_list", + "type": "basic", + "name": "Div", + "children": [ { "type": "basic", "name": "Div", @@ -493,6 +498,15 @@ ] } ] + }, + { + "comment": "알림 정의 행 하단 확장 슬롯(범용). 각 소유 확장이 알림별 부가 UI(예: 채널 연동)를 이 자리에 주입한다. 특정 확장을 알지 못하며, 확장 미설치 시 빈 자리로 남는다. def·현재 채널을 extensionPointProps 로 전달한다.", + "type": "extension_point", + "name": "notification_definition_row_footer", + "props": { + "definition": "{{def}}", + "activeChannel": "{{_local.activeNotificationChannel ?? query.channel ?? 'mail'}}" + } } ] }, @@ -657,70 +671,72 @@ } ] }, - { - "id": "board_notif_empty", - "type": "basic", - "name": "Div", - "if": "{{(boardNotificationDefinitions?.data?.data ?? []).length === 0}}", - "props": { - "className": "admin-card" - }, - "children": [ { + "id": "board_notif_empty", "type": "basic", "name": "Div", + "if": "{{(boardNotificationDefinitions?.data?.data ?? []).length === 0}}", "props": { - "className": "row-stack" + "className": "admin-card" }, "children": [ { "type": "basic", - "name": "Icon", + "name": "Div", "props": { - "name": "fa-solid fa-envelope", - "className": "text-4xl text-gray-300 dark:text-gray-600 mb-3" - } - }, - { - "type": "basic", - "name": "P", - "props": { - "className": "text-label-subtle" + "className": "row-stack" }, - "text": "$t:sirsoft-board.admin.settings.notification_definitions.empty_message" + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fa-solid fa-envelope", + "className": "text-4xl text-gray-300 dark:text-gray-600 mb-3" + } + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-label-subtle" + }, + "text": "$t:sirsoft-board.admin.settings.notification_definitions.empty_message" + } + ] } ] - } - ] - }, - { - "id": "board_notif_pagination", - "type": "basic", - "name": "Div", - "props": { - "className": "flex justify-center" - }, - "children": [ + }, { - "type": "composite", - "name": "Pagination", + "id": "board_notif_pagination", + "type": "basic", + "name": "Div", "props": { - "currentPage": "{{boardNotificationDefinitions?.data?.pagination?.current_page || 1}}", - "totalPages": "{{boardNotificationDefinitions?.data?.pagination?.last_page || 1}}" + "className": "flex justify-center" }, - "actions": [ + "children": [ { - "event": "onPageChange", - "type": "action", - "handler": "navigate", - "params": { - "path": "/admin/boards/settings", - "replace": true, - "mergeQuery": true, - "query": { - "page": "{{$args[0]}}" + "type": "composite", + "name": "Pagination", + "props": { + "currentPage": "{{boardNotificationDefinitions?.data?.pagination?.current_page || 1}}", + "totalPages": "{{boardNotificationDefinitions?.data?.pagination?.last_page || 1}}" + }, + "actions": [ + { + "event": "onPageChange", + "type": "action", + "handler": "navigate", + "params": { + "path": "/admin/boards/settings", + "replace": true, + "mergeQuery": true, + "query": { + "page": "{{$args[0]}}" + } + } } - } + ] } ] } diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index 73fe64b1..2e0b7590 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -10,6 +10,7 @@ - 사이트맵에 담을 상품·카테고리 수의 안전 상한을 지원합니다. 상한을 설정하면 상품 목록·카테고리·상품 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다. - 상품·카테고리를 전시/숨김으로 바꾸거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 상품이 많은 쇼핑몰에서도 빠르게 최신 상태가 유지됩니다. +- 이커머스 알림 설정 화면에서도 코어와 동일하게 카카오 알림톡 템플릿을 연결할 수 있습니다(비즈뿌리오 플러그인 설치 시). ### Changed diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/notificationRowFooterExtensionPoint.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/notificationRowFooterExtensionPoint.test.tsx new file mode 100644 index 00000000..caf7d366 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/notificationRowFooterExtensionPoint.test.tsx @@ -0,0 +1,78 @@ +// e2e:allow 레이아웃 트리 위치 검증(정적 구조 회귀 가드)이라 vitest 로 충분. 실제 화면 렌더는 +// Chrome MCP 로 확인·수정까지 완료(2026-07-28). 알림설정 화면 Playwright 인프라 부재 (#28) +/** + * 이커머스 알림 정의 행 하단 확장 슬롯(notification_definition_row_footer) 위치 회귀 가드 (#28) + * + * @description + * 회귀 시나리오: extension_point 를 행 정보 컬럼(`flex-1 min-w-0`) 밖에 형제로 잘못 넣으면, + * 비즈뿌리오 플러그인이 주입하는 [연결/변경] 버튼이 정보 컬럼과 토글·편집 버튼 컬럼 + * (`flex-center gap-3 flex-shrink-0`) 사이의 3번째 flex 아이템이 되어 옆으로 붙어 보인다 + * (변수 뱃지 아래 새 줄이 아니라 오른쪽 버튼 옆으로 밀림). + * + * extension_point 는 반드시 정보 컬럼의 children 배열 마지막 원소(variables 뱃지의 형제)여야 + * 하며, 토글/편집 버튼을 담는 형제 Div 의 children 에는 나타나지 않아야 한다. + * + * @vitest-environment node + */ + +import { describe, it, expect } from 'vitest'; + +import tab from '../../../layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json'; + +type AnyNode = Record & { + id?: string; + type?: string; + name?: string; + props?: { className?: string }; + children?: AnyNode[]; +}; + +function findAll(node: AnyNode | undefined, pred: (n: AnyNode) => boolean, results: AnyNode[] = []): AnyNode[] { + if (!node) return results; + if (pred(node)) results.push(node); + if (Array.isArray(node.children)) { + for (const c of node.children) findAll(c, pred, results); + } + return results; +} + +describe('이커머스 알림 행 footer extension_point 위치 (#28)', () => { + it('extension_point 는 정보 컬럼(flex-1 min-w-0)의 children 이다', () => { + const infoColumns = findAll(tab as AnyNode, (n) => n.props?.className === 'flex-1 min-w-0'); + expect(infoColumns.length).toBeGreaterThan(0); + + const infoColumnHasEp = infoColumns.some((col) => + (col.children ?? []).some((c) => c.type === 'extension_point' && c.name === 'notification_definition_row_footer'), + ); + expect(infoColumnHasEp).toBe(true); + }); + + it('extension_point 는 토글·편집 버튼 컬럼(flex-center gap-3 flex-shrink-0)의 children 이 아니다', () => { + const buttonColumns = findAll( + tab as AnyNode, + (n) => n.props?.className === 'flex-center gap-3 flex-shrink-0', + ); + expect(buttonColumns.length).toBeGreaterThan(0); + + const buttonColumnHasEp = buttonColumns.some((col) => + (col.children ?? []).some((c) => c.type === 'extension_point'), + ); + expect(buttonColumnHasEp).toBe(false); + }); + + it('extension_point 가 정보 컬럼과 버튼 컬럼의 공통 부모(행 wrapper)에 직접 걸려 있지 않다', () => { + const rowWrappers = findAll( + tab as AnyNode, + (n) => + Array.isArray(n.children) && + n.children.some((c) => c.props?.className === 'flex-1 min-w-0') && + n.children.some((c) => c.props?.className === 'flex-center gap-3 flex-shrink-0'), + ); + expect(rowWrappers.length).toBeGreaterThan(0); + + for (const wrapper of rowWrappers) { + const directEp = (wrapper.children ?? []).some((c) => c.type === 'extension_point'); + expect(directEp, 'extension_point 가 행 wrapper 의 3번째 flex 형제가 되면 옆으로 붙는 회귀').toBe(false); + } + }); +}); \ No newline at end of file diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json index 20ee5d5e..d14ea83a 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json @@ -498,6 +498,15 @@ ] } ] + }, + { + "comment": "알림 정의 행 하단 확장 슬롯(범용). 각 소유 확장이 알림별 부가 UI(예: 채널 연동)를 이 자리에 주입한다. 특정 확장을 알지 못하며, 확장 미설치 시 빈 자리로 남는다. def·현재 채널을 extensionPointProps 로 전달한다.", + "type": "extension_point", + "name": "notification_definition_row_footer", + "props": { + "definition": "{{def}}", + "activeChannel": "{{_local.activeNotificationChannel ?? query.channel ?? 'mail'}}" + } } ] }, diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md index 4653b67c..f18b6e25 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -9,6 +9,6 @@ ### Added - 비즈뿌리오 연동 환경설정 화면과 문자(SMS/LMS)·카카오 알림톡 발송 채널을 추가했습니다. 회원가입·주문 등 코어 알림에 자동 연결되며, 검수/운영 환경을 구분해 운영합니다. -- 설정 화면에서 카카오 알림톡 템플릿을 조회해 알림에 연결하고 실제로 발송할 수 있습니다. 템플릿 등록·검수는 비즈뿌리오 콘솔에서 진행합니다. +- 설정 화면에서 카카오 알림톡 템플릿을 조회해 알림에 연결하고 실제로 발송할 수 있습니다. 템플릿 등록·검수는 비즈뿌리오 콘솔에서 진행합니다. 게시판·이커머스 알림 설정 화면에서도 코어와 동일하게 연결할 수 있습니다. - 비즈뿌리오 webhook 으로 발송 결과를 수신해 "알림 발송 이력" 화면에 성공/실패와 사유를 기록하며, 지갑 잔액 부족 등으로 발송이 막히면 관리자에게 알립니다. - 플러그인을 삭제하면 추가했던 발송 채널이 함께 정리되고, 재설치 시 자동 복원됩니다. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_board.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_board.json new file mode 100644 index 00000000..a14f9948 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_board.json @@ -0,0 +1,483 @@ +{ + "target_layout": "admin_board_settings", + "comment": "게시판 알림 설정 알림톡 탭에 비즈뿌리오 연동 UI 를 얹는다(notification_tab_core.json 과 동일 패턴, target_layout/target_id/모달 id 만 게시판 전용). 코어 목록·편집 모달·저장 버튼은 건드리지 않는다(무오염). ① 탭 상단 상태 배너(문제 있을 때만) ② 각 알림 행 하단(notification_definition_row_footer 확장 슬롯, notification_row_footer.json 이 채널 무관 전역 매칭)에 연결 상태 줄 + [연결/변경] 버튼 ③ [연결] 클릭 시 이 모듈 전용 연결 모달에서 승인 템플릿 선택·SMS 대체 설정 후 [저장]. 저장은 우리 API(notification_type 은 게시판 알림 type 값 그대로 전달, 백엔드는 확장 구분 없는 문자열 키).", + "data_sources": [ + { + "id": "bizppurioBindings", + "comment": "행 연결 상태 표시 + 모달 프리필용. auto_fetch:false — 설정 페이지 전 탭 자동 호출 방지. 알림톡 탭 진입 시 init_actions 로 조회한다.", + "type": "api", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings", + "method": "GET", + "auto_fetch": false, + "auth_required": true, + "fallback": { + "data": { + "bindings": {} + } + } + }, + { + "id": "bizppurioApprovedTemplates", + "comment": "연결 모달 드롭다운 옵션(카카오 관리 API 위임). auto_fetch:false — 자격증명 미설정 시 카카오 API 422 가 전 탭에서 발화하던 문제 차단. 연결 모달 열 때만 조회. errorHandling.default:suppress — 자격증명 미설정 시 422(카카오 API 요청 실패)를 조용히 무시한다(fallback 사용). 조회 실패와 '진짜 0건'을 화면에서 구분하기 위해 fallback.data.load_failed:true 마커를 싣는다.", + "type": "api", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates", + "method": "GET", + "auto_fetch": false, + "auth_required": true, + "errorHandling": { + "default": { + "handler": "suppress" + } + }, + "fallback": { + "data": { + "templates": [], + "load_failed": true + } + } + } + ], + "injections": [ + { + "target_id": "board_notif_channel_content", + "position": "prepend_child", + "comment": "알림톡·SMS 탭 상단 상태 배너. 문제 있을 때만 노출(정상=배너 없음). readiness 미충족 🔴 설정하기 / is_test_mode 🟡 테스트 모드.", + "components": [ + { + "id": "bizppurio_board_status_banner", + "type": "basic", + "name": "Div", + "if": "{{['sms','alimtalk'].includes(_local.activeNotificationChannel ?? query.channel ?? '') && ((availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.readiness?.ready === false || (availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.is_test_mode === true)}}", + "props": { + "className": "mt-4 mb-3 space-y-2" + }, + "children": [ + { + "id": "bizppurio_board_banner_not_ready", + "type": "basic", + "name": "Div", + "if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.readiness?.ready === false}}", + "props": { + "className": "flex-between gap-3 p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-circle-exclamation", + "size": "sm", + "className": "text-red-600 dark:text-red-400" + } + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-sm text-red-800 dark:text-red-200" + }, + "text": "$t:sirsoft-message_bizppurio.banner.not_ready" + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-3 py-1.5 rounded-md bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 whitespace-nowrap" + }, + "text": "$t:sirsoft-message_bizppurio.banner.setup_action", + "actions": [ + { + "type": "click", + "handler": "navigate", + "params": { + "path": "/admin/plugins/sirsoft-message_bizppurio/settings" + } + } + ] + } + ] + }, + { + "id": "bizppurio_board_banner_test_mode", + "type": "basic", + "name": "Div", + "if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.is_test_mode === true}}", + "props": { + "className": "flex-center gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-flask", + "size": "sm", + "className": "text-amber-600 dark:text-amber-400" + } + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-sm text-amber-800 dark:text-amber-200" + }, + "text": "$t:sirsoft-message_bizppurio.banner.test_mode" + } + ] + } + ] + }, + { + "id": "bizppurio_board_alimtalk_guide", + "comment": "알림톡 탭 상시 안내 박스 — 이 화면에서 무엇을 하는지(각 알림에 승인 템플릿을 연결). alimtalk 탭일 때만. 배너(빨강/노랑)와 구분되게 연한 파랑 정보 박스.", + "type": "basic", + "name": "Div", + "if": "{{(_local.activeNotificationChannel ?? query.channel) === 'alimtalk'}}", + "props": { + "className": "flex items-center gap-2 mt-3 mb-1 p-3 rounded-lg bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-circle-info", + "size": "sm", + "className": "text-blue-500 dark:text-blue-400 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-sm text-blue-800 dark:text-blue-200" + }, + "text": "$t:sirsoft-message_bizppurio.binding.list_guide" + } + ] + } + ] + } + ], + "modals": [ + { + "id": "modal_bizppurio_binding", + "comment": "알림톡 연결 전용 모달(우리 소유). 코어 편집 모달과 분리. 승인 템플릿 드롭다운·SMS 대체 토글·변수 안내 + [취소][저장]. 저장은 우리 API store. 모달 컨텍스트 분리 대응으로 값은 _global.bizppurio_binding_modal 에 담는다.", + "type": "composite", + "name": "Modal", + "props": { + "title": "$t:sirsoft-message_bizppurio.binding.modal_title|name={{_global.bizppurio_binding_modal?.notification_name ?? ''}}", + "size": "md", + "closeOnOverlayClick": false + }, + "children": [ + { + "id": "bizppurio_board_binding_modal_body", + "type": "basic", + "name": "Div", + "blur_until_loaded": "{{_global.bizppurio_binding_modal?.isSaving}}", + "props": { + "className": "space-y-4" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex items-center gap-2 p-3 rounded-lg bg-teal-50 dark:bg-teal-950 border border-teal-200 dark:border-teal-800" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-circle-info", + "size": "sm", + "className": "text-teal-600 dark:text-teal-400 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-xs text-teal-700 dark:text-teal-300" + }, + "text": "$t:sirsoft-message_bizppurio.binding.section_hint" + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.binding.connected_template" + }, + { + "type": "composite", + "name": "Select", + "props": { + "value": "{{_global.bizppurio_binding_modal?.template_code ?? ''}}", + "className": "w-full text-sm", + "options": "{{[{ value: '', label: $t('sirsoft-message_bizppurio.binding.none') }, ...(bizppurioApprovedTemplates?.data?.templates ?? []).map(t => ({ value: t.template_code, label: t.template_name + ' (' + t.template_code + ')' }))]}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bizppurio_binding_modal.template_code": "{{$event.target.value}}", + "bizppurio_binding_modal.template_name": "{{((bizppurioApprovedTemplates?.data?.templates ?? []).find(t => t.template_code === $event.target.value)?.template_name) ?? ''}}", + "bizppurio_binding_modal.fallback_sms": "{{$event.target.value === '' ? false : (_global.bizppurio_binding_modal?.fallback_sms ?? false)}}" + } + } + ] + }, + { + "comment": "드롭다운이 비었고 조회 실패(fallback 마커)일 때 — 키 오류 등을 '0건'과 구분해 설정 확인을 안내.", + "type": "basic", + "name": "P", + "if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0 && bizppurioApprovedTemplates?.data?.load_failed === true}}", + "props": { + "className": "text-xs text-red-600 dark:text-red-400 mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.binding.templates_load_failed" + }, + { + "comment": "드롭다운이 비었고 조회는 정상(load_failed 없음)일 때 — 실제 승인 템플릿 0건.", + "type": "basic", + "name": "P", + "if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0 && !(bizppurioApprovedTemplates?.data?.load_failed)}}", + "props": { + "className": "text-xs text-amber-600 dark:text-amber-400 mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.binding.no_approved_templates" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center gap-2" + }, + "children": [ + { + "type": "composite", + "name": "Toggle", + "props": { + "checked": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}", + "disabled": "{{(_global.bizppurio_binding_modal?.template_code ?? '') === ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bizppurio_binding_modal.fallback_sms": "{{!(_global.bizppurio_binding_modal?.fallback_sms ?? false)}}" + } + } + ] + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-secondary text-xs" + }, + "text": "$t:sirsoft-message_bizppurio.binding.fallback_sms" + } + ] + }, + { + "id": "bizppurio_board_binding_fallback_hint", + "comment": "SMS 대체발송 시 무엇이 발송되는지 안내 — 이 알림의 본문이 문자로 발송됨.", + "type": "basic", + "name": "P", + "props": { + "className": "text-xs text-gray-500 dark:text-gray-400 pl-11 -mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.binding.fallback_hint" + }, + { + "id": "bizppurio_board_binding_variables", + "type": "basic", + "name": "Div", + "if": "{{(_global.bizppurio_binding_modal?.variables ?? []).length > 0}}", + "props": { + "className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3" + }, + "children": [ + { + "type": "basic", + "name": "P", + "props": { + "className": "text-xs text-gray-600 dark:text-gray-400 mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.binding.variables_hint" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex flex-wrap gap-1" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "iteration": { + "source": "{{_global.bizppurio_binding_modal?.variables ?? []}}", + "item_var": "v" + }, + "props": { + "className": "text-xs font-mono px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300" + }, + "text": "{{'#{' + (v.key ?? '') + '}'}}" + } + ] + } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", + "disabled": "{{_global.bizppurio_binding_modal?.isSaving ?? false}}" + }, + "text": "$t:common.cancel", + "actions": [ + { + "type": "click", + "handler": "closeModal", + "target": "modal_bizppurio_binding" + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50", + "disabled": "{{_global.bizppurio_binding_modal?.isSaving ?? false}}" + }, + "text": "$t:common.save", + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bizppurio_binding_modal.isSaving": true + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings", + "params": { + "method": "POST", + "body": { + "notification_type": "{{_global.bizppurio_binding_modal?.notification_type}}", + "template_code": "{{_global.bizppurio_binding_modal?.template_code ?? ''}}", + "template_name": "{{_global.bizppurio_binding_modal?.template_name ?? ''}}", + "fallback_sms_enabled": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}" + } + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioBindings" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.binding.saved", + "type": "success" + } + }, + { + "handler": "closeModal", + "target": "modal_bizppurio_binding" + }, + { + "handler": "setState", + "params": { + "target": "global", + "bizppurio_binding_modal.isSaving": false + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bizppurio_binding_modal.isSaving": false + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.binding.save_error", + "type": "error" + } + } + ] + } + ] + } + } + ] + } + ] + } + ] + } + ], + "init_actions": [ + { + "comment": "알림톡 탭 진입 시 연결 맵 조회(행 연결 상태 표시용). 다른 탭에선 자동 호출 안 됨(auto_fetch:false).", + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioBindings" + } + } + ], + "priority": 320 +} \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_ecommerce.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_ecommerce.json new file mode 100644 index 00000000..f2699a76 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_ecommerce.json @@ -0,0 +1,483 @@ +{ + "target_layout": "admin_ecommerce_settings", + "comment": "이커머스 알림 설정 알림톡 탭에 비즈뿌리오 연동 UI 를 얹는다(notification_tab_core.json 과 동일 패턴, target_layout/target_id/모달 id 만 이커머스 전용). 코어 목록·편집 모달·저장 버튼은 건드리지 않는다(무오염). ① 탭 상단 상태 배너(문제 있을 때만) ② 각 알림 행 하단(notification_definition_row_footer 확장 슬롯, notification_row_footer.json 이 채널 무관 전역 매칭)에 연결 상태 줄 + [연결/변경] 버튼 ③ [연결] 클릭 시 이 모듈 전용 연결 모달에서 승인 템플릿 선택·SMS 대체 설정 후 [저장]. 저장은 우리 API(notification_type 은 이커머스 알림 type 값 그대로 전달, 백엔드는 확장 구분 없는 문자열 키).", + "data_sources": [ + { + "id": "bizppurioBindings", + "comment": "행 연결 상태 표시 + 모달 프리필용. auto_fetch:false — 설정 페이지 전 탭 자동 호출 방지. 알림톡 탭 진입 시 init_actions 로 조회한다.", + "type": "api", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings", + "method": "GET", + "auto_fetch": false, + "auth_required": true, + "fallback": { + "data": { + "bindings": {} + } + } + }, + { + "id": "bizppurioApprovedTemplates", + "comment": "연결 모달 드롭다운 옵션(카카오 관리 API 위임). auto_fetch:false — 자격증명 미설정 시 카카오 API 422 가 전 탭에서 발화하던 문제 차단. 연결 모달 열 때만 조회. errorHandling.default:suppress — 자격증명 미설정 시 422(카카오 API 요청 실패)를 조용히 무시한다(fallback 사용). 조회 실패와 '진짜 0건'을 화면에서 구분하기 위해 fallback.data.load_failed:true 마커를 싣는다.", + "type": "api", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates", + "method": "GET", + "auto_fetch": false, + "auth_required": true, + "errorHandling": { + "default": { + "handler": "suppress" + } + }, + "fallback": { + "data": { + "templates": [], + "load_failed": true + } + } + } + ], + "injections": [ + { + "target_id": "ecommerce_notif_channel_content", + "position": "prepend_child", + "comment": "알림톡·SMS 탭 상단 상태 배너. 문제 있을 때만 노출(정상=배너 없음). readiness 미충족 🔴 설정하기 / is_test_mode 🟡 테스트 모드.", + "components": [ + { + "id": "bizppurio_ecommerce_status_banner", + "type": "basic", + "name": "Div", + "if": "{{['sms','alimtalk'].includes(_local.activeNotificationChannel ?? query.channel ?? '') && ((availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.readiness?.ready === false || (availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.is_test_mode === true)}}", + "props": { + "className": "mt-4 mb-3 space-y-2" + }, + "children": [ + { + "id": "bizppurio_ecommerce_banner_not_ready", + "type": "basic", + "name": "Div", + "if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.readiness?.ready === false}}", + "props": { + "className": "flex-between gap-3 p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-circle-exclamation", + "size": "sm", + "className": "text-red-600 dark:text-red-400" + } + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-sm text-red-800 dark:text-red-200" + }, + "text": "$t:sirsoft-message_bizppurio.banner.not_ready" + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-3 py-1.5 rounded-md bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 whitespace-nowrap" + }, + "text": "$t:sirsoft-message_bizppurio.banner.setup_action", + "actions": [ + { + "type": "click", + "handler": "navigate", + "params": { + "path": "/admin/plugins/sirsoft-message_bizppurio/settings" + } + } + ] + } + ] + }, + { + "id": "bizppurio_ecommerce_banner_test_mode", + "type": "basic", + "name": "Div", + "if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.is_test_mode === true}}", + "props": { + "className": "flex-center gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-flask", + "size": "sm", + "className": "text-amber-600 dark:text-amber-400" + } + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-sm text-amber-800 dark:text-amber-200" + }, + "text": "$t:sirsoft-message_bizppurio.banner.test_mode" + } + ] + } + ] + }, + { + "id": "bizppurio_ecommerce_alimtalk_guide", + "comment": "알림톡 탭 상시 안내 박스 — 이 화면에서 무엇을 하는지(각 알림에 승인 템플릿을 연결). alimtalk 탭일 때만. 배너(빨강/노랑)와 구분되게 연한 파랑 정보 박스.", + "type": "basic", + "name": "Div", + "if": "{{(_local.activeNotificationChannel ?? query.channel) === 'alimtalk'}}", + "props": { + "className": "flex items-center gap-2 mt-3 mb-1 p-3 rounded-lg bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-circle-info", + "size": "sm", + "className": "text-blue-500 dark:text-blue-400 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-sm text-blue-800 dark:text-blue-200" + }, + "text": "$t:sirsoft-message_bizppurio.binding.list_guide" + } + ] + } + ] + } + ], + "modals": [ + { + "id": "modal_bizppurio_binding", + "comment": "알림톡 연결 전용 모달(우리 소유). 코어 편집 모달과 분리. 승인 템플릿 드롭다운·SMS 대체 토글·변수 안내 + [취소][저장]. 저장은 우리 API store. 모달 컨텍스트 분리 대응으로 값은 _global.bizppurio_binding_modal 에 담는다.", + "type": "composite", + "name": "Modal", + "props": { + "title": "$t:sirsoft-message_bizppurio.binding.modal_title|name={{_global.bizppurio_binding_modal?.notification_name ?? ''}}", + "size": "md", + "closeOnOverlayClick": false + }, + "children": [ + { + "id": "bizppurio_ecommerce_binding_modal_body", + "type": "basic", + "name": "Div", + "blur_until_loaded": "{{_global.bizppurio_binding_modal?.isSaving}}", + "props": { + "className": "space-y-4" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex items-center gap-2 p-3 rounded-lg bg-teal-50 dark:bg-teal-950 border border-teal-200 dark:border-teal-800" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-circle-info", + "size": "sm", + "className": "text-teal-600 dark:text-teal-400 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-xs text-teal-700 dark:text-teal-300" + }, + "text": "$t:sirsoft-message_bizppurio.binding.section_hint" + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.binding.connected_template" + }, + { + "type": "composite", + "name": "Select", + "props": { + "value": "{{_global.bizppurio_binding_modal?.template_code ?? ''}}", + "className": "w-full text-sm", + "options": "{{[{ value: '', label: $t('sirsoft-message_bizppurio.binding.none') }, ...(bizppurioApprovedTemplates?.data?.templates ?? []).map(t => ({ value: t.template_code, label: t.template_name + ' (' + t.template_code + ')' }))]}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bizppurio_binding_modal.template_code": "{{$event.target.value}}", + "bizppurio_binding_modal.template_name": "{{((bizppurioApprovedTemplates?.data?.templates ?? []).find(t => t.template_code === $event.target.value)?.template_name) ?? ''}}", + "bizppurio_binding_modal.fallback_sms": "{{$event.target.value === '' ? false : (_global.bizppurio_binding_modal?.fallback_sms ?? false)}}" + } + } + ] + }, + { + "comment": "드롭다운이 비었고 조회 실패(fallback 마커)일 때 — 키 오류 등을 '0건'과 구분해 설정 확인을 안내.", + "type": "basic", + "name": "P", + "if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0 && bizppurioApprovedTemplates?.data?.load_failed === true}}", + "props": { + "className": "text-xs text-red-600 dark:text-red-400 mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.binding.templates_load_failed" + }, + { + "comment": "드롭다운이 비었고 조회는 정상(load_failed 없음)일 때 — 실제 승인 템플릿 0건.", + "type": "basic", + "name": "P", + "if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0 && !(bizppurioApprovedTemplates?.data?.load_failed)}}", + "props": { + "className": "text-xs text-amber-600 dark:text-amber-400 mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.binding.no_approved_templates" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center gap-2" + }, + "children": [ + { + "type": "composite", + "name": "Toggle", + "props": { + "checked": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}", + "disabled": "{{(_global.bizppurio_binding_modal?.template_code ?? '') === ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bizppurio_binding_modal.fallback_sms": "{{!(_global.bizppurio_binding_modal?.fallback_sms ?? false)}}" + } + } + ] + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-secondary text-xs" + }, + "text": "$t:sirsoft-message_bizppurio.binding.fallback_sms" + } + ] + }, + { + "id": "bizppurio_ecommerce_binding_fallback_hint", + "comment": "SMS 대체발송 시 무엇이 발송되는지 안내 — 이 알림의 본문이 문자로 발송됨.", + "type": "basic", + "name": "P", + "props": { + "className": "text-xs text-gray-500 dark:text-gray-400 pl-11 -mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.binding.fallback_hint" + }, + { + "id": "bizppurio_ecommerce_binding_variables", + "type": "basic", + "name": "Div", + "if": "{{(_global.bizppurio_binding_modal?.variables ?? []).length > 0}}", + "props": { + "className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3" + }, + "children": [ + { + "type": "basic", + "name": "P", + "props": { + "className": "text-xs text-gray-600 dark:text-gray-400 mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.binding.variables_hint" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex flex-wrap gap-1" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "iteration": { + "source": "{{_global.bizppurio_binding_modal?.variables ?? []}}", + "item_var": "v" + }, + "props": { + "className": "text-xs font-mono px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300" + }, + "text": "{{'#{' + (v.key ?? '') + '}'}}" + } + ] + } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", + "disabled": "{{_global.bizppurio_binding_modal?.isSaving ?? false}}" + }, + "text": "$t:common.cancel", + "actions": [ + { + "type": "click", + "handler": "closeModal", + "target": "modal_bizppurio_binding" + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50", + "disabled": "{{_global.bizppurio_binding_modal?.isSaving ?? false}}" + }, + "text": "$t:common.save", + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bizppurio_binding_modal.isSaving": true + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings", + "params": { + "method": "POST", + "body": { + "notification_type": "{{_global.bizppurio_binding_modal?.notification_type}}", + "template_code": "{{_global.bizppurio_binding_modal?.template_code ?? ''}}", + "template_name": "{{_global.bizppurio_binding_modal?.template_name ?? ''}}", + "fallback_sms_enabled": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}" + } + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioBindings" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.binding.saved", + "type": "success" + } + }, + { + "handler": "closeModal", + "target": "modal_bizppurio_binding" + }, + { + "handler": "setState", + "params": { + "target": "global", + "bizppurio_binding_modal.isSaving": false + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bizppurio_binding_modal.isSaving": false + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.binding.save_error", + "type": "error" + } + } + ] + } + ] + } + } + ] + } + ] + } + ] + } + ], + "init_actions": [ + { + "comment": "알림톡 탭 진입 시 연결 맵 조회(행 연결 상태 표시용). 다른 탭에선 자동 호출 안 됨(auto_fetch:false).", + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioBindings" + } + } + ], + "priority": 320 +} \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_board_ecommerce.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_board_ecommerce.test.tsx new file mode 100644 index 00000000..7e89914c --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_board_ecommerce.test.tsx @@ -0,0 +1,167 @@ +// e2e:allow 게시판·이커머스 배너/버튼/모달 노출은 Chrome MCP 로 실브라우저 확인·수정까지 완료 +// (2026-07-28). 정식 E2E는 비즈뿌리오 발송 인프라 의존이 커서 별도 계획에서 다룸(코어와 동일 사유). +/** + * 게시판·이커머스 알림 설정 알림톡 탭 연동 UI 구조 검증 (이슈 #28 후속) + * + * 배경: notification_tab_core.json(target_layout=admin_settings) + notification_row_footer.json + * (extension_point, 전역 매칭)은 코어 알림 설정 화면에만 연동 버튼·배너를 노출했다. + * extension_point 는 이름만 같으면 어느 레이아웃에서도 매칭되지만, target_id 기반 overlay + * injection 은 레이아웃별로 독립이라 게시판·이커머스는 배너/안내박스/연결모달이 뜨지 않았다. + * + * notification_tab_board.json / notification_tab_ecommerce.json 을 신설해 + * target_layout=admin_board_settings / admin_ecommerce_settings 로 각각 등록했다. + * notification_row_footer.json 은 그대로(전역 매칭)이므로 재사용된다. + */ + +import { describe, it, expect } from 'vitest'; +import boardOverlay from '../../../extensions/notification_tab_board.json'; +import ecommerceOverlay from '../../../extensions/notification_tab_ecommerce.json'; +import ko from '../../../lang/ko.json'; +import en from '../../../lang/en.json'; +import { findById, type AnyNode } from './helpers'; + +type OverlayFixture = { + label: string; + overlay: typeof boardOverlay; + targetLayout: string; + targetId: string; + bannerId: string; + notReadyId: string; + testModeId: string; + guideId: string; + modalBodyId: string; +}; + +const FIXTURES: OverlayFixture[] = [ + { + label: '게시판', + overlay: boardOverlay, + targetLayout: 'admin_board_settings', + targetId: 'board_notif_channel_content', + bannerId: 'bizppurio_board_status_banner', + notReadyId: 'bizppurio_board_banner_not_ready', + testModeId: 'bizppurio_board_banner_test_mode', + guideId: 'bizppurio_board_alimtalk_guide', + modalBodyId: 'bizppurio_board_binding_modal_body', + }, + { + label: '이커머스', + overlay: ecommerceOverlay, + targetLayout: 'admin_ecommerce_settings', + targetId: 'ecommerce_notif_channel_content', + bannerId: 'bizppurio_ecommerce_status_banner', + notReadyId: 'bizppurio_ecommerce_banner_not_ready', + testModeId: 'bizppurio_ecommerce_banner_test_mode', + guideId: 'bizppurio_ecommerce_alimtalk_guide', + modalBodyId: 'bizppurio_ecommerce_binding_modal_body', + }, +]; + +const collectPluginKeys = (json: unknown): string[] => { + const text = JSON.stringify(json); + const prefixed = text.match(/\$t:sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+/g) ?? []; + const called = text.match(/\$t\('sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+'\)/g) ?? []; + return Array.from(new Set([ + ...prefixed.map((m) => m.replace('$t:', '')), + ...called.map((m) => m.replace(/^\$t\('/, '').replace(/'\)$/, '')), + ])); +}; + +describe.each(FIXTURES)('$label 알림톡 연동 overlay', ({ + overlay, targetLayout, targetId, bannerId, notReadyId, testModeId, guideId, modalBodyId, +}) => { + const injectionRoot = { + children: ((overlay as { injections?: Array<{ components?: AnyNode[] }> }).injections ?? []) + .flatMap((i) => i.components ?? []), + } as AnyNode; + const modalRoot = { children: (overlay as { modals?: AnyNode[] }).modals ?? [] } as AnyNode; + + it(`target_layout=${targetLayout} 이고 extension_point 키가 없다(overlay 전용)`, () => { + expect((overlay as { target_layout?: string }).target_layout).toBe(targetLayout); + expect((overlay as Record).extension_point).toBeUndefined(); + }); + + it(`배너·안내박스는 target_id=${targetId} 에 prepend_child 로 주입된다`, () => { + const injections = (overlay as { injections?: Array> }).injections ?? []; + expect(injections).toHaveLength(1); + expect(injections[0].target_id).toBe(targetId); + expect(injections[0].position).toBe('prepend_child'); + }); + + it('연결 맵·승인 템플릿 데이터소스를 등록한다(코어와 동일 endpoint)', () => { + const ids = ((overlay as { data_sources?: Array<{ id: string }> }).data_sources ?? []).map((d) => d.id); + expect(ids).toContain('bizppurioBindings'); + expect(ids).toContain('bizppurioApprovedTemplates'); + }); + + it('상태 배너는 sms·alimtalk 탭에서 문제(readiness 미충족 / test_mode)일 때만 노출된다', () => { + const banner = findById(injectionRoot, bannerId); + expect(banner).toBeTruthy(); + const cond = (banner as { if?: string }).if ?? ''; + expect(cond).toContain("'sms'"); + expect(cond).toContain("'alimtalk'"); + expect(cond).toContain('readiness?.ready === false'); + expect(cond).toContain('is_test_mode === true'); + }); + + it('readiness 미충족 배너에 설정하기 이동 버튼이 있다', () => { + const raw = JSON.stringify(findById(injectionRoot, notReadyId)); + expect(raw).toContain('banner.not_ready'); + expect(raw).toContain('banner.setup_action'); + expect(raw).toContain('/admin/plugins/sirsoft-message_bizppurio/settings'); + }); + + it('검수 모드 배너는 readiness 와 무관하게 is_test_mode 만으로 노출된다', () => { + const banner = findById(injectionRoot, testModeId); + expect(banner).toBeTruthy(); + const cond = (banner as { if?: string }).if ?? ''; + expect(cond).not.toContain('readiness'); + expect(cond).toContain('is_test_mode === true'); + }); + + it('알림톡 탭 상시 안내 박스가 있다', () => { + const guide = findById(injectionRoot, guideId); + expect(guide).toBeTruthy(); + expect((guide as { if?: string }).if).toContain("=== 'alimtalk'"); + expect(JSON.stringify(guide)).toContain('binding.list_guide'); + }); + + it('연결 전용 모달(modal_bizppurio_binding)이 modals 로 등록된다', () => { + const modal = findById(modalRoot, 'modal_bizppurio_binding'); + expect(modal).toBeTruthy(); + expect((modal as { name?: string }).name).toBe('Modal'); + }); + + it('[저장] 은 우리 API store 로 저장하고 toast + 모달 닫힘 + 목록 갱신한다', () => { + const modal = findById(modalRoot, 'modal_bizppurio_binding'); + const raw = JSON.stringify(modal); + expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings'); + expect(raw).toContain('"method":"POST"'); + expect(raw).toContain('binding.saved'); + expect(raw).toContain('"closeModal"'); + expect(raw).toContain('bizppurioBindings'); + }); + + it('연결 템플릿이 없으면 SMS 대체 토글이 비활성이다(코어와 동일 규칙)', () => { + const raw = JSON.stringify(findById(modalRoot, modalBodyId)); + expect(raw).toContain('"disabled"'); + expect(raw).toContain("=== ''"); + }); + + it('overlay 는 코어 편집 모달 저장 body(notification-templates PUT)를 건드리지 않는다', () => { + const raw = JSON.stringify(overlay); + expect(raw).not.toContain('/api/admin/notification-templates/'); + expect(raw).not.toContain('notification_template_form_modal'); + }); + + it('참조하는 모든 플러그인 i18n 키가 ko·en 에 존재한다', () => { + const keys = collectPluginKeys(overlay); + expect(keys.length).toBeGreaterThan(0); + const resolve = (root: unknown, path: string): unknown => + path.split('.').slice(1).reduce((acc, seg) => (acc as Record)?.[seg], root); + for (const key of Array.from(new Set(keys))) { + expect(resolve(ko, key), `ko 누락: ${key}`).toBeTruthy(); + expect(resolve(en, key), `en 누락: ${key}`).toBeTruthy(); + } + }); +});