v7.0.0-beta.1 release

This commit is contained in:
HeuJung committed 2026-04-01 10:30:52 +09:00
commit 6595fd0eb5
3967 files changed
+1875383

No files matched your search

@@ -0,0 +1,43 @@
# Changelog
이 프로젝트의 모든 주요 변경사항을 기록합니다.
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.0-beta.1] - 2026-04-01
### Changed
- 오픈 베타 릴리즈
## [0.1.3] - 2026-03-16
### Changed
- 라이선스 프로그램 명칭 정비
## [0.1.2] - 2026-03-13
### Added
- manifest에 license 필드 및 LICENSE 파일 추가
### Changed
- 설정 레이아웃 경로를 `resources/layouts/settings.json` → `resources/layouts/admin/plugin_settings.json`으로 이동 (모듈과 동일한 구조 통일)
## [0.1.1] - 2026-02-24
### Changed
- 버전 체계 조정 (정식 출시 전 0.x 체계로 변경)
## [0.1.0] - 2026-02-23
### Added
- Daum 우편번호 검색 플러그인 초기 구현
- Daum 우편번호 서비스 API 연동 (API 키 불필요)
- 주소 검색 팝업/레이어 표시 모드 설정
- 커스텀 핸들러 (openPostcode, setFieldReadOnly)
- 이커머스 주소 검색 레이아웃 확장 (ecommerce-address-search)
- 플러그인 설정 UI (표시 모드, 테마 설정)
- 다국어 지원 (ko, en)
@@ -0,0 +1,47 @@
프로그램 명칭 : 그누보드7용 Daum 우편번호 플러그인 (sirsoft-daum_postcode)
저작자 : (주)에스아이알소프트
----- MIT 라이선스 (한국어 번역) --------------------------------------------------------
MIT 라이선스
Copyright (c) 2026 (주)에스아이알소프트
이 소프트웨어와 관련 문서 파일(이하 "소프트웨어")의 복사본을 취득하는 모든 사람에게
소프트웨어를 제한 없이 사용, 복사, 수정, 병합, 출판, 배포, 서브라이선스 허여 및/또는
판매할 수 있는 권리를 무상으로 부여합니다. 다만, 소프트웨어를 제공받은 사람은 다음
조건을 따라야 합니다:
위 저작권 고지와 본 허가 고지는 소프트웨어의 모든 복사본 또는 상당 부분에 포함되어야
합니다.
소프트웨어는 "있는 그대로" 제공되며, 명시적이든 묵시적이든 어떠한 종류의 보증도 하지
않습니다. 여기에는 상품성, 특정 목적에의 적합성 및 비침해에 대한 보증이 포함되나 이에
국한되지 않습니다. 어떠한 경우에도 저작자 또는 저작권자는 소프트웨어나 소프트웨어의
사용 또는 기타 거래로 인해 발생하는 계약, 불법행위 또는 기타 청구, 손해 또는 기타
책임에 대해 책임을 지지 않습니다.
----- MIT License (English Original) --------------------------------------------------------
The MIT License (MIT)
Copyright (c) 2026 SIRSOFT
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,20 @@
{
"name": "plugins/sirsoft-daum_postcode",
"description": "Daum Postcode Service Plugin for Gnuboard7 platform",
"type": "library",
"version": "1.0.0-beta.1",
"authors": [
{
"name": "sirsoft",
"email": "contact@sirsoft.com"
}
],
"require": {
"php": "^8.2"
},
"autoload": {
"psr-4": {
"Plugins\\Sirsoft\\DaumPostcode\\": ["src/", "./"]
}
}
}
@@ -0,0 +1,18 @@
{
"_meta": {
"version": "1.0.0",
"description": "Daum 우편번호 플러그인 환경설정"
},
"defaults": {
"display_mode": "layer",
"popup_width": 500,
"popup_height": 600,
"theme_color": "#1D4ED8"
},
"frontend_schema": {
"display_mode": { "expose": true },
"popup_width": { "expose": true },
"popup_height": { "expose": true },
"theme_color": { "expose": true }
}
}
@@ -0,0 +1,37 @@
var SirsoftDaumPostcode=function(m){"use strict";const y=window.G7Core?.createLogger?.("DaumPostcode:SetFieldReadOnly")??{log:(...e)=>console.log("[DaumPostcode:SetFieldReadOnly]",...e),warn:(...e)=>console.warn("[DaumPostcode:SetFieldReadOnly]",...e),error:(...e)=>console.error("[DaumPostcode:SetFieldReadOnly]",...e)};function C(e,c){const o=e.params||{},t=o.fields||[],n=o.readOnly!==!1;if(!t.length){y.warn("[setFieldReadOnly] No fields specified");return}y.log(`[setFieldReadOnly] Setting readOnly=${n} for fields:`,t),t.forEach(r=>{const s=document.querySelectorAll(`input[name="${r}"], textarea[name="${r}"]`);if(s.length===0){y.warn(`[setFieldReadOnly] No input found with name="${r}"`);return}s.forEach(i=>{i.readOnly=n,n?i.classList.add("readonly"):i.classList.remove("readonly"),y.log(`[setFieldReadOnly] Set readOnly=${n} on input[name="${r}"]`)})})}const a=window.G7Core?.createLogger?.("DaumPostcode:OpenPostcode")??{log:(...e)=>console.log("[DaumPostcode:OpenPostcode]",...e),warn:(...e)=>console.warn("[DaumPostcode:OpenPostcode]",...e),error:(...e)=>console.error("[DaumPostcode:OpenPostcode]",...e)};function O(e){return{zipcode:e.zonecode||"",address:e.roadAddress||e.jibunAddress||"",addressDetail:"",region:e.sido||"",city:e.sigungu||"",countryCode:"KR",_raw:e}}function x(e,c){const o=e.params||{},{callbackAction:t,displayMode:n="layer",width:r=500,height:s=600,theme:i,targetFields:l}=o,b=(window.G7Core?.plugin?.getSettings?.("sirsoft-daum_postcode")||{}).target_fields||{zipcode:"shipping.zipcode",address:"shipping.address",region:"shipping.region",city:"shipping.city"};if(a.log("[openPostcode] Starting with params:",{displayMode:n,width:r,height:s,hasCallbackAction:!!t,targetFields:l||b}),!window.daum?.Postcode){a.error("[openPostcode] Daum Postcode API not loaded");return}const g={oncomplete:h=>{a.log("[openPostcode] Address selected (raw):",h);const f=O(h);a.log("[openPostcode] Converted to G7 format:",f),t?(a.log("[openPostcode] Executing callbackAction"),L(t,f,c)):(a.log("[openPostcode] Executing default setState"),S(l||b,f))},width:r,height:s};i&&(g.theme=i);const v=new window.daum.Postcode(g);if(n==="popup")v.open();else{const{layer:h,closeLayer:f}=$(r,s),D=g.oncomplete;g.oncomplete=k=>{f(),D(k)},new window.daum.Postcode(g).embed(h)}}function $(e,c){const o=document.createElement("div");o.id="daum-postcode-overlay",o.style.cssText=`
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
`;const t=document.createElement("div");t.id="daum-postcode-layer",t.style.cssText=`
width: ${e}px;
height: ${c}px;
background-color: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
`;const n=document.createElement("button");n.type="button",n.innerHTML="×",n.style.cssText=`
position: absolute;
top: -12px;
right: -12px;
width: 32px;
height: 32px;
border-radius: 50%;
background-color: #374151;
color: white;
border: none;
cursor: pointer;
font-size: 20px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
`;const r=document.createElement("div");r.style.cssText="position: relative;",r.appendChild(n),r.appendChild(t),o.appendChild(r),document.body.appendChild(o);const s=()=>{o.parentNode&&o.parentNode.removeChild(o)};n.addEventListener("click",s),o.addEventListener("click",l=>{l.target===o&&s()});const i=l=>{l.key==="Escape"&&(s(),document.removeEventListener("keydown",i))};return document.addEventListener("keydown",i),a.log("[openPostcode] Layer created with size:",{width:e,height:c}),{overlay:o,layer:t,closeLayer:s}}function L(e,c,o){const t=window.G7Core;if(!t?.dispatch){a.error("[openPostcode] G7Core.dispatch not available");return}const n=o?.state||t?.state?.getLocal?.()||{},r={state:n,setState:o?.setState,data:{...o?.data||{},_local:n,$event:c},isolatedContext:o?.isolatedContext};a.log("[openPostcode] Dispatching with componentContext:",{hasLocalState:!!n,sourceType:o?.state?"handlerContext":"getLocal",localCheckout:n?.checkout,event:c});try{const s=Array.isArray(e)?e:[e];for(const i of s)i&&typeof i=="object"&&(a.log("[openPostcode] Dispatching action:",i),t.dispatch(i,{componentContext:r}))}catch(s){a.error("[openPostcode] callbackAction failed:",s)}}function S(e,c){const o=window.G7Core;if(!o?.state?.setLocal){a.error("[openPostcode] G7Core.state.setLocal not available");return}const t={};e.zipcode&&(t[e.zipcode]=c.zipcode),e.address&&(t[e.address]=c.address),e.region&&(t[e.region]=c.region),e.city&&(t[e.city]=c.city),a.log("[openPostcode] Setting local state:",t),o.state.setLocal(t)}const p={setFieldReadOnly:C,openPostcode:x},d="sirsoft-daum_postcode",u=window.G7Core?.createLogger?.(`Plugin:${d}`)??{log:(...e)=>console.log(`[Plugin:${d}]`,...e),warn:(...e)=>console.warn(`[Plugin:${d}]`,...e),error:(...e)=>console.error(`[Plugin:${d}]`,...e)};function P(e=!1){const c=window.G7Core?.getActionDispatcher?.();if(c)Object.entries(p).forEach(([o,t])=>{const n=`${d}.${o}`;c.registerHandler(n,t,{category:"plugin",source:d})}),u.log(`${Object.keys(p).length} handler(s) registered:`,Object.keys(p).map(o=>`${d}.${o}`));else if(e){let o=0;const t=50,n=()=>{const r=window.G7Core?.getActionDispatcher?.();r?(Object.entries(p).forEach(([s,i])=>{const l=`${d}.${s}`;r.registerHandler(l,i,{category:"plugin",source:d})}),u.log(`${Object.keys(p).length} handler(s) registered:`,Object.keys(p).map(s=>`${d}.${s}`))):(o++,o<=t?(u.warn(`ActionDispatcher not found, retrying... (${o}/${t})`),setTimeout(n,100)):u.error("Failed to register handlers: ActionDispatcher not available after maximum retries"))};n()}else u.warn("ActionDispatcher not found, handlers not registered")}function w(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>P(!0));else{const e=!!window.G7Core?.getActionDispatcher?.();P(!e)}}return w(),typeof window<"u"&&(window.__SirsoftDaumPostcode={identifier:d,handlers:Object.keys(p),initPlugin:w}),m.initPlugin=w,Object.defineProperty(m,Symbol.toStringTag,{value:"Module"}),m}({});
//# sourceMappingURL=plugin.iife.js.map
@@ -0,0 +1 @@
{"version":3,"file":"plugin.iife.js","sources":["../../resources/js/handlers/setFieldReadOnly.ts","../../resources/js/handlers/openPostcode.ts","../../resources/js/handlers/index.ts","../../resources/js/index.ts"],"sourcesContent":["/**\n * setFieldReadOnly 핸들러\n *\n * 지정된 필드명의 input 요소를 찾아 readOnly 속성을 설정합니다.\n * 주소 검색 플러그인이 설치된 경우, 우편번호/주소 필드를 readOnly로 설정하여\n * 사용자가 직접 입력하지 못하도록 합니다.\n */\n\nimport type { ActionContext } from '../types';\n\n// Logger 설정 (G7Core 초기화 전에도 동작하도록 폴백 포함)\nconst logger = ((window as any).G7Core?.createLogger?.('DaumPostcode:SetFieldReadOnly')) ?? {\n log: (...args: unknown[]) => console.log('[DaumPostcode:SetFieldReadOnly]', ...args),\n warn: (...args: unknown[]) => console.warn('[DaumPostcode:SetFieldReadOnly]', ...args),\n error: (...args: unknown[]) => console.error('[DaumPostcode:SetFieldReadOnly]', ...args),\n};\n\ninterface ActionWithParams {\n handler: string;\n params?: {\n /** readOnly로 설정할 필드명 배열 */\n fields?: string[];\n /** readOnly 값 (기본값: true) */\n readOnly?: boolean;\n };\n [key: string]: any;\n}\n\n/**\n * 지정된 필드명의 input 요소를 찾아 readOnly 속성을 설정합니다.\n *\n * @param action 액션 객체 (params.fields: 필드명 배열, params.readOnly: boolean)\n * @param _context 액션 컨텍스트\n *\n * @example\n * // 레이아웃 JSON에서 사용\n * {\n * \"handler\": \"sirsoft-daum_postcode.setFieldReadOnly\",\n * \"params\": {\n * \"fields\": [\"zonecode\", \"address\"],\n * \"readOnly\": true\n * }\n * }\n */\nexport function setFieldReadOnlyHandler(\n action: ActionWithParams,\n _context: ActionContext\n): void {\n const params = action.params || {};\n const fields = params.fields || [];\n const readOnly = params.readOnly !== false; // 기본값 true\n\n if (!fields.length) {\n logger.warn('[setFieldReadOnly] No fields specified');\n return;\n }\n\n logger.log(`[setFieldReadOnly] Setting readOnly=${readOnly} for fields:`, fields);\n\n // 각 필드에 대해 DOM에서 input 요소를 찾아 readOnly 설정\n fields.forEach((fieldName) => {\n // name 속성으로 input 요소 찾기\n const inputs = document.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>(\n `input[name=\"${fieldName}\"], textarea[name=\"${fieldName}\"]`\n );\n\n if (inputs.length === 0) {\n logger.warn(`[setFieldReadOnly] No input found with name=\"${fieldName}\"`);\n return;\n }\n\n inputs.forEach((input) => {\n input.readOnly = readOnly;\n\n // readOnly 상태에 따른 시각적 피드백 (선택적)\n if (readOnly) {\n input.classList.add('readonly');\n } else {\n input.classList.remove('readonly');\n }\n\n logger.log(`[setFieldReadOnly] Set readOnly=${readOnly} on input[name=\"${fieldName}\"]`);\n });\n });\n}\n","/**\n * openPostcode 핸들러\n *\n * 다음 우편번호 API를 호출하고 G7 표준 형식으로 변환하여 콜백 실행\n * - callbackAction 있으면: 커스텀 동작 실행 (Extension Point에서 정의한 onAddressSelect)\n * - callbackAction 없으면: 플러그인 기본 동작 (설정 기반 setState)\n *\n * G7 표준 주소 이벤트 형식:\n * - zipcode: 우편번호\n * - address: 기본 주소 (도로명/지번)\n * - addressDetail: 상세 주소 (사용자 입력)\n * - region: 시/도\n * - city: 시/군/구\n * - countryCode: 국가 코드\n * - _raw: 원본 다음 API 응답 (필요 시 접근 가능)\n */\n\nimport type { ActionContext } from '../types';\n\n// Logger 설정 (G7Core 초기화 전에도 동작하도록 폴백 포함)\nconst logger = ((window as any).G7Core?.createLogger?.('DaumPostcode:OpenPostcode')) ?? {\n log: (...args: unknown[]) => console.log('[DaumPostcode:OpenPostcode]', ...args),\n warn: (...args: unknown[]) => console.warn('[DaumPostcode:OpenPostcode]', ...args),\n error: (...args: unknown[]) => console.error('[DaumPostcode:OpenPostcode]', ...args),\n};\n\ninterface ActionWithParams {\n handler: string;\n params?: {\n /** 주소 선택 시 실행할 콜백 액션 (Extension Point props에서 전달) */\n callbackAction?: any;\n /** 표시 모드 (popup | layer) */\n displayMode?: string;\n /** 팝업/레이어 너비 */\n width?: number;\n /** 팝업/레이어 높이 */\n height?: number;\n /** 테마 설정 */\n theme?: {\n searchBgColor?: string;\n queryTextColor?: string;\n };\n /** 대상 필드 매핑 (기본 동작 시 사용) */\n targetFields?: {\n zipcode?: string;\nLine truncated
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,16 @@
{
"name": "@g7/sirsoft-daum_postcode",
"version": "1.0.0-beta.1",
"description": "그누보드7 Daum 우편번호 플러그인 프론트엔드 에셋",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "^5.3.3",
"vite": "^5.4.14"
}
}
@@ -0,0 +1,32 @@
{
"identifier": "sirsoft-daum_postcode",
"vendor": "sirsoft",
"name": {
"ko": "Daum 우편번호",
"en": "Daum Postcode"
},
"version": "1.0.0-beta.1",
"license": "MIT",
"description": {
"ko": "Daum 우편번호 서비스를 통한 주소 검색 기능을 제공하는 플러그인입니다. API 키 없이 무료로 사용할 수 있습니다.",
"en": "Plugin that provides address search functionality through Daum Postcode service. Free to use without API key."
},
"g7_version": ">=7.0.0-beta.1",
"dependencies": {
"modules": {},
"plugins": {}
},
"github_url": "https://github.com/gnuboard/g7-plugin-sirsoft-daum_postcode",
"github_changelog_url": "https://github.com/gnuboard/g7-plugin-sirsoft-daum_postcode/blob/main/CHANGELOG.md",
"assets": {
"js": {
"entry": "resources/js/index.ts",
"output": "dist/js/plugin.iife.js"
},
"handlers": true
},
"loading": {
"strategy": "global",
"priority": 100
}
}
@@ -0,0 +1,145 @@
<?php
namespace Plugins\Sirsoft\DaumPostcode;
use App\Extension\AbstractPlugin;
/**
* Daum 우편번호 서비스 플러그인
*
* Daum 우편번호 검색 API를 통한 주소 검색 기능을 제공합니다.
*/
class Plugin extends AbstractPlugin
{
/**
* 플러그인 메타데이터 반환
*
* @return array 메타데이터
*/
public function getMetadata(): array
{
return [
'author' => 'Sirsoft',
'license' => 'MIT',
'homepage' => 'https://sir.kr',
'keywords' => ['postcode', 'address', 'daum', 'korea'],
];
}
/**
* 플러그인 설정 스키마 반환
*
* @return array 설정 스키마
*/
public function getSettingsSchema(): array
{
return [
'display_mode' => [
'type' => 'enum',
'options' => ['popup', 'layer'],
'default' => 'layer',
'label' => [
'ko' => '표시 방식',
'en' => 'Display Mode',
],
'hint' => [
'ko' => '주소 검색 창을 표시하는 방식을 선택합니다.',
'en' => 'Select how to display the address search window.',
],
'required' => false,
],
'popup_width' => [
'type' => 'integer',
'default' => 500,
'label' => [
'ko' => '팝업 너비 (px)',
'en' => 'Popup Width (px)',
],
'hint' => [
'ko' => '팝업 모드에서 사용되는 창 너비입니다.',
'en' => 'Window width used in popup mode.',
],
'required' => false,
],
'popup_height' => [
'type' => 'integer',
'default' => 600,
'label' => [
'ko' => '팝업 높이 (px)',
'en' => 'Popup Height (px)',
],
'hint' => [
'ko' => '팝업 모드에서 사용되는 창 높이입니다.',
'en' => 'Window height used in popup mode.',
],
'required' => false,
],
'theme_color' => [
'type' => 'string',
'default' => '#1D4ED8',
'label' => [
'ko' => '테마 색상',
'en' => 'Theme Color',
],
'hint' => [
'ko' => '우편번호 검색 창의 테마 색상입니다. (예: #1D4ED8)',
'en' => 'Theme color for the postcode search window. (e.g., #1D4ED8)',
],
'required' => false,
],
];
}
/**
* 플러그인 설정 기본값 반환
*
* @return array 기본 설정값
*/
public function getConfigValues(): array
{
return [
'display_mode' => 'layer',
'popup_width' => 500,
'popup_height' => 600,
'theme_color' => '#1D4ED8',
];
}
/**
* 플러그인이 제공하는 훅 정보 반환
*
* @return array 훅 정의 배열
*/
public function getHooks(): array
{
return [
[
'name' => 'sirsoft-daum_postcode.address.selected',
'type' => 'action',
'description' => [
'ko' => '주소 선택 완료 시 실행되는 액션 훅',
'en' => 'Action hook executed when address selection is complete',
],
'parameters' => [
'zonecode' => 'string - 우편번호',
'address' => 'string - 기본 주소',
'roadAddress' => 'string - 도로명 주소',
'jibunAddress' => 'string - 지번 주소',
'buildingName' => 'string - 건물명',
],
],
[
'name' => 'sirsoft-daum_postcode.filter_address_data',
'type' => 'filter',
'description' => [
'ko' => '선택된 주소 데이터를 필터링하는 훅',
'en' => 'Filter hook to modify selected address data',
],
'parameters' => [
'data' => 'array - 주소 데이터',
],
'return' => 'array - 필터링된 주소 데이터',
],
];
}
}
@@ -0,0 +1,62 @@
{
"extension_point": "address_search_slot",
"scripts": [
{
"src": "//t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js",
"id": "daum_postcode_script"
}
],
"components": [
{
"id": "daum_postcode_container",
"type": "basic",
"name": "Div",
"props": {
"className": "inline-flex"
},
"lifecycle": {
"onMount": [
{
"comment": "주소 검색 플러그인 마운트 시 지정된 필드를 readOnly로 설정",
"handler": "sirsoft-daum_postcode.setFieldReadOnly",
"params": {
"fields": "{{extensionPointProps.readOnlyFields ?? ['zipcode', 'address']}}",
"readOnly": true
}
}
]
},
"children": [
{
"id": "btn_search_address",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "px-3 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 text-sm font-medium transition-colors"
},
"text": "$t:sirsoft-daum_postcode.search_address",
"actions": [
{
"type": "click",
"comment": "G7 표준 형식으로 변환하여 callbackAction 실행 (하이브리드 방식)",
"handler": "sirsoft-daum_postcode.openPostcode",
"params": {
"displayMode": "{{_global.plugins['sirsoft-daum_postcode']?.display_mode ?? 'layer'}}",
"width": "{{_global.plugins['sirsoft-daum_postcode']?.popup_width ?? 500}}",
"height": "{{_global.plugins['sirsoft-daum_postcode']?.popup_height ?? 600}}",
"theme": {
"searchBgColor": "{{_global.plugins['sirsoft-daum_postcode']?.theme_color ?? '#1D4ED8'}}",
"queryTextColor": "#FFFFFF"
},
"callbackAction": "{{extensionPointProps.onAddressSelect}}",
"targetFields": "{{extensionPointProps.targetFields}}"
}
}
]
}
]
}
],
"priority": 100
}
@@ -0,0 +1,23 @@
/**
* sirsoft-daum_postcode 플러그인 핸들러
*
* 플러그인에서 사용하는 모든 커스텀 핸들러를 정의합니다.
*/
import { setFieldReadOnlyHandler } from './setFieldReadOnly';
import { openPostcodeHandler } from './openPostcode';
/**
* 핸들러 맵
*
* 키: 핸들러 이름 (네임스페이스 없이)
* 값: 핸들러 함수
*
* ActionDispatcher에 등록 시 플러그인 식별자가 네임스페이스로 추가됩니다.
* 예: 'setFieldReadOnly' -> 'sirsoft-daum_postcode.setFieldReadOnly'
* 예: 'openPostcode' -> 'sirsoft-daum_postcode.openPostcode'
*/
export const handlerMap = {
setFieldReadOnly: setFieldReadOnlyHandler,
openPostcode: openPostcodeHandler,
} as const;
@@ -0,0 +1,374 @@
/**
* openPostcode 핸들러
*
* 다음 우편번호 API를 호출하고 G7 표준 형식으로 변환하여 콜백 실행
* - callbackAction 있으면: 커스텀 동작 실행 (Extension Point에서 정의한 onAddressSelect)
* - callbackAction 없으면: 플러그인 기본 동작 (설정 기반 setState)
*
* G7 표준 주소 이벤트 형식:
* - zipcode: 우편번호
* - address: 기본 주소 (도로명/지번)
* - addressDetail: 상세 주소 (사용자 입력)
* - region: 시/도
* - city: 시/군/구
* - countryCode: 국가 코드
* - _raw: 원본 다음 API 응답 (필요 시 접근 가능)
*/
import type { ActionContext } from '../types';
// Logger 설정 (G7Core 초기화 전에도 동작하도록 폴백 포함)
const logger = ((window as any).G7Core?.createLogger?.('DaumPostcode:OpenPostcode')) ?? {
log: (...args: unknown[]) => console.log('[DaumPostcode:OpenPostcode]', ...args),
warn: (...args: unknown[]) => console.warn('[DaumPostcode:OpenPostcode]', ...args),
error: (...args: unknown[]) => console.error('[DaumPostcode:OpenPostcode]', ...args),
};
interface ActionWithParams {
handler: string;
params?: {
/** 주소 선택 시 실행할 콜백 액션 (Extension Point props에서 전달) */
callbackAction?: any;
/** 표시 모드 (popup | layer) */
displayMode?: string;
/** 팝업/레이어 너비 */
width?: number;
/** 팝업/레이어 높이 */
height?: number;
/** 테마 설정 */
theme?: {
searchBgColor?: string;
queryTextColor?: string;
};
/** 대상 필드 매핑 (기본 동작 시 사용) */
targetFields?: {
zipcode?: string;
address?: string;
region?: string;
city?: string;
};
};
[key: string]: any;
}
/**
* G7 표준 주소 이벤트 인터페이스
*/
interface G7AddressEvent {
/** 우편번호 */
zipcode: string;
/** 기본 주소 (도로명/지번) */
address: string;
/** 상세 주소 (사용자 입력) */
addressDetail: string;
/** 시/도 */
region: string;
/** 시/군/구 */
city: string;
/** 국가 코드 */
countryCode: string;
/** 원본 다음 API 응답 */
_raw: any;
}
/**
* 다음 우편번호 API 응답을 G7 표준 형식으로 변환합니다.
*/
function convertToG7AddressEvent(daumData: any): G7AddressEvent {
return {
zipcode: daumData.zonecode || '',
address: daumData.roadAddress || daumData.jibunAddress || '',
addressDetail: '',
region: daumData.sido || '',
city: daumData.sigungu || '',
countryCode: 'KR',
_raw: daumData,
};
}
/**
* 다음 우편번호 API를 호출하고 G7 표준 형식으로 변환하여 콜백 실행
*/
export function openPostcodeHandler(
action: ActionWithParams,
context: ActionContext
): void {
const params = action.params || {};
const {
callbackAction,
displayMode = 'layer',
width = 500,
height = 600,
theme,
targetFields,
} = params;
// 플러그인 설정에서 기본 필드 매핑 조회
const G7Core = (window as any).G7Core;
const pluginSettings = G7Core?.plugin?.getSettings?.('sirsoft-daum_postcode') || {};
const defaultTargetFields = pluginSettings.target_fields || {
zipcode: 'shipping.zipcode',
address: 'shipping.address',
region: 'shipping.region',
city: 'shipping.city',
};
logger.log('[openPostcode] Starting with params:', {
displayMode,
width,
height,
hasCallbackAction: !!callbackAction,
targetFields: targetFields || defaultTargetFields,
});
// 다음 우편번호 API가 로드되었는지 확인
if (!(window as any).daum?.Postcode) {
logger.error('[openPostcode] Daum Postcode API not loaded');
return;
}
const postcodeConfig: any = {
oncomplete: (data: any) => {
logger.log('[openPostcode] Address selected (raw):', data);
// 다음 API 응답 → G7 표준 형식 변환
const g7AddressEvent = convertToG7AddressEvent(data);
logger.log('[openPostcode] Converted to G7 format:', g7AddressEvent);
// 하이브리드 분기: callbackAction 유무에 따라 동작 결정
if (callbackAction) {
// 커스텀 동작: Extension Point에서 정의한 onAddressSelect 실행
// context를 전달하여 비동기 콜백에서도 컴포넌트 상태 참조 가능
logger.log('[openPostcode] Executing callbackAction');
executeCallbackAction(callbackAction, g7AddressEvent, context);
} else {
// 기본 동작: 플러그인 설정 기반 setState
logger.log('[openPostcode] Executing default setState');
executeDefaultSetState(targetFields || defaultTargetFields, g7AddressEvent);
}
},
width,
height,
};
// 테마 설정 적용
if (theme) {
postcodeConfig.theme = theme;
}
// 다음 우편번호 인스턴스 생성
const postcode = new (window as any).daum.Postcode(postcodeConfig);
if (displayMode === 'popup') {
// 팝업 모드: 새 창으로 열기
postcode.open();
} else {
// 레이어 모드: 오버레이에 임베드
const { layer, closeLayer } = createEmbedLayer(width, height);
// oncomplete 콜백 후 레이어 닫기 처리
const originalOncomplete = postcodeConfig.oncomplete;
postcodeConfig.oncomplete = (data: any) => {
closeLayer();
originalOncomplete(data);
};
// 새 config로 인스턴스 재생성 후 임베드
const layerPostcode = new (window as any).daum.Postcode(postcodeConfig);
layerPostcode.embed(layer);
}
}
/**
* 임베드용 레이어(오버레이) 요소를 생성합니다.
* 플러그인 설정에서 전달받은 width, height를 사용합니다.
*/
function createEmbedLayer(width: number, height: number): {
overlay: HTMLElement;
layer: HTMLElement;
closeLayer: () => void;
} {
// 오버레이 배경
const overlay = document.createElement('div');
overlay.id = 'daum-postcode-overlay';
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
`;
// 레이어 컨테이너
const layer = document.createElement('div');
layer.id = 'daum-postcode-layer';
layer.style.cssText = `
width: ${width}px;
height: ${height}px;
background-color: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
`;
// 닫기 버튼
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.innerHTML = '×';
closeButton.style.cssText = `
position: absolute;
top: -12px;
right: -12px;
width: 32px;
height: 32px;
border-radius: 50%;
background-color: #374151;
color: white;
border: none;
cursor: pointer;
font-size: 20px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
`;
// 레이어 래퍼 (닫기 버튼 포지셔닝용)
const wrapper = document.createElement('div');
wrapper.style.cssText = 'position: relative;';
wrapper.appendChild(closeButton);
wrapper.appendChild(layer);
overlay.appendChild(wrapper);
document.body.appendChild(overlay);
// 레이어 닫기 함수
const closeLayer = () => {
if (overlay.parentNode) {
overlay.parentNode.removeChild(overlay);
}
};
// 닫기 버튼 클릭 시 레이어 닫기
closeButton.addEventListener('click', closeLayer);
// 오버레이 배경 클릭 시 레이어 닫기
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
closeLayer();
}
});
// ESC 키로 레이어 닫기
const handleEscKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
closeLayer();
document.removeEventListener('keydown', handleEscKey);
}
};
document.addEventListener('keydown', handleEscKey);
logger.log('[openPostcode] Layer created with size:', { width, height });
return { overlay, layer, closeLayer };
}
/**
* callbackAction을 실행합니다.
* G7Core.dispatch의 componentContext 옵션을 활용하여 비동기 콜백에서도
* 버튼 클릭 시점의 컴포넌트 상태를 정확히 전달합니다.
*
* dispatch 내부 우선순위: options.componentContext > __g7ActionContext > global fallback
* 따라서 componentContext를 명시적으로 전달하면 __g7ActionContext 수동 관리가 불필요합니다.
*
* handlerContext.state 우선 사용 이유:
* - handleSetState의 컴포넌트 경로(isRealComponentContext=true)는 React setState만 호출하고
* globalState._local을 업데이트하지 않음
* - React 커밋 후 __g7PendingLocalState가 클리어되면 getLocal()은 stale globalState._local 반환
* - handlerContext.state는 extendedDataContext._local(globalState + localDynamicState 병합)에서
* 캡처되므로 컴포넌트 setState 결과가 반영된 최신 상태임
*/
function executeCallbackAction(callbackAction: any, g7AddressEvent: G7AddressEvent, handlerContext?: ActionContext): void {
const G7Core = (window as any).G7Core;
if (!G7Core?.dispatch) {
logger.error('[openPostcode] G7Core.dispatch not available');
return;
}
// handlerContext.state(버튼 클릭 시점 componentContext.state)를 우선 사용
// getLocal()은 globalState._local만 반환하여 컴포넌트 setState 결과 누락 가능
const currentLocalState = handlerContext?.state || G7Core?.state?.getLocal?.() || {};
// dispatch의 componentContext 옵션으로 전달
// → dispatch 내부에서 __g7ActionContext보다 우선 적용됨
const componentContext = {
state: currentLocalState,
setState: handlerContext?.setState,
data: {
...(handlerContext?.data || {}),
_local: currentLocalState,
$event: g7AddressEvent,
},
isolatedContext: handlerContext?.isolatedContext,
};
logger.log('[openPostcode] Dispatching with componentContext:', {
hasLocalState: !!currentLocalState,
sourceType: handlerContext?.state ? 'handlerContext' : 'getLocal',
localCheckout: currentLocalState?.checkout,
event: g7AddressEvent,
});
try {
const actions = Array.isArray(callbackAction) ? callbackAction : [callbackAction];
for (const cbAction of actions) {
if (cbAction && typeof cbAction === 'object') {
logger.log('[openPostcode] Dispatching action:', cbAction);
G7Core.dispatch(cbAction, { componentContext });
}
}
} catch (error) {
logger.error('[openPostcode] callbackAction failed:', error);
}
}
/**
* 기본 setState 동작을 실행합니다.
*/
function executeDefaultSetState(
targetFields: Record<string, string>,
g7AddressEvent: G7AddressEvent
): void {
const G7Core = (window as any).G7Core;
if (!G7Core?.state?.setLocal) {
logger.error('[openPostcode] G7Core.state.setLocal not available');
return;
}
// targetFields 매핑에 따라 상태 업데이트
const updates: Record<string, string> = {};
if (targetFields.zipcode) {
updates[targetFields.zipcode] = g7AddressEvent.zipcode;
}
if (targetFields.address) {
updates[targetFields.address] = g7AddressEvent.address;
}
if (targetFields.region) {
updates[targetFields.region] = g7AddressEvent.region;
}
if (targetFields.city) {
updates[targetFields.city] = g7AddressEvent.city;
}
logger.log('[openPostcode] Setting local state:', updates);
G7Core.state.setLocal(updates);
}
@@ -0,0 +1,85 @@
/**
* setFieldReadOnly 핸들러
*
* 지정된 필드명의 input 요소를 찾아 readOnly 속성을 설정합니다.
* 주소 검색 플러그인이 설치된 경우, 우편번호/주소 필드를 readOnly로 설정하여
* 사용자가 직접 입력하지 못하도록 합니다.
*/
import type { ActionContext } from '../types';
// Logger 설정 (G7Core 초기화 전에도 동작하도록 폴백 포함)
const logger = ((window as any).G7Core?.createLogger?.('DaumPostcode:SetFieldReadOnly')) ?? {
log: (...args: unknown[]) => console.log('[DaumPostcode:SetFieldReadOnly]', ...args),
warn: (...args: unknown[]) => console.warn('[DaumPostcode:SetFieldReadOnly]', ...args),
error: (...args: unknown[]) => console.error('[DaumPostcode:SetFieldReadOnly]', ...args),
};
interface ActionWithParams {
handler: string;
params?: {
/** readOnly로 설정할 필드명 배열 */
fields?: string[];
/** readOnly 값 (기본값: true) */
readOnly?: boolean;
};
[key: string]: any;
}
/**
* 지정된 필드명의 input 요소를 찾아 readOnly 속성을 설정합니다.
*
* @param action 액션 객체 (params.fields: 필드명 배열, params.readOnly: boolean)
* @param _context 액션 컨텍스트
*
* @example
* // 레이아웃 JSON에서 사용
* {
* "handler": "sirsoft-daum_postcode.setFieldReadOnly",
* "params": {
* "fields": ["zonecode", "address"],
* "readOnly": true
* }
* }
*/
export function setFieldReadOnlyHandler(
action: ActionWithParams,
_context: ActionContext
): void {
const params = action.params || {};
const fields = params.fields || [];
const readOnly = params.readOnly !== false; // 기본값 true
if (!fields.length) {
logger.warn('[setFieldReadOnly] No fields specified');
return;
}
logger.log(`[setFieldReadOnly] Setting readOnly=${readOnly} for fields:`, fields);
// 각 필드에 대해 DOM에서 input 요소를 찾아 readOnly 설정
fields.forEach((fieldName) => {
// name 속성으로 input 요소 찾기
const inputs = document.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>(
`input[name="${fieldName}"], textarea[name="${fieldName}"]`
);
if (inputs.length === 0) {
logger.warn(`[setFieldReadOnly] No input found with name="${fieldName}"`);
return;
}
inputs.forEach((input) => {
input.readOnly = readOnly;
// readOnly 상태에 따른 시각적 피드백 (선택적)
if (readOnly) {
input.classList.add('readonly');
} else {
input.classList.remove('readonly');
}
logger.log(`[setFieldReadOnly] Set readOnly=${readOnly} on input[name="${fieldName}"]`);
});
});
}
@@ -0,0 +1,117 @@
/**
* sirsoft-daum_postcode 플러그인 엔트리포인트
*
* 플러그인 활성화 시 자동으로 로드되어 핸들러를 등록합니다.
*/
// 핸들러 맵 임포트
import { handlerMap } from './handlers';
// 플러그인 식별자
const PLUGIN_IDENTIFIER = 'sirsoft-daum_postcode';
// Logger 설정 (G7Core 초기화 전에도 동작하도록 폴백 포함)
const logger = ((window as any).G7Core?.createLogger?.(`Plugin:${PLUGIN_IDENTIFIER}`)) ?? {
log: (...args: unknown[]) => console.log(`[Plugin:${PLUGIN_IDENTIFIER}]`, ...args),
warn: (...args: unknown[]) => console.warn(`[Plugin:${PLUGIN_IDENTIFIER}]`, ...args),
error: (...args: unknown[]) => console.error(`[Plugin:${PLUGIN_IDENTIFIER}]`, ...args),
};
/**
* 핸들러 등록 함수 (내부용)
*
* ActionDispatcher에 핸들러를 등록합니다.
* retry 옵션으로 ActionDispatcher가 없을 때 재시도 여부를 제어합니다.
*
* @param retry 재시도 여부 (기본: false)
*/
function registerHandlers(retry: boolean = false): void {
const actionDispatcher = (window as any).G7Core?.getActionDispatcher?.();
if (actionDispatcher) {
// handlerMap의 모든 핸들러를 자동으로 등록
// 네임스페이스: {plugin-identifier}.{handler-name}
Object.entries(handlerMap).forEach(([name, handler]) => {
const fullName = `${PLUGIN_IDENTIFIER}.${name}`;
actionDispatcher.registerHandler(fullName, handler, {
category: 'plugin',
source: PLUGIN_IDENTIFIER,
});
});
logger.log(
`${Object.keys(handlerMap).length} handler(s) registered:`,
Object.keys(handlerMap).map(name => `${PLUGIN_IDENTIFIER}.${name}`)
);
} else if (retry) {
// 최초 로드 시에만 재시도 (언어 전환 등 재초기화 시에는 재시도하지 않음)
let retryCount = 0;
const maxRetries = 50; // 최대 5초 대기 (50 * 100ms)
const retryRegister = () => {
const dispatcher = (window as any).G7Core?.getActionDispatcher?.();
if (dispatcher) {
Object.entries(handlerMap).forEach(([name, handler]) => {
const fullName = `${PLUGIN_IDENTIFIER}.${name}`;
dispatcher.registerHandler(fullName, handler, {
category: 'plugin',
source: PLUGIN_IDENTIFIER,
});
});
logger.log(
`${Object.keys(handlerMap).length} handler(s) registered:`,
Object.keys(handlerMap).map(name => `${PLUGIN_IDENTIFIER}.${name}`)
);
} else {
retryCount++;
if (retryCount <= maxRetries) {
logger.warn(
`ActionDispatcher not found, retrying... (${retryCount}/${maxRetries})`
);
setTimeout(retryRegister, 100);
} else {
logger.error(
`Failed to register handlers: ActionDispatcher not available after maximum retries`
);
}
}
};
retryRegister();
} else {
logger.warn(
`ActionDispatcher not found, handlers not registered`
);
}
}
/**
* 플러그인 초기화 함수
*
* ActionDispatcher에 핸들러를 등록합니다.
* 플러그인 식별자를 네임스페이스로 사용하여 다른 플러그인과 충돌을 방지합니다.
*/
export function initPlugin(): void {
// DOMContentLoaded 이벤트 사용 (DOM 파싱 완료 후)
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => registerHandlers(true));
} else {
// DOM이 이미 로드된 경우 즉시 실행
const hasDispatcher = !!(window as any).G7Core?.getActionDispatcher?.();
registerHandlers(hasDispatcher ? false : true);
}
}
// 플러그인 초기화 자동 실행
initPlugin();
// 전역 객체에 플러그인 노출 (디버깅용)
if (typeof window !== 'undefined') {
(window as any).__SirsoftDaumPostcode = {
identifier: PLUGIN_IDENTIFIER,
handlers: Object.keys(handlerMap),
initPlugin,
};
}
@@ -0,0 +1,29 @@
/**
* sirsoft-daum_postcode 플러그인 타입 정의
*/
/**
* 액션 컨텍스트 인터페이스
*
* ActionDispatcher에서 핸들러 실행 시 전달되는 컨텍스트입니다.
*/
export interface ActionContext {
/** 현재 로컬 상태 가져오기 */
getLocalState?: () => Record<string, any>;
/** 로컬 상태 업데이트 */
setLocalState?: (updates: Record<string, any>) => void;
/** 전역 상태 가져오기 */
getGlobalState?: () => Record<string, any>;
/** 전역 상태 업데이트 */
setGlobalState?: (updates: Record<string, any>) => void;
/** 라우트 파라미터 */
route?: Record<string, string>;
/** 쿼리 파라미터 */
query?: Record<string, string>;
/** 네비게이션 함수 */
navigate?: (path: string) => void;
/** 이벤트 객체 */
event?: Event;
/** 데이터 컨텍스트 */
dataContext?: Record<string, any>;
}
@@ -0,0 +1,51 @@
{
"search_address": "Search Address",
"postcode": {
"search": "Search Address",
"search_button": "Find Address",
"placeholder": "Search for an address",
"zonecode": "Postal Code",
"address": "Address",
"road_address": "Road Address",
"jibun_address": "Jibun Address",
"detail_address": "Detail Address",
"detail_placeholder": "Enter detail address",
"building_name": "Building Name",
"selected": "Address has been selected.",
"error": {
"load_failed": "Failed to load postcode service.",
"search_failed": "Address search failed."
}
},
"settings": {
"title": "Daum Postcode Settings",
"description": "Configure the display mode and style of Daum Postcode service.",
"notice": "Daum Postcode service is free to use without API key.",
"guide": {
"title": "Usage Guide",
"step1": "Select display mode. Popup opens in a new window, Layer displays as an overlay on the current page.",
"step2": "If popup mode is selected, set the popup window size.",
"step3": "Customize the postcode search window style by setting the theme color."
},
"fields": {
"display_mode": {
"label": "Display Mode",
"hint": "Select how to display the address search window.",
"popup": "Popup",
"layer": "Layer"
},
"popup_width": {
"label": "Popup Width (px)",
"hint": "Window width used in popup mode."
},
"popup_height": {
"label": "Popup Height (px)",
"hint": "Window height used in popup mode."
},
"theme_color": {
"label": "Theme Color",
"hint": "Theme color for the postcode search window. (e.g., #1D4ED8)"
}
}
}
}
@@ -0,0 +1,51 @@
{
"search_address": "주소 검색",
"postcode": {
"search": "주소 검색",
"search_button": "주소 찾기",
"placeholder": "주소를 검색하세요",
"zonecode": "우편번호",
"address": "기본 주소",
"road_address": "도로명 주소",
"jibun_address": "지번 주소",
"detail_address": "상세 주소",
"detail_placeholder": "상세 주소를 입력하세요",
"building_name": "건물명",
"selected": "주소가 선택되었습니다.",
"error": {
"load_failed": "우편번호 서비스를 불러오는데 실패했습니다.",
"search_failed": "주소 검색에 실패했습니다."
}
},
"settings": {
"title": "Daum 우편번호 설정",
"description": "Daum 우편번호 서비스의 표시 방식과 스타일을 설정합니다.",
"notice": "Daum 우편번호 서비스는 API 키 없이 무료로 사용할 수 있습니다.",
"guide": {
"title": "사용 안내",
"step1": "표시 방식을 선택합니다. 팝업은 새 창으로, 레이어는 현재 페이지 위에 오버레이로 표시됩니다.",
"step2": "팝업 모드를 선택한 경우, 팝업 창의 크기를 설정합니다.",
"step3": "테마 색상을 설정하여 우편번호 검색 창의 스타일을 커스터마이징할 수 있습니다."
},
"fields": {
"display_mode": {
"label": "표시 방식",
"hint": "주소 검색 창을 표시하는 방식을 선택합니다.",
"popup": "팝업",
"layer": "레이어"
},
"popup_width": {
"label": "팝업 너비 (px)",
"hint": "팝업 모드에서 사용되는 창 너비입니다."
},
"popup_height": {
"label": "팝업 높이 (px)",
"hint": "팝업 모드에서 사용되는 창 높이입니다."
},
"theme_color": {
"label": "테마 색상",
"hint": "우편번호 검색 창의 테마 색상입니다. (예: #1D4ED8)"
}
}
}
}
@@ -0,0 +1,624 @@
{
"version": "1.0.0",
"layout_name": "plugin_settings",
"permissions": [
"core.plugins.update"
],
"extends": "_admin_base",
"meta": {
"title": "$t:sirsoft-daum_postcode.settings.title",
"description": "$t:sirsoft-daum_postcode.settings.description"
},
"data_sources": [
{
"id": "settings",
"type": "api",
"endpoint": "/api/admin/plugins/{{route.identifier}}/settings",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"initLocal": "form",
"refetchOnMount": true
}
],
"slots": {
"content": [
{
"id": "plugin_settings_content",
"type": "basic",
"name": "Div",
"blur_until_loaded": "{{_local.isSaving}}",
"dataKey": "form",
"trackChanges": true,
"props": {
"className": "p-4 sm:p-6 lg:p-8 bg-gray-50 dark:bg-gray-900 min-h-screen"
},
"children": [
{
"id": "page_header",
"type": "composite",
"name": "PageHeader",
"props": {
"title": "$t:sirsoft-daum_postcode.settings.title",
"description": "$t:sirsoft-daum_postcode.settings.description",
"backPath": "/admin/plugins",
"backLabel": "$t:admin.plugins.back_to_list"
}
},
{
"id": "notice_banner",
"type": "basic",
"name": "Div",
"props": {
"className": "mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex gap-3"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "circle-info",
"className": "w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
}
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-blue-700 dark:text-blue-300"
},
"text": "$t:sirsoft-daum_postcode.settings.notice"
}
]
}
]
},
{
"id": "validation_error",
"type": "basic",
"name": "Div",
"if": "{{_local.errors}}",
"props": {
"className": "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mt-6"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex gap-3"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "triangle-exclamation",
"className": "w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5"
}
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-sm font-medium text-red-800 dark:text-red-200"
},
"text": "$t:admin.plugins.settings.validation_error"
},
{
"type": "basic",
"name": "Ul",
"if": "{{typeof _local.errors === 'object' && Object.keys(_local.errors).length > 0}}",
"props": {
"className": "mt-2 list-disc list-inside space-y-1"
},
"iteration": {
"source": "Object.entries(_local.errors ?? {}).flatMap(([field, messages]) => Array.isArray(messages) ? messages : [messages])",
"item_var": "message"
},
"children": [
{
"type": "basic",
"name": "Li",
"props": {
"className": "text-sm text-red-700 dark:text-red-300"
},
"text": "{{message}}"
}
]
}
]
}
]
}
]
},
{
"id": "settings_form",
"type": "basic",
"name": "Div",
"props": {
"className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700"
},
"children": [
{
"id": "settings_fields",
"type": "basic",
"name": "Div",
"props": {
"className": "p-6 space-y-6"
},
"children": [
{
"id": "field_display_mode",
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-1 lg:grid-cols-3 gap-4 items-start"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "lg:col-span-1"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300"
},
"text": "$t:sirsoft-daum_postcode.settings.fields.display_mode.label"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "mt-1 text-xs text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-daum_postcode.settings.fields.display_mode.hint"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "lg:col-span-2"
},
"children": [
{
"type": "basic",
"name": "Select",
"props": {
"name": "display_mode",
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
},
"children": [
{
"type": "basic",
"name": "Option",
"props": {
"value": "popup"
},
"text": "popup"
},
{
"type": "basic",
"name": "Option",
"props": {
"value": "layer"
},
"text": "layer"
}
]
}
]
}
]
},
{
"id": "field_popup_width",
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-1 lg:grid-cols-3 gap-4 items-start"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "lg:col-span-1"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300"
},
"text": "$t:sirsoft-daum_postcode.settings.fields.popup_width.label"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "mt-1 text-xs text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-daum_postcode.settings.fields.popup_width.hint"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "lg:col-span-2"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "number",
"name": "popup_width",
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
}
}
]
}
]
},
{
"id": "field_popup_height",
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-1 lg:grid-cols-3 gap-4 items-start"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "lg:col-span-1"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300"
},
"text": "$t:sirsoft-daum_postcode.settings.fields.popup_height.label"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "mt-1 text-xs text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-daum_postcode.settings.fields.popup_height.hint"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "lg:col-span-2"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "number",
"name": "popup_height",
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
}
}
]
}
]
},
{
"id": "field_theme_color",
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-1 lg:grid-cols-3 gap-4 items-start"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "lg:col-span-1"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300"
},
"text": "$t:sirsoft-daum_postcode.settings.fields.theme_color.label"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "mt-1 text-xs text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-daum_postcode.settings.fields.theme_color.hint"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "lg:col-span-2"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"name": "theme_color",
"placeholder": "#1D4ED8",
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
}
}
]
}
]
}
]
}
]
},
{
"id": "guide_section",
"type": "basic",
"name": "Div",
"props": {
"className": "mt-6 bg-gray-50 dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-gray-700 p-6"
},
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white mb-4"
},
"text": "$t:sirsoft-daum_postcode.settings.guide.title"
},
{
"type": "basic",
"name": "Ol",
"props": {
"className": "list-decimal list-inside space-y-3"
},
"children": [
{
"type": "basic",
"name": "Li",
"props": {
"className": "text-sm text-gray-600 dark:text-gray-400"
},
"text": "$t:sirsoft-daum_postcode.settings.guide.step1"
},
{
"type": "basic",
"name": "Li",
"props": {
"className": "text-sm text-gray-600 dark:text-gray-400"
},
"text": "$t:sirsoft-daum_postcode.settings.guide.step2"
},
{
"type": "basic",
"name": "Li",
"props": {
"className": "text-sm text-gray-600 dark:text-gray-400"
},
"text": "$t:sirsoft-daum_postcode.settings.guide.step3"
}
]
}
]
},
{
"id": "footer_buttons",
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end gap-3 mt-6"
},
"children": [
{
"id": "cancel_button",
"type": "basic",
"name": "Button",
"props": {
"className": "px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 font-medium transition-colors"
},
"actions": [
{
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/plugins"
}
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:common.cancel"
}
]
},
{
"id": "save_button",
"type": "basic",
"name": "Button",
"props": {
"disabled": "{{!_local.hasChanges || _local.isSaving}}",
"className": "flex items-center gap-1.5 px-4 py-2 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-100 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": true,
"errors": null
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/admin/plugins/{{route.identifier}}/settings",
"params": {
"method": "PUT",
"body": "{{_local.form}}"
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": false,
"hasChanges": false
}
},
{
"handler": "toast",
"params": {
"type": "success",
"message": "$t:admin.plugins.settings.save_success"
}
},
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "settings"
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": false,
"errors": "{{$error.errors}}"
}
},
{
"handler": "toast",
"params": {
"type": "error",
"message": "$t:admin.plugins.settings.save_error"
}
}
]
}
]
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"if": "{{_local.isSaving}}",
"props": {
"name": "spinner",
"className": "w-4 h-4 animate-spin"
}
},
{
"type": "basic",
"name": "Span",
"if": "{{!_local.isSaving}}",
"text": "$t:common.save"
},
{
"type": "basic",
"name": "Span",
"if": "{{_local.isSaving}}",
"text": "$t:common.saving"
}
]
}
]
}
]
}
]
},
"pageConfig": {
"notice": "$t:sirsoft-daum_postcode.settings.notice",
"guide": {
"title": "$t:sirsoft-daum_postcode.settings.guide.title",
"steps": [
"$t:sirsoft-daum_postcode.settings.guide.step1",
"$t:sirsoft-daum_postcode.settings.guide.step2",
"$t:sirsoft-daum_postcode.settings.guide.step3"
]
}
},
"schema": {
"display_mode": {
"type": "enum",
"options": [
"popup",
"layer"
],
"default": "layer",
"label": "$t:sirsoft-daum_postcode.settings.fields.display_mode.label",
"hint": "$t:sirsoft-daum_postcode.settings.fields.display_mode.hint"
},
"popup_width": {
"type": "integer",
"default": 500,
"label": "$t:sirsoft-daum_postcode.settings.fields.popup_width.label",
"hint": "$t:sirsoft-daum_postcode.settings.fields.popup_width.hint"
},
"popup_height": {
"type": "integer",
"default": 600,
"label": "$t:sirsoft-daum_postcode.settings.fields.popup_height.label",
"hint": "$t:sirsoft-daum_postcode.settings.fields.popup_height.hint"
},
"theme_color": {
"type": "string",
"default": "#1D4ED8",
"label": "$t:sirsoft-daum_postcode.settings.fields.theme_color.label",
"hint": "$t:sirsoft-daum_postcode.settings.fields.theme_color.hint"
}
}
}
@@ -0,0 +1,4 @@
{
"version": "1.0.0",
"routes": []
}
@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Paths */
"baseUrl": ".",
"paths": {
"@/*": ["resources/js/*"]
}
},
"include": ["resources/js/**/*.ts"]
}
@@ -0,0 +1,47 @@
import { defineConfig } from 'vite';
import path from 'path';
export default defineConfig({
define: {
'process.env.NODE_ENV': JSON.stringify('production'),
},
build: {
// 라이브러리 모드 설정
lib: {
entry: path.resolve(__dirname, 'resources/js/index.ts'),
name: 'SirsoftDaumPostcode', // 전역 변수명 (IIFE 모드용)
fileName: 'plugin',
formats: ['iife'], // IIFE 포맷만 빌드
},
// 빌드 출력 설정
outDir: 'dist',
emptyOutDir: true,
// 소스맵 생성
sourcemap: true,
rollupOptions: {
output: {
// JS 파일명 패턴
entryFileNames: 'js/plugin.iife.js',
chunkFileNames: 'js/[name]-[hash].js',
},
},
// 빌드 최적화
minify: 'esbuild',
target: 'es2020',
// Chunk 크기 경고 임계값
chunkSizeWarningLimit: 500,
},
// 타입스크립트 설정
resolve: {
alias: {
'@': path.resolve(__dirname, 'resources/js'),
},
},
});
@@ -0,0 +1,108 @@
# Changelog
이 프로젝트의 모든 주요 변경사항을 기록합니다.
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.0-beta.1] - 2026-04-01
### Changed
- 오픈 베타 릴리즈
## [0.3.3] - 2026-03-30
### Fixed
- 관리자 다크모드 레이아웃 수정 — plugin_settings.json에서 누락된 `dark:` variant 약 10건 추가 (text-gray, focus:ring 등)
## [0.3.2] - 2026-03-30
### Added
- `config/settings/defaults.json` 추가 — frontend_schema 정의로 프론트엔드 설정 노출 제어
- `marketing_consent_terms_slug`, `third_party_consent_terms_slug`, `info_disclosure_terms_slug`, `channels`를 `expose: false` 처리 — 프론트엔드 미참조 설정의 보안 노출 차단
## [0.3.1] - 2026-03-30
### Fixed
- 인덱스명 약어 수정 — `idx_mkt_consent_hist_user_created` → `idx_marketing_consent_histories_user_created` (네이밍 규칙 준수)
## [0.3.0] - 2026-03-29
### Added
- 검색/필터/정렬 성능 향상을 위한 누락 인덱스 추가 — user_marketing_consent_histories([user_id+created_at] 사용자별 시간순 이력 조회)
## [0.2.1] - 2026-03-28
### Fixed
- 채널 삭제 시 즉시 API 호출로 반영되던 문제 수정 — 삭제 후 최종 저장 버튼 클릭 시에만 반영되도록 변경
- 저장 버튼이 channels API 검증을 거치도록 2단계 순차 호출로 변경 (channels 검증 → 설정 저장)
- 비활성화 모달(channel_deactivate_modal)이 즉시 API 호출 대신 상태만 변경하도록 수정
## [0.2.0] - 2026-03-24
### Added
- 마케팅 동의 데이터를 EAV 구조 기반 전용 테이블(`user_marketing_consents`, `user_marketing_consent_histories`)로 전환 — 동의 항목별 독립 저장 및 이력 관리
- 각 동의 항목의 활성화 여부를 독립 설정(`*_enabled`)으로 분리하고 비활성 항목은 가입/수정 폼에서 자동 제외
- 마케팅 동의 현황 조회 및 채널별 동의 현황 조회 공개 API 추가
- 관리자 사용자 상세 화면에서 마케팅 동의 현황 및 이력 조회 기능 추가 (MarketingAdminController)
- 동의항목 레이아웃을 1열 구성으로 변경하고 약관 모달에서 실제 약관 내용 표시 구현
- 채널 관리 UI 추가 — 채널 추가/편집/삭제 및 활성화 토글 기능
- 데이터 접근 계층을 Repository 패턴으로 분리하여 테스트 용이성과 유지보수성 개선
- PHP 백엔드에서 다국어 메시지를 사용할 수 있도록 PHP 전용 lang 파일 추가 (ko/en)
- 플러그인 레이아웃 JSON 파일 전체를 대상으로 렌더링 테스트 56개 작성 — 구조, 데이터 바인딩, 다국어, 다크모드 항목 검증
- 플러그인 프론트엔드 테스트 환경(Vitest) 구성
- 동의자가 있는 채널을 삭제하려 할 때 즉시 삭제 대신 비활성화를 안내하는 모달 추가
- 채널 이름을 한국어/영어로 각각 표시할 수 있도록 다국어 처리 추가
### Fixed
- 채널 편집 모달 바깥 영역 클릭 시 실수로 닫히는 문제 수정
- 스피너·배지 컴포넌트에서 다크모드 색상이 적용되지 않던 문제 수정
- 마이페이지 프로필 저장 시 국가·언어 미선택 상태에서 오류가 발생하던 문제 수정
- 동일 이벤트가 여러 번 발생할 때 동의 이력이 중복 저장되던 문제 수정
### Changed
- 관리자 화면 사용자 상세 폼의 다크모드 스타일 전반 개선
## [0.1.3] - 2026-03-16
### Changed
- 마이그레이션 통합 — 증분 마이그레이션을 테이블당 1개 create 마이그레이션으로 정리
- 라이선스 프로그램 명칭 정비
## [0.1.2] - 2026-03-13
### Added
- manifest에 license 필드 및 LICENSE 파일 추가
### Changed
- 설정 레이아웃 경로를 `resources/layouts/settings.json` → `resources/layouts/admin/plugin_settings.json`으로 이동 (모듈과 동일한 구조 통일)
## [0.1.1] - 2026-02-24
### Changed
- 버전 체계 조정 (정식 출시 전 0.x 체계로 변경)
## [0.1.0] - 2026-02-23
### Added
- 마케팅 동의 플러그인 초기 구현
- 이메일 구독, 마케팅 동의, 제3자 제공 동의 관리
- MarketingConsent, MarketingConsentHistory 모델
- MarketingConsentService 비즈니스 로직
- MarketingConsentListener 훅 리스너
- 사용자 관리 화면 레이아웃 확장 (마케팅 동의 상세/폼/프로필)
- 회원가입 폼 마케팅 동의 항목 확장
- 플러그인 설정 UI
- 역할 기반 접근 제어 (마케팅 관리자)
- 다국어 지원 (ko, en)
@@ -0,0 +1,47 @@
프로그램 명칭 : 그누보드7용 마케팅 동의 플러그인 (sirsoft-marketing)
저작자 : (주)에스아이알소프트
----- MIT 라이선스 (한국어 번역) --------------------------------------------------------
MIT 라이선스
Copyright (c) 2026 (주)에스아이알소프트
이 소프트웨어와 관련 문서 파일(이하 "소프트웨어")의 복사본을 취득하는 모든 사람에게
소프트웨어를 제한 없이 사용, 복사, 수정, 병합, 출판, 배포, 서브라이선스 허여 및/또는
판매할 수 있는 권리를 무상으로 부여합니다. 다만, 소프트웨어를 제공받은 사람은 다음
조건을 따라야 합니다:
위 저작권 고지와 본 허가 고지는 소프트웨어의 모든 복사본 또는 상당 부분에 포함되어야
합니다.
소프트웨어는 "있는 그대로" 제공되며, 명시적이든 묵시적이든 어떠한 종류의 보증도 하지
않습니다. 여기에는 상품성, 특정 목적에의 적합성 및 비침해에 대한 보증이 포함되나 이에
국한되지 않습니다. 어떠한 경우에도 저작자 또는 저작권자는 소프트웨어나 소프트웨어의
사용 또는 기타 거래로 인해 발생하는 계약, 불법행위 또는 기타 청구, 손해 또는 기타
책임에 대해 책임을 지지 않습니다.
----- MIT License (English Original) --------------------------------------------------------
The MIT License (MIT)
Copyright (c) 2026 SIRSOFT
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,14 @@
{
"name": "plugins/sirsoft-marketing",
"description": "Marketing consent and subscription management plugin for Gnuboard7 platform",
"type": "library",
"version": "1.0.0-beta.1",
"autoload": {
"psr-4": {
"Plugins\\Sirsoft\\Marketing\\": ["src/", "./"]
}
},
"require": {
"php": "^8.2"
}
}
@@ -0,0 +1,15 @@
{
"_meta": {
"version": "1.0.0",
"description": "마케팅 동의 플러그인 환경설정 기본값 및 프론트엔드 스키마"
},
"frontend_schema": {
"marketing_consent_enabled": { "type": "boolean", "expose": true },
"marketing_consent_terms_slug": { "type": "string", "expose": false },
"third_party_consent_enabled": { "type": "boolean", "expose": true },
"third_party_consent_terms_slug": { "type": "string", "expose": false },
"info_disclosure_enabled": { "type": "boolean", "expose": true },
"info_disclosure_terms_slug": { "type": "string", "expose": false },
"channels": { "type": "string", "expose": false }
}
}
@@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::dropIfExists('user_marketing_consents');
Schema::create('user_marketing_consents', function (Blueprint $table) {
$table->id()->comment('고유 ID');
$table->foreignId('user_id')
->constrained('users')
->cascadeOnDelete()
->comment('사용자 ID');
$table->string('consent_key', 30)->comment('동의 항목 키 (email_subscription, marketing_consent 등)');
$table->boolean('is_consented')->default(false)->comment('현재 동의 여부');
$table->timestamp('consented_at')->nullable()->comment('최근 동의 일시');
$table->timestamp('revoked_at')->nullable()->comment('최근 철회 일시');
$table->unsignedInteger('consent_count')->default(0)->comment('총 동의 횟수');
$table->string('last_source', 20)->nullable()->comment('최근 변경 경로 (register/profile/admin)');
$table->timestamps();
$table->unique(['user_id', 'consent_key']);
$table->index(['consent_key', 'is_consented']);
});
if (DB::getDriverName() == 'mysql') {
Schema::table('user_marketing_consents', function (Blueprint $table) {
$table->comment('사용자 마케팅 동의 현황');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::hasTable('user_marketing_consents')) {
Schema::dropIfExists('user_marketing_consents');
}
}
};
@@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::dropIfExists('user_marketing_consent_histories');
Schema::create('user_marketing_consent_histories', function (Blueprint $table) {
$table->id()->comment('고유 ID');
$table->foreignId('user_id')
->constrained('users')
->cascadeOnDelete()
->comment('사용자 ID');
$table->string('channel_key', 30)->comment('동의 항목 키 (email_subscription, marketing_consent 등)');
$table->string('action', 10)->comment('변경 유형 (granted/revoked)');
$table->string('source', 20)->comment('변경 경로 (register/profile/admin)');
$table->string('ip_address', 45)->nullable()->comment('IP 주소');
$table->timestamp('created_at')->nullable()->comment('생성 일시');
$table->index(['user_id', 'channel_key']);
});
if (DB::getDriverName() == 'mysql') {
Schema::table('user_marketing_consent_histories', function (Blueprint $table) {
$table->comment('마케팅 동의 변경 이력');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::hasTable('user_marketing_consent_histories')) {
Schema::dropIfExists('user_marketing_consent_histories');
}
}
};
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* user_marketing_consent_histories 테이블 인덱스 추가.
*
* - [user_id, created_at]: 사용자별 시간순 이력 조회
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('user_marketing_consent_histories', function (Blueprint $table) {
$table->index(['user_id', 'created_at'], 'idx_marketing_consent_histories_user_created');
});
}
public function down(): void
{
if (! Schema::hasTable('user_marketing_consent_histories')) {
return;
}
$existingIndexes = array_column(Schema::getIndexes('user_marketing_consent_histories'), 'name');
Schema::table('user_marketing_consent_histories', function (Blueprint $table) use ($existingIndexes) {
if (in_array('idx_marketing_consent_histories_user_created', $existingIndexes)) {
$table->dropIndex('idx_marketing_consent_histories_user_created');
}
});
}
};
@@ -0,0 +1,9 @@
<?php
return [
'channels_saved' => 'Channels saved successfully.',
'channel_key_duplicate' => 'Duplicate channel key found.',
'channel_system_protected' => 'The is_system flag of a system channel cannot be changed.',
'channel_system_cannot_delete' => 'System channel (:key) cannot be deleted.',
'channel_has_consents' => 'Channel (:key) has :count consented member(s) and cannot be deleted.',
];
@@ -0,0 +1,9 @@
<?php
return [
'channels_saved' => '채널이 저장되었습니다.',
'channel_key_duplicate' => '채널 키가 중복되었습니다.',
'channel_system_protected' => '시스템 채널의 is_system 값은 변경할 수 없습니다.',
'channel_system_cannot_delete' => '시스템 채널(:key)은 삭제할 수 없습니다.',
'channel_has_consents' => '채널(:key)에 동의한 회원이 :count명 있어 삭제할 수 없습니다.',
];
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,17 @@
{
"name": "@g7/sirsoft-marketing",
"version": "1.0.0-beta.1",
"description": "G7 마케팅 동의 플러그인 프론트엔드 에셋",
"private": true,
"type": "module",
"scripts": {
"test": "vitest",
"test:run": "vitest run"
},
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
"vite": "^5.4.14",
"vitest": "^2.1.8"
}
}
@@ -0,0 +1,23 @@
{
"identifier": "sirsoft-marketing",
"vendor": "sirsoft",
"name": {
"ko": "마케팅 동의",
"en": "Marketing Consent"
},
"version": "1.0.0-beta.1",
"description": {
"ko": "이메일 구독, 마케팅 동의, 제3자 제공 동의 등을 관리하는 플러그인",
"en": "Plugin for managing email subscriptions, marketing consent, and third-party data sharing consent"
},
"license": "MIT",
"g7_version": ">=7.0.0-beta.1",
"dependencies": {
"modules": {
"sirsoft-page": ">=0.1.0"
},
"plugins": {}
},
"github_url": "https://github.com/gnuboard/g7-plugin-sirsoft-marketing",
"github_changelog_url": "https://github.com/gnuboard/g7-plugin-sirsoft-marketing/blob/main/CHANGELOG.md"
}
@@ -0,0 +1,211 @@
<?php
namespace Plugins\Sirsoft\Marketing;
use App\Extension\AbstractPlugin;
class Plugin extends AbstractPlugin
{
/**
* 플러그인이 제공하는 훅 정보 반환
*
* @return array 훅 정의 배열
*/
public function getHooks(): array
{
return [
[
'name' => 'sirsoft-marketing.user.consent_changed',
'type' => 'action',
'description' => [
'ko' => '사용자 마케팅 동의 변경 시 실행되는 액션 훅',
'en' => 'Action hook executed when user marketing consent changes',
],
'parameters' => [
'consent' => 'Model - 마케팅 동의 모델 (MarketingConsent)',
'context' => "array - 변경 컨텍스트 ['source' => string, 'data' => array]",
],
],
[
'name' => 'sirsoft-marketing.user.subscribed',
'type' => 'action',
'description' => [
'ko' => '사용자 마케팅 동의 필드가 동의(granted)로 변경될 때 실행되는 액션 훅',
'en' => 'Action hook executed when a marketing consent field is granted',
],
'parameters' => [
'consent' => 'Model - 마케팅 동의 모델 (MarketingConsent)',
'field' => 'string - 변경된 동의 필드 (email_subscription, marketing_consent 등)',
],
],
[
'name' => 'sirsoft-marketing.user.unsubscribed',
'type' => 'action',
'description' => [
'ko' => '사용자 마케팅 동의 필드가 철회(revoked)로 변경될 때 실행되는 액션 훅',
'en' => 'Action hook executed when a marketing consent field is revoked',
],
'parameters' => [
'consent' => 'Model - 마케팅 동의 모델 (MarketingConsent)',
'field' => 'string - 변경된 동의 필드 (email_subscription, marketing_consent 등)',
],
],
[
'name' => 'sirsoft-marketing.filter_consent_data',
'type' => 'filter',
'description' => [
'ko' => '마케팅 동의 데이터를 필터링하는 훅',
'en' => 'Filter hook to modify marketing consent data',
],
'parameters' => [
'data' => 'array - 동의 데이터',
'user' => 'Model - 사용자 모델',
],
'return' => 'array - 필터링된 동의 데이터',
],
];
}
/**
* 플러그인 설정 값 반환
*
* @return array 설정 값 배열
*/
public function getConfigValues(): array
{
return [
// 마케팅 전체 동의 사용 여부 및 약관 slug
'marketing_consent_enabled' => true,
'marketing_consent_terms_slug' => 'marketing-terms',
// 법적 동의 항목 사용 여부 및 약관 slug
'third_party_consent_enabled' => true,
'third_party_consent_terms_slug' => '',
'info_disclosure_enabled' => true,
'info_disclosure_terms_slug' => '',
// 채널 목록 (JSON 배열 문자열)
'channels' => json_encode([
[
'key' => 'email_subscription',
'label' => ['ko' => '광고성 이메일 수신', 'en' => 'Email Marketing'],
'page_slug' => '',
'enabled' => true,
'is_system' => true,
],
]),
];
}
/**
* 플러그인 설정 스키마 반환
*
* @return array 설정 스키마
*/
public function getSettingsSchema(): array
{
return [
'marketing_consent_enabled' => [
'type' => 'boolean',
'default' => true,
'label' => ['ko' => '마케팅 동의 사용', 'en' => 'Enable Marketing Consent'],
'hint' => ['ko' => '비활성화 시 모든 화면에서 마케팅 동의 항목이 숨겨집니다.', 'en' => 'When disabled, the marketing consent item is hidden from all screens.'],
'required' => false,
],
'marketing_consent_terms_slug' => [
'type' => 'string',
'default' => 'marketing-terms',
'label' => ['ko' => '마케팅 동의 약관 페이지 Slug', 'en' => 'Marketing Consent Terms Page Slug'],
'hint' => ['ko' => '페이지 모듈에서 관리하는 약관 페이지의 SLUG를 입력하세요. 비워두면 약관 보기 버튼이 숨겨집니다.', 'en' => 'Enter the SLUG of the terms page managed by the Page module. Leave empty to hide the view terms button.'],
'required' => false,
],
'channels' => [
'type' => 'json',
'default' => '[]',
'label' => ['ko' => '채널 목록', 'en' => 'Channel List'],
'hint' => ['ko' => '마케팅 동의 채널 목록 (JSON). 관리자 UI에서 편집합니다.', 'en' => 'Marketing consent channel list (JSON). Edit via admin UI.'],
'required' => false,
],
'third_party_consent_enabled' => [
'type' => 'boolean',
'default' => true,
'label' => ['ko' => '제3자 제공 동의 사용', 'en' => 'Enable Third Party Consent'],
'required' => false,
],
'third_party_consent_terms_slug' => [
'type' => 'string',
'default' => '',
'label' => ['ko' => '제3자 제공 동의 약관 페이지 Slug', 'en' => 'Third Party Consent Terms Page Slug'],
'required' => false,
],
'info_disclosure_enabled' => [
'type' => 'boolean',
'default' => true,
'label' => ['ko' => '정보 공개 동의 사용', 'en' => 'Enable Info Disclosure Consent'],
'required' => false,
],
'info_disclosure_terms_slug' => [
'type' => 'string',
'default' => '',
'label' => ['ko' => '정보 공개 동의 약관 페이지 Slug', 'en' => 'Info Disclosure Terms Page Slug'],
'required' => false,
],
];
}
/**
* 플러그인 메타데이터 반환
*
* @return array 메타데이터 배열
*/
public function getMetadata(): array
{
return [
'author' => 'Sirsoft',
'license' => 'MIT',
'homepage' => 'https://sir.kr',
'keywords' => ['marketing', 'consent', 'subscription', 'gdpr'],
];
}
/**
* 플러그인이 동적으로 생성한 테이블 목록 반환
*
* 언인스톨 시 Manager가 자동으로 삭제합니다.
* FK 순서에 맞게 histories 먼저, consents 나중에 반환합니다.
*
* @return array 테이블명 배열
*/
public function getDynamicTables(): array
{
return [
'user_marketing_consent_histories',
'user_marketing_consents',
];
}
/**
* 플러그인 의존성 반환
*
* sirsoft-page 모듈이 필요합니다 (약관 페이지 slug 연동).
*
* @return array 모듈/플러그인 의존성 배열
*/
public function getDependencies(): array
{
return [
'sirsoft-page' => '>=0.1.0',
];
}
/**
* 훅 리스너 목록 반환
*
* @return array 훅 리스너 클래스 배열
*/
public function getHookListeners(): array
{
return [
\Plugins\Sirsoft\Marketing\Listeners\MarketingConsentListener::class,
];
}
}
@@ -0,0 +1,572 @@
{
"target_layout": "admin_user_detail",
"injections": [
{
"target_id": "user_detail_tabs",
"position": "inject_props",
"props": {
"tabs": {
"_append": [
{
"id": "ext_marketing",
"label": "$t:admin.users.form.sections.marketing_consent",
"iconName": "puzzle-piece"
}
]
}
}
},
{
"target_id": "extension_tab_content",
"position": "append_child",
"components": [
{
"id": "ext_marketing_content",
"type": "basic",
"name": "Div",
"props": {
"id": "ext_marketing",
"className": "scroll-mt-32"
},
"children": [
{
"id": "section_marketing_consent",
"type": "basic",
"name": "Div",
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center justify-between mb-4"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "bullhorn",
"size": "md",
"className": "text-gray-600 dark:text-gray-400"
}
},
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white"
},
"text": "$t:admin.users.form.sections.marketing_consent"
}
]
},
{
"type": "composite",
"name": "ExtensionBadge",
"props": {
"type": "plugin",
"identifier": "sirsoft-marketing",
"installedPlugins": "{{_global.installedPlugins}}"
}
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-1 gap-4"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-3"
},
"children": [
{
"type": "basic",
"name": "Label",
"if": "{{user?.data?.marketing_consent_enabled === true}}",
"props": {
"className": "flex items-start gap-3"
},
"children": [
{
"type": "basic",
"name": "Checkbox",
"props": {
"checked": "{{!!user?.data?.marketing_consent}}",
"disabled": true,
"className": "mt-0.5 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded cursor-not-allowed"
}
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1 space-y-1"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 flex-wrap"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-marketing.marketing_consent"
},
{
"type": "basic",
"name": "Span",
"if": "{{user?.data?.marketing_consent_at}}",
"props": {
"className": "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-400"
},
"text": "{{user?.data?.marketing_consent_at ? '$t:sirsoft-marketing.consent_date_prefix ' + new Date(user.data.marketing_consent_at).toLocaleDateString() : ''}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{!user?.data?.marketing_consent_at}}",
"props": {
"className": "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-marketing.not_agreed"
}
]
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "block text-xs text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-marketing.marketing_consent_desc"
}
]
}
]
},
{
"type": "basic",
"name": "Label",
"if": "{{user?.data?.marketing_consent_enabled === true}}",
"iteration": {
"source": "(user?.data?.channels ?? []).filter(ch => ch.enabled !== false)",
"item_var": "channel"
},
"props": {
"className": "flex items-start gap-3 ml-8"
},
"children": [
{
"type": "basic",
"name": "Checkbox",
"props": {
"checked": "{{!!user?.data?.[channel.key]}}",
"disabled": true,
"className": "mt-0.5 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded cursor-not-allowed"
}
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1 space-y-1"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 flex-wrap"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
},
"text": "{{channel.label}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{user?.data?.[channel.key + '_at']}}",
"props": {
"className": "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-400"
},
"text": "{{user?.data?.[channel.key + '_at'] ? '$t:sirsoft-marketing.consent_date_prefix ' + new Date(user.data[channel.key + '_at']).toLocaleDateString() : ''}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{!user?.data?.[channel.key + '_at']}}",
"props": {
"className": "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-marketing.not_agreed"
}
]
}
]
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-3"
},
"children": [
{
"type": "basic",
"name": "Label",
"if": "{{user?.data?.third_party_consent_enabled === true}}",
"props": {
"className": "flex items-start gap-3"
},
"children": [
{
"type": "basic",
"name": "Checkbox",
"props": {
"checked": "{{!!user?.data?.third_party_consent}}",
"disabled": true,
"className": "mt-0.5 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded cursor-not-allowed"
}
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1 space-y-1"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 flex-wrap"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-marketing.third_party_consent"
},
{
"type": "basic",
"name": "Span",
"if": "{{user?.data?.third_party_consent_at}}",
"props": {
"className": "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-400"
},
"text": "{{user?.data?.third_party_consent_at ? '$t:sirsoft-marketing.consent_date_prefix ' + new Date(user.data.third_party_consent_at).toLocaleDateString() : ''}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{!user?.data?.third_party_consent_at}}",
"props": {
"className": "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-marketing.not_agreed"
}
]
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "block text-xs text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-marketing.third_party_consent_desc"
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-1"
},
"children": [
{
"type": "basic",
"name": "Label",
"if": "{{user?.data?.info_disclosure_enabled === true}}",
"props": {
"className": "flex items-start gap-3"
},
"children": [
{
"type": "basic",
"name": "Checkbox",
"props": {
"checked": "{{!!user?.data?.info_disclosure}}",
"disabled": true,
"className": "mt-0.5 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded cursor-not-allowed"
}
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1 space-y-1"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 flex-wrap"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-marketing.info_disclosure"
},
{
"type": "basic",
"name": "Span",
"if": "{{user?.data?.info_disclosure_at}}",
"props": {
"className": "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-400"
},
"text": "{{user?.data?.info_disclosure_at ? '$t:sirsoft-marketing.consent_date_prefix ' + new Date(user.data.info_disclosure_at).toLocaleDateString() : ''}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{!user?.data?.info_disclosure_at}}",
"props": {
"className": "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-marketing.not_agreed"
}
]
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "block text-xs text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-marketing.info_disclosure_desc"
}
]
}
]
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "block text-xs text-gray-400 dark:text-gray-500 ml-8"
},
"text": "$t:sirsoft-marketing.info_disclosure_warning"
}
]
}
]
}
]
},
{
"comment": "동의 이력 테이블",
"type": "basic",
"name": "Div",
"props": {
"className": "mt-6 pt-4 border-t border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-sm font-semibold text-gray-900 dark:text-white mb-3"
},
"text": "$t:admin.users.detail.consent_history"
},
{
"comment": "이력이 없는 경우",
"type": "basic",
"name": "Div",
"if": "{{!user?.data?.consent_histories || user?.data?.consent_histories?.length === 0}}",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400 py-4 text-center"
},
"text": "$t:sirsoft-marketing.no_consent_history"
},
{
"comment": "이력 테이블",
"type": "basic",
"name": "Div",
"if": "{{user?.data?.consent_histories?.length > 0}}",
"props": {
"className": "max-h-60 overflow-y-auto overflow-x-auto"
},
"children": [
{
"type": "basic",
"name": "Table",
"props": {
"className": "min-w-full divide-y divide-gray-200 dark:divide-gray-700"
},
"children": [
{
"type": "basic",
"name": "Thead",
"props": {
"className": "sticky top-0 bg-gray-50 dark:bg-gray-900 z-10"
},
"children": [
{
"type": "basic",
"name": "Tr",
"children": [
{
"type": "basic",
"name": "Th",
"props": {
"className": "px-2 py-1.5 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
},
"text": "$t:sirsoft-marketing.history_date"
},
{
"type": "basic",
"name": "Th",
"props": {
"className": "px-2 py-1.5 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
},
"text": "$t:sirsoft-marketing.history_channel_key"
},
{
"type": "basic",
"name": "Th",
"props": {
"className": "px-2 py-1.5 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
},
"text": "$t:sirsoft-marketing.history_action"
},
{
"type": "basic",
"name": "Th",
"props": {
"className": "px-2 py-1.5 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
},
"text": "$t:sirsoft-marketing.history_source"
}
]
}
]
},
{
"type": "basic",
"name": "Tbody",
"props": {
"className": "divide-y divide-gray-200 dark:divide-gray-700"
},
"children": [
{
"type": "basic",
"name": "Tr",
"iteration": {
"source": "{{user?.data?.consent_histories ?? []}}",
"item_var": "history",
"index_var": "histIdx"
},
"children": [
{
"type": "basic",
"name": "Td",
"props": {
"className": "px-2 py-1.5 text-xs text-gray-900 dark:text-white whitespace-nowrap"
},
"text": "{{history?.created_at ?? '-'}}"
},
{
"type": "basic",
"name": "Td",
"props": {
"className": "px-2 py-1.5 text-xs text-gray-900 dark:text-white"
},
"text": "{{history?.channel_key === 'marketing_consent' ? '$t:sirsoft-marketing.marketing_consent' : history?.channel_key === 'third_party_consent' ? '$t:sirsoft-marketing.third_party_consent' : history?.channel_key === 'info_disclosure' ? '$t:sirsoft-marketing.info_disclosure' : ((user?.data?.channels ?? []).find(ch => ch.key === history?.channel_key)?.label ?? history?.channel_key ?? '-')}}"
},
{
"type": "basic",
"name": "Td",
"props": {
"className": "px-2 py-1.5 text-xs"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "{{history?.action === 'granted' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}}"
},
"text": "{{history?.action === 'granted' ? '$t:sirsoft-marketing.action_granted' : '$t:sirsoft-marketing.action_revoked'}}"
}
]
},
{
"type": "basic",
"name": "Td",
"props": {
"className": "px-2 py-1.5 text-xs text-gray-500 dark:text-gray-400"
},
"text": "{{history?.source === 'register' ? '$t:sirsoft-marketing.source_register' : history?.source === 'admin' ? '$t:sirsoft-marketing.source_admin' : '$t:sirsoft-marketing.source_profile'}}"
}
]
}
]
}
]
}
]
}
]
}
]
}
]
}
]
}
],
"priority": 200
}
@@ -0,0 +1,759 @@
{
"target_layout": "admin_user_form",
"data_sources": [
{
"id": "marketing_settings",
"type": "api",
"endpoint": "/api/plugins/sirsoft-marketing/settings",
"method": "GET",
"auto_fetch": true,
"auth_required": true
}
],
"injections": [
{
"target_id": "user_form_tabs",
"position": "inject_props",
"props": {
"tabs": {
"_append": [
{
"id": "ext_marketing",
"label": "$t:admin.users.form.sections.marketing_consent",
"iconName": "puzzle-piece"
}
]
}
}
},
{
"target_id": "extension_form_content",
"position": "append_child",
"components": [
{
"id": "ext_marketing_form_content",
"type": "basic",
"name": "Div",
"props": {
"id": "ext_marketing",
"className": "scroll-mt-32"
},
"children": [
{
"id": "section_marketing_consent",
"type": "basic",
"name": "Div",
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center justify-between mb-4"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "bullhorn",
"size": "md",
"className": "text-gray-600 dark:text-gray-400"
}
},
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white"
},
"text": "$t:admin.users.form.sections.marketing_consent"
}
]
},
{
"type": "composite",
"name": "ExtensionBadge",
"props": {
"type": "plugin",
"identifier": "sirsoft-marketing",
"installedPlugins": "{{_global.installedPlugins}}"
}
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-1 gap-4"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-3"
},
"children": [
{
"id": "field_marketing_consent",
"type": "basic",
"name": "Div",
"if": "{{(_local.form?.marketing_consent_enabled ?? _global.plugins['sirsoft-marketing']?.marketing_consent_enabled) === true}}",
"props": {
"className": "flex items-start gap-3"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-start gap-3 flex-1 cursor-pointer"
},
"children": [
{
"type": "basic",
"name": "Checkbox",
"props": {
"name": "marketing_consent",
"className": "mt-0.5 text-blue-600 bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500"
},
"actions": [
{
"type": "change",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"form": "{{Object.assign({}, _local.form, { marketing_consent: $event.target.checked }, Object.fromEntries((_local.form?.channels ?? marketing_settings?.data?.channels ?? []).map(ch => [ch.key, $event.target.checked ? true : false])))}}"
}
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1 space-y-1"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "block text-sm font-medium text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-marketing.marketing_consent"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "block text-xs text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-marketing.marketing_consent_desc"
}
]
}
]
},
{
"type": "basic",
"name": "Button",
"if": "{{(_local.form?.marketing_consent_terms_slug_set ?? (marketing_settings?.data?.marketing_consent_terms_slug ? true : false)) === true}}",
"props": {
"type": "button",
"className": "text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline flex-shrink-0 mt-0.5"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsModalType": "marketing_consent",
"marketingTermsPageData": null,
"marketingTermsLoading": true
}
},
{
"handler": "openModal",
"target": "marketing_terms_modal"
},
{
"handler": "apiCall",
"target": "/api/modules/sirsoft-page/pages/{{_local.form?.marketing_consent_terms_slug ?? marketing_settings?.data?.marketing_consent_terms_slug}}",
"auth_mode": "optional",
"params": { "method": "GET" },
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsPageData": "{{response.data}}",
"marketingTermsLoading": false
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsLoading": false
}
}
]
}
]
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-marketing.view_terms"
}
]
}
]
},
{
"id": "field_channel",
"type": "basic",
"name": "Div",
"if": "{{(_local.form?.marketing_consent_enabled ?? _global.plugins['sirsoft-marketing']?.marketing_consent_enabled) === true}}",
"iteration": {
"source": "(_local.form?.channels ?? marketing_settings?.data?.channels ?? []).filter(ch => ch.enabled !== false)",
"item_var": "channel"
},
"props": {
"className": "flex items-start gap-3 ml-8"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-start gap-3 flex-1 cursor-pointer"
},
"children": [
{
"type": "basic",
"name": "Checkbox",
"props": {
"name": "{{channel.key}}",
"className": "mt-0.5 text-blue-600 bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500"
},
"actions": [
{
"type": "change",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"form": "{{Object.assign({}, _local.form, $event.target.checked ? { [channel.key]: true, marketing_consent: true } : { [channel.key]: false })}}"
}
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1 space-y-1"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "block text-sm font-medium text-gray-900 dark:text-white"
},
"text": "{{$get(channel.label_i18n, _global.locale, channel.label)}}"
}
]
}
]
},
{
"type": "basic",
"name": "Button",
"if": "{{channel.terms_slug_set === true}}",
"props": {
"type": "button",
"className": "text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline flex-shrink-0 mt-0.5"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsModalType": "{{channel.key}}",
"marketingTermsPageData": null,
"marketingTermsLoading": true
}
},
{
"handler": "openModal",
"target": "marketing_terms_modal"
},
{
"handler": "apiCall",
"target": "/api/modules/sirsoft-page/pages/{{channel.terms_slug}}",
"auth_mode": "optional",
"params": { "method": "GET" },
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsPageData": "{{response.data}}",
"marketingTermsLoading": false
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsLoading": false
}
}
]
}
]
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-marketing.view_terms"
}
]
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-3"
},
"children": [
{
"id": "field_third_party_consent",
"type": "basic",
"name": "Div",
"if": "{{(_local.form?.third_party_consent_enabled ?? _global.plugins['sirsoft-marketing']?.third_party_consent_enabled) === true}}",
"props": {
"className": "flex items-start gap-3"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-start gap-3 flex-1 cursor-pointer"
},
"children": [
{
"type": "basic",
"name": "Checkbox",
"props": {
"name": "third_party_consent",
"className": "mt-0.5 text-blue-600 bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"form": "{{Object.assign({}, _local.form, { third_party_consent: $event.target.checked })}}"
}
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1 space-y-1"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "block text-sm font-medium text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-marketing.third_party_consent"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "block text-xs text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-marketing.third_party_consent_desc"
}
]
}
]
},
{
"type": "basic",
"name": "Button",
"if": "{{(_local.form?.third_party_consent_terms_slug_set ?? (marketing_settings?.data?.third_party_consent_terms_slug ? true : false)) === true}}",
"props": {
"type": "button",
"className": "text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline flex-shrink-0 mt-0.5"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsModalType": "third_party_consent",
"marketingTermsPageData": null,
"marketingTermsLoading": true
}
},
{
"handler": "openModal",
"target": "marketing_terms_modal"
},
{
"handler": "apiCall",
"target": "/api/modules/sirsoft-page/pages/{{_local.form?.third_party_consent_terms_slug ?? marketing_settings?.data?.third_party_consent_terms_slug}}",
"auth_mode": "optional",
"params": { "method": "GET" },
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsPageData": "{{response.data}}",
"marketingTermsLoading": false
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsLoading": false
}
}
]
}
]
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-marketing.view_terms"
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{(_local.form?.info_disclosure_enabled ?? _global.plugins['sirsoft-marketing']?.info_disclosure_enabled) === true}}",
"props": {
"className": "space-y-1"
},
"children": [
{
"id": "field_info_disclosure",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-start gap-3"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-start gap-3 flex-1 cursor-pointer"
},
"children": [
{
"type": "basic",
"name": "Checkbox",
"props": {
"name": "info_disclosure",
"className": "mt-0.5 text-blue-600 bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"form": "{{Object.assign({}, _local.form, { info_disclosure: $event.target.checked })}}"
}
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1 space-y-1"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "block text-sm font-medium text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-marketing.info_disclosure"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "block text-xs text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-marketing.info_disclosure_desc"
}
]
}
]
},
{
"type": "basic",
"name": "Button",
"if": "{{(_local.form?.info_disclosure_terms_slug_set ?? (marketing_settings?.data?.info_disclosure_terms_slug ? true : false)) === true}}",
"props": {
"type": "button",
"className": "text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline flex-shrink-0 mt-0.5"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsModalType": "info_disclosure",
"marketingTermsPageData": null,
"marketingTermsLoading": true
}
},
{
"handler": "openModal",
"target": "marketing_terms_modal"
},
{
"handler": "apiCall",
"target": "/api/modules/sirsoft-page/pages/{{_local.form?.info_disclosure_terms_slug ?? marketing_settings?.data?.info_disclosure_terms_slug}}",
"auth_mode": "optional",
"params": { "method": "GET" },
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsPageData": "{{response.data}}",
"marketingTermsLoading": false
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsLoading": false
}
}
]
}
]
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-marketing.view_terms"
}
]
}
]
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "block text-xs text-gray-400 dark:text-gray-500 ml-8"
},
"text": "$t:sirsoft-marketing.info_disclosure_warning"
}
]
}
]
}
]
}
]
}
]
}
]
}
],
"modals": [
{
"id": "marketing_terms_modal",
"type": "composite",
"name": "Modal",
"props": {
"title": {
"$switch": "{{_global.marketingTermsModalType}}",
"$cases": {
"marketing_consent": "$t:sirsoft-marketing.terms_modal_marketing_consent",
"email_subscription": "$t:sirsoft-marketing.terms_modal_email_subscription",
"third_party_consent": "$t:sirsoft-marketing.terms_modal_third_party_consent",
"info_disclosure": "$t:sirsoft-marketing.terms_modal_info_disclosure"
},
"$default": "{{_global.marketingTermsModalType ?? ''}}"
}
},
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "flex flex-col" },
"children": [
{
"type": "basic",
"name": "Div",
"if": "{{_global.marketingTermsLoading}}",
"props": { "className": "flex justify-center items-center py-12" },
"children": [
{
"type": "basic",
"name": "Icon",
"props": { "name": "circle-notch", "className": "text-blue-600 animate-spin text-3xl" }
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{_global.marketingTermsPageData?.published_at}}",
"props": { "className": "flex-shrink-0 flex items-center gap-2 mb-3" },
"children": [
{
"type": "basic",
"name": "Span",
"props": { "className": "text-sm text-gray-500 dark:text-gray-400" },
"text": "$t:sirsoft-marketing.terms_published_at: {{_global.marketingTermsPageData?.published_at | date}}"
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{_global.marketingTermsPageData}}",
"props": { "className": "flex-1 overflow-y-auto min-h-0 max-h-96" },
"children": [
{
"type": "composite",
"name": "HtmlContent",
"props": {
"content": "{{_global.marketingTermsPageData?.content ?? ''}}",
"isHtml": "{{(_global.marketingTermsPageData?.content_mode ?? 'html') === 'html'}}",
"className": "prose dark:prose-invert prose-sm max-w-none text-gray-700 dark:text-gray-300"
}
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{!_global.marketingTermsLoading && !_global.marketingTermsPageData}}",
"props": { "className": "py-8 text-center text-gray-500 dark:text-gray-400" },
"text": "$t:sirsoft-marketing.no_terms_content"
},
{
"type": "basic",
"name": "Div",
"props": { "className": "flex-shrink-0 flex justify-end gap-3 mt-4 pt-4 border-t border-gray-200 dark:border-gray-700" },
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "px-4 py-2 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-100"
},
"actions": [
{
"type": "click",
"handler": "closeModal"
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:common.close"
}
]
}
]
}
]
}
]
}
],
"priority": 200
}
@@ -0,0 +1,385 @@
{
"target_layout": "mypage/profile",
"injections": [
{
"target_id": "profile_view_bio_card",
"position": "append",
"components": [
{
"id": "profile_view_marketing_consent_section",
"type": "basic",
"name": "Div",
"props": {
"className": "bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6"
},
"responsive": {
"portable": {
"props": {
"className": "bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4"
}
}
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center justify-between mb-4 pb-3 border-b border-gray-200 dark:border-gray-700"
},
"responsive": {
"portable": {
"props": {
"className": "flex flex-col gap-2 mb-4 pb-3 border-b border-gray-200 dark:border-gray-700"
}
}
},
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-base font-semibold text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-marketing.marketing_consent_title"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs text-blue-600 dark:text-blue-400"
},
"text": "$t:sirsoft-marketing.marketing_consent_edit_hint"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-1 gap-4"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-3"
},
"children": [
{
"type": "basic",
"name": "Label",
"if": "{{user.data?.marketing_consent_enabled === true}}",
"props": {
"className": "flex items-start gap-3"
},
"children": [
{
"type": "basic",
"name": "Checkbox",
"props": {
"checked": "{{!!user.data?.marketing_consent}}",
"disabled": true,
"className": "mt-0.5 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded cursor-not-allowed"
}
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1 space-y-1"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 flex-wrap"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-marketing.marketing_consent"
},
{
"type": "basic",
"name": "Span",
"if": "{{user.data?.marketing_consent_at}}",
"props": {
"className": "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-400"
},
"text": "{{user.data?.marketing_consent_at ? '$t:sirsoft-marketing.consent_date_prefix ' + new Date(user.data.marketing_consent_at).toLocaleDateString() : ''}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{!user.data?.marketing_consent_at}}",
"props": {
"className": "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-marketing.not_agreed"
}
]
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "block text-xs text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-marketing.marketing_consent_desc"
}
]
}
]
},
{
"type": "basic",
"name": "Label",
"if": "{{user.data?.marketing_consent_enabled === true}}",
"iteration": {
"source": "(user.data?.channels ?? []).filter(ch => ch.enabled !== false)",
"item_var": "channel"
},
"props": {
"className": "flex items-start gap-3 ml-8"
},
"children": [
{
"type": "basic",
"name": "Checkbox",
"props": {
"checked": "{{!!user.data?.[channel.key]}}",
"disabled": true,
"className": "mt-0.5 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded cursor-not-allowed"
}
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1 space-y-1"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 flex-wrap"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
},
"text": "{{channel.label}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{user.data?.[channel.key + '_at']}}",
"props": {
"className": "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-400"
},
"text": "{{user.data?.[channel.key + '_at'] ? '$t:sirsoft-marketing.consent_date_prefix ' + new Date(user.data[channel.key + '_at']).toLocaleDateString() : ''}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{!user.data?.[channel.key + '_at']}}",
"props": {
"className": "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-marketing.not_agreed"
}
]
}
]
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-3"
},
"children": [
{
"type": "basic",
"name": "Label",
"if": "{{user.data?.third_party_consent_enabled === true}}",
"props": {
"className": "flex items-start gap-3"
},
"children": [
{
"type": "basic",
"name": "Checkbox",
"props": {
"checked": "{{!!user.data?.third_party_consent}}",
"disabled": true,
"className": "mt-0.5 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded cursor-not-allowed"
}
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1 space-y-1"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 flex-wrap"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-marketing.third_party_consent"
},
{
"type": "basic",
"name": "Span",
"if": "{{user.data?.third_party_consent_at}}",
"props": {
"className": "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-400"
},
"text": "{{user.data?.third_party_consent_at ? '$t:sirsoft-marketing.consent_date_prefix ' + new Date(user.data.third_party_consent_at).toLocaleDateString() : ''}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{!user.data?.third_party_consent_at}}",
"props": {
"className": "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-marketing.not_agreed"
}
]
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "block text-xs text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-marketing.third_party_consent_desc"
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{user.data?.info_disclosure_enabled === true}}",
"props": {
"className": "space-y-1"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-start gap-3"
},
"children": [
{
"type": "basic",
"name": "Checkbox",
"props": {
"checked": "{{!!user.data?.info_disclosure}}",
"disabled": true,
"className": "mt-0.5 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded cursor-not-allowed"
}
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1 space-y-1"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 flex-wrap"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-marketing.info_disclosure"
},
{
"type": "basic",
"name": "Span",
"if": "{{user.data?.info_disclosure_at}}",
"props": {
"className": "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-400"
},
"text": "{{user.data?.info_disclosure_at ? '$t:sirsoft-marketing.consent_date_prefix ' + new Date(user.data.info_disclosure_at).toLocaleDateString() : ''}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{!user.data?.info_disclosure_at}}",
"props": {
"className": "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-marketing.not_agreed"
}
]
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "block text-xs text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-marketing.info_disclosure_desc"
}
]
}
]
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "block text-xs text-gray-400 dark:text-gray-500 ml-8"
},
"text": "$t:sirsoft-marketing.info_disclosure_warning"
}
]
}
]
}
]
}
]
}
]
}
],
"priority": 100
}
@@ -0,0 +1,674 @@
{
"target_layout": "mypage/profile-edit",
"data_sources": [
{
"id": "marketing_settings",
"type": "api",
"endpoint": "/api/plugins/sirsoft-marketing/settings",
"method": "GET",
"auto_fetch": true,
"auth_required": false
}
],
"injections": [
{
"target_id": "profile_bio_card",
"position": "append",
"components": [
{
"id": "profile_marketing_consent_section",
"type": "basic",
"name": "Div",
"props": {
"className": "bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6"
},
"responsive": {
"portable": {
"props": {
"className": "bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4"
}
}
},
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-base font-semibold text-gray-900 dark:text-white mb-4 pb-3 border-b border-gray-200 dark:border-gray-700"
},
"text": "$t:sirsoft-marketing.marketing_consent_title"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-1 gap-4"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-3"
},
"children": [
{
"type": "basic",
"name": "Div",
"if": "{{_local.profileEditForm?.marketing_consent_enabled === true}}",
"props": {
"className": "flex items-start gap-3"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-start gap-3 flex-1 cursor-pointer"
},
"children": [
{
"type": "basic",
"name": "Checkbox",
"props": {
"name": "marketing_consent",
"className": "mt-0.5 text-blue-600 bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500"
},
"actions": [
{
"type": "change",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"profileEditForm": "{{Object.assign({}, _local.profileEditForm, { marketing_consent: $event.target.checked }, Object.fromEntries((marketing_settings?.data?.channels ?? []).map(ch => [ch.key, $event.target.checked ? true : false])))}}"
}
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1 space-y-1"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "block text-sm font-medium text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-marketing.marketing_consent"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "block text-xs text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-marketing.marketing_consent_desc"
}
]
}
]
},
{
"type": "basic",
"name": "Button",
"if": "{{_local.profileEditForm?.marketing_consent_terms_slug_set === true}}",
"props": {
"type": "button",
"className": "text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline flex-shrink-0 mt-0.5"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsModalType": "marketing_consent",
"marketingTermsPageData": null,
"marketingTermsLoading": true
}
},
{
"handler": "openModal",
"target": "marketing_terms_modal"
},
{
"handler": "apiCall",
"target": "/api/modules/sirsoft-page/pages/{{_local.profileEditForm.marketing_consent_terms_slug}}",
"auth_mode": "optional",
"params": { "method": "GET" },
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsPageData": "{{response.data}}",
"marketingTermsLoading": false
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsLoading": false
}
}
]
}
]
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-marketing.view_terms"
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{_local.profileEditForm?.marketing_consent_enabled === true}}",
"iteration": {
"source": "marketing_settings?.data?.channels ?? []",
"item_var": "channel"
},
"props": {
"className": "flex items-start gap-3 ml-8"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-start gap-3 flex-1 cursor-pointer"
},
"children": [
{
"type": "basic",
"name": "Checkbox",
"props": {
"name": "{{channel.key}}",
"className": "mt-0.5 text-blue-600 bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"profileEditForm": "{{Object.assign({}, _local.profileEditForm, $event.target.checked ? { [channel.key]: true, marketing_consent: true } : { [channel.key]: false })}}"
}
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1 space-y-1"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "block text-sm font-medium text-gray-900 dark:text-white"
},
"text": "{{$get(channel.label_i18n, _global.locale, channel.label)}}"
}
]
}
]
},
{
"type": "basic",
"name": "Button",
"if": "{{channel.terms_slug_set === true}}",
"props": {
"type": "button",
"className": "text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline flex-shrink-0 mt-0.5"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsModalType": "{{channel.key}}",
"marketingTermsPageData": null,
"marketingTermsLoading": true
}
},
{
"handler": "openModal",
"target": "marketing_terms_modal"
},
{
"handler": "apiCall",
"target": "/api/modules/sirsoft-page/pages/{{channel.terms_slug}}",
"auth_mode": "optional",
"params": { "method": "GET" },
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsPageData": "{{response.data}}",
"marketingTermsLoading": false
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsLoading": false
}
}
]
}
]
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-marketing.view_terms"
}
]
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-3"
},
"children": [
{
"type": "basic",
"name": "Div",
"if": "{{_local.profileEditForm?.third_party_consent_enabled === true}}",
"props": {
"className": "flex items-start gap-3"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-start gap-3 flex-1 cursor-pointer"
},
"children": [
{
"type": "basic",
"name": "Checkbox",
"props": {
"name": "third_party_consent",
"className": "mt-0.5 text-blue-600 bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500"
}
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1 space-y-1"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "block text-sm font-medium text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-marketing.third_party_consent"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "block text-xs text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-marketing.third_party_consent_desc"
}
]
}
]
},
{
"type": "basic",
"name": "Button",
"if": "{{_local.profileEditForm?.third_party_consent_terms_slug_set === true}}",
"props": {
"type": "button",
"className": "text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline flex-shrink-0 mt-0.5"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsModalType": "third_party_consent",
"marketingTermsPageData": null,
"marketingTermsLoading": true
}
},
{
"handler": "openModal",
"target": "marketing_terms_modal"
},
{
"handler": "apiCall",
"target": "/api/modules/sirsoft-page/pages/{{_local.profileEditForm.third_party_consent_terms_slug}}",
"auth_mode": "optional",
"params": { "method": "GET" },
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsPageData": "{{response.data}}",
"marketingTermsLoading": false
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsLoading": false
}
}
]
}
]
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-marketing.view_terms"
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{_local.profileEditForm?.info_disclosure_enabled === true}}",
"props": {
"className": "space-y-1"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-start gap-3"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-start gap-3 flex-1 cursor-pointer"
},
"children": [
{
"type": "basic",
"name": "Checkbox",
"props": {
"name": "info_disclosure",
"className": "mt-0.5 text-blue-600 bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500"
}
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1 space-y-1"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "block text-sm font-medium text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-marketing.info_disclosure"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "block text-xs text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-marketing.info_disclosure_desc"
}
]
}
]
},
{
"type": "basic",
"name": "Button",
"if": "{{_local.profileEditForm?.info_disclosure_terms_slug_set === true}}",
"props": {
"type": "button",
"className": "text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline flex-shrink-0 mt-0.5"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsModalType": "info_disclosure",
"marketingTermsPageData": null,
"marketingTermsLoading": true
}
},
{
"handler": "openModal",
"target": "marketing_terms_modal"
},
{
"handler": "apiCall",
"target": "/api/modules/sirsoft-page/pages/{{_local.profileEditForm.info_disclosure_terms_slug}}",
"auth_mode": "optional",
"params": { "method": "GET" },
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsPageData": "{{response.data}}",
"marketingTermsLoading": false
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsLoading": false
}
}
]
}
]
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-marketing.view_terms"
}
]
}
]
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "block text-xs text-gray-400 dark:text-gray-500 ml-8"
},
"text": "$t:sirsoft-marketing.info_disclosure_warning"
}
]
}
]
}
]
}
]
}
]
}
],
"modals": [
{
"id": "marketing_terms_modal",
"type": "composite",
"name": "Modal",
"props": {
"title": {
"$switch": "{{_global.marketingTermsModalType}}",
"$cases": {
"marketing_consent": "$t:sirsoft-marketing.terms_modal_marketing_consent",
"third_party_consent": "$t:sirsoft-marketing.terms_modal_third_party_consent",
"info_disclosure": "$t:sirsoft-marketing.terms_modal_info_disclosure"
},
"$default": "{{_global.marketingTermsModalType ?? ''}}"
}
},
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "flex flex-col" },
"children": [
{
"type": "basic",
"name": "Div",
"if": "{{_global.marketingTermsLoading}}",
"props": { "className": "flex justify-center items-center py-12" },
"children": [
{
"type": "basic",
"name": "Icon",
"props": { "name": "circle-notch", "className": "text-blue-600 animate-spin text-3xl" }
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{_global.marketingTermsPageData?.published_at}}",
"props": { "className": "flex-shrink-0 flex items-center gap-2 mb-3" },
"children": [
{
"type": "basic",
"name": "Span",
"props": { "className": "text-sm text-gray-500 dark:text-gray-400" },
"text": "$t:sirsoft-marketing.terms_published_at: {{_global.marketingTermsPageData?.published_at | date}}"
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{_global.marketingTermsPageData}}",
"props": { "className": "flex-1 overflow-y-auto min-h-0 max-h-96" },
"children": [
{
"type": "composite",
"name": "HtmlContent",
"props": {
"content": "{{_global.marketingTermsPageData?.content ?? ''}}",
"isHtml": "{{(_global.marketingTermsPageData?.content_mode ?? 'html') === 'html'}}",
"className": "prose dark:prose-invert prose-sm max-w-none text-gray-700 dark:text-gray-300"
}
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{!_global.marketingTermsLoading && !_global.marketingTermsPageData}}",
"props": { "className": "py-8 text-center text-gray-500 dark:text-gray-400" },
"text": "$t:sirsoft-marketing.no_terms_content"
},
{
"type": "basic",
"name": "Div",
"props": { "className": "flex-shrink-0 flex justify-end gap-3 mt-4 pt-4 border-t border-gray-200 dark:border-gray-700" },
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "px-4 py-2 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-100"
},
"actions": [
{
"type": "click",
"handler": "closeModal"
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:common.close"
}
]
}
]
}
]
}
]
}
],
"priority": 100
}
@@ -0,0 +1,583 @@
{
"target_layout": "auth/register",
"data_sources": [
{
"id": "marketing_settings",
"type": "api",
"endpoint": "/api/plugins/sirsoft-marketing/settings",
"method": "GET",
"auto_fetch": true,
"auth_required": false
}
],
"injections": [
{
"target_id": "register_privacy_agreement",
"position": "append",
"components": [
{
"id": "register_marketing_consent_section",
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-2"
},
"children": [
{
"type": "basic",
"name": "Div",
"if": "{{marketing_settings?.data?.marketing_consent_enabled === true}}",
"props": {
"className": "flex items-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-center gap-2 flex-1 cursor-pointer text-sm text-gray-700 dark:text-gray-300"
},
"children": [
{
"type": "basic",
"name": "Checkbox",
"props": {
"name": "agree_marketing_consent",
"disabled": "{{_local?.isRegistering}}"
},
"actions": [
{
"type": "change",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"registerForm": "{{Object.assign({}, _local.registerForm, { agree_marketing_consent: $event.target.checked }, Object.fromEntries((marketing_settings?.data?.channels ?? []).map(ch => ['agree_' + ch.key, $event.target.checked ? true : false])))}}"
}
}
]
}
]
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-marketing.agree_marketing_consent"
}
]
},
{
"type": "basic",
"name": "Button",
"if": "{{marketing_settings?.data?.marketing_consent_terms_slug_set === true}}",
"props": {
"type": "button",
"className": "text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline flex-shrink-0"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsModalType": "marketing_consent",
"marketingTermsPageData": null,
"marketingTermsLoading": true
}
},
{
"handler": "openModal",
"target": "marketing_terms_modal"
},
{
"handler": "apiCall",
"target": "/api/modules/sirsoft-page/pages/{{marketing_settings?.data?.marketing_consent_terms_slug}}",
"auth_mode": "optional",
"params": { "method": "GET" },
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsPageData": "{{response.data}}",
"marketingTermsLoading": false
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsLoading": false
}
}
]
}
]
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-marketing.view_terms"
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{marketing_settings?.data?.marketing_consent_enabled === true}}",
"iteration": {
"source": "marketing_settings?.data?.channels ?? []",
"item_var": "channel"
},
"props": {
"className": "flex items-center gap-2 ml-8"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-center gap-2 flex-1 cursor-pointer text-sm text-gray-700 dark:text-gray-300"
},
"children": [
{
"type": "basic",
"name": "Checkbox",
"props": {
"name": "agree_{{channel.key}}",
"disabled": "{{_local?.isRegistering}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"registerForm": "{{Object.assign({}, _local.registerForm, $event.target.checked ? { ['agree_' + channel.key]: true, agree_marketing_consent: true } : { ['agree_' + channel.key]: false })}}"
}
}
]
},
{
"type": "basic",
"name": "Span",
"text": "{{$get(channel.label_i18n, _global.locale, channel.label)}}"
}
]
},
{
"type": "basic",
"name": "Button",
"if": "{{channel.terms_slug_set === true}}",
"props": {
"type": "button",
"className": "text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline flex-shrink-0"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsModalType": "{{channel.key}}",
"marketingTermsPageData": null,
"marketingTermsLoading": true
}
},
{
"handler": "openModal",
"target": "marketing_terms_modal"
},
{
"handler": "apiCall",
"target": "/api/modules/sirsoft-page/pages/{{channel.terms_slug}}",
"auth_mode": "optional",
"params": { "method": "GET" },
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsPageData": "{{response.data}}",
"marketingTermsLoading": false
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsLoading": false
}
}
]
}
]
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-marketing.view_terms"
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{marketing_settings?.data?.third_party_consent_enabled === true}}",
"props": {
"className": "flex items-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-center gap-2 flex-1 cursor-pointer text-sm text-gray-700 dark:text-gray-300"
},
"children": [
{
"type": "basic",
"name": "Checkbox",
"props": {
"name": "agree_third_party_consent",
"disabled": "{{_local?.isRegistering}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"registerForm": "{{Object.assign({}, _local.registerForm, { agree_third_party_consent: $event.target.checked })}}"
}
}
]
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-marketing.agree_third_party_consent"
}
]
},
{
"type": "basic",
"name": "Button",
"if": "{{marketing_settings?.data?.third_party_consent_terms_slug_set === true}}",
"props": {
"type": "button",
"className": "text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline flex-shrink-0"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsModalType": "third_party_consent",
"marketingTermsPageData": null,
"marketingTermsLoading": true
}
},
{
"handler": "openModal",
"target": "marketing_terms_modal"
},
{
"handler": "apiCall",
"target": "/api/modules/sirsoft-page/pages/{{marketing_settings?.data?.third_party_consent_terms_slug}}",
"auth_mode": "optional",
"params": { "method": "GET" },
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsPageData": "{{response.data}}",
"marketingTermsLoading": false
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsLoading": false
}
}
]
}
]
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-marketing.view_terms"
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{marketing_settings?.data?.info_disclosure_enabled === true}}",
"props": {
"className": "space-y-1"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-center gap-2 flex-1 cursor-pointer text-sm text-gray-700 dark:text-gray-300"
},
"children": [
{
"type": "basic",
"name": "Checkbox",
"props": {
"name": "agree_info_disclosure",
"disabled": "{{_local?.isRegistering}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"registerForm": "{{Object.assign({}, _local.registerForm, { agree_info_disclosure: $event.target.checked })}}"
}
}
]
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-marketing.agree_info_disclosure"
}
]
},
{
"type": "basic",
"name": "Button",
"if": "{{marketing_settings?.data?.info_disclosure_terms_slug_set === true}}",
"props": {
"type": "button",
"className": "text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline flex-shrink-0"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsModalType": "info_disclosure",
"marketingTermsPageData": null,
"marketingTermsLoading": true
}
},
{
"handler": "openModal",
"target": "marketing_terms_modal"
},
{
"handler": "apiCall",
"target": "/api/modules/sirsoft-page/pages/{{marketing_settings?.data?.info_disclosure_terms_slug}}",
"auth_mode": "optional",
"params": { "method": "GET" },
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsPageData": "{{response.data}}",
"marketingTermsLoading": false
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "global",
"marketingTermsLoading": false
}
}
]
}
]
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-marketing.view_terms"
}
]
}
]
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "block text-xs text-gray-400 dark:text-gray-500 ml-8"
},
"text": "$t:sirsoft-marketing.info_disclosure_warning"
}
]
}
]
}
]
}
],
"modals": [
{
"id": "marketing_terms_modal",
"type": "composite",
"name": "Modal",
"props": {
"title": {
"$switch": "{{_global.marketingTermsModalType}}",
"$cases": {
"marketing_consent": "$t:sirsoft-marketing.terms_modal_marketing_consent",
"email_subscription": "$t:sirsoft-marketing.terms_modal_email_subscription",
"third_party_consent": "$t:sirsoft-marketing.terms_modal_third_party_consent",
"info_disclosure": "$t:sirsoft-marketing.terms_modal_info_disclosure"
},
"$default": "{{_global.marketingTermsModalType ?? ''}}"
}
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-col"
},
"children": [
{
"type": "basic",
"name": "Div",
"if": "{{_global.marketingTermsLoading}}",
"props": { "className": "flex justify-center items-center py-12" },
"children": [
{
"type": "basic",
"name": "Icon",
"props": { "name": "circle-notch", "className": "text-blue-600 animate-spin text-3xl" }
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{_global.marketingTermsPageData?.published_at}}",
"props": { "className": "flex-shrink-0 flex items-center gap-2 mb-3" },
"children": [
{
"type": "basic",
"name": "Span",
"props": { "className": "text-sm text-gray-500 dark:text-gray-400" },
"text": "$t:sirsoft-marketing.terms_published_at: {{_global.marketingTermsPageData?.published_at | date}}"
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{_global.marketingTermsPageData}}",
"props": { "className": "flex-1 overflow-y-auto min-h-0 max-h-96" },
"children": [
{
"type": "composite",
"name": "HtmlContent",
"props": {
"content": "{{_global.marketingTermsPageData?.content ?? ''}}",
"isHtml": "{{(_global.marketingTermsPageData?.content_mode ?? 'html') === 'html'}}",
"className": "prose dark:prose-invert prose-sm max-w-none text-gray-700 dark:text-gray-300"
}
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{!_global.marketingTermsLoading && !_global.marketingTermsPageData}}",
"props": { "className": "py-8 text-center text-gray-500 dark:text-gray-400" },
"text": "$t:sirsoft-marketing.no_terms_content"
},
{
"type": "basic",
"name": "Div",
"props": { "className": "flex-shrink-0 flex justify-end gap-3 mt-4 pt-4 border-t border-gray-200 dark:border-gray-700" },
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "px-4 py-2 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-100"
},
"actions": [
{
"type": "click",
"handler": "closeModal"
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:common.close"
}
]
}
]
}
]
}
]
}
],
"priority": 100
}
@@ -0,0 +1,677 @@
/**
* sirsoft-marketing 플러그인 Extension JSON 레이아웃 구조 검증 테스트
*
* @description
* Phase 2 잔여 테스트 시나리오:
* - *_enabled=false 시 항목 숨김 if 조건
* - slug 설정 여부에 따른 약관 보기 버튼 표시/숨김
* - apiCall onSuccess 후 상태 저장 경로
* - channels iteration 구조
* - Object.assign setState 패턴
* - modals 구조 (marketing_terms_modal)
* - 다국어 키 네임스페이스
* - 다크모드 클래스
*/
import { describe, it, expect } from 'vitest';
import registerLayout from '../../../extensions/user-marketing-register.json';
import profileLayout from '../../../extensions/user-marketing-profile.json';
import profileViewLayout from '../../../extensions/user-marketing-profile-view.json';
import formLayout from '../../../extensions/user-marketing-form.json';
import detailLayout from '../../../extensions/user-marketing-detail.json';
import pluginSettingsLayout from '../../../layouts/admin/plugin_settings.json';
// ─────────────────────────────────────────────
// 헬퍼
// ─────────────────────────────────────────────
function findById(node: any, id: string): any | null {
if (!node || typeof node !== 'object') return null;
if (node.id === id) return node;
for (const key of ['children', 'components', 'injections']) {
const children = node[key];
if (Array.isArray(children)) {
for (const child of children) {
const found = findById(child, id);
if (found) return found;
}
}
}
// injections 내부 components
if (node.position !== undefined && node.components) {
for (const c of node.components) {
const found = findById(c, id);
if (found) return found;
}
}
return null;
}
function findAll(node: any, predicate: (n: any) => boolean): any[] {
const results: any[] = [];
if (!node || typeof node !== 'object') return results;
if (predicate(node)) results.push(node);
for (const key of ['children', 'components']) {
const children = node[key];
if (Array.isArray(children)) {
for (const child of children) {
results.push(...findAll(child, predicate));
}
}
}
return results;
}
function findAllRecursive(node: any, predicate: (n: any) => boolean): any[] {
const results: any[] = [];
if (!node || typeof node !== 'object') return results;
if (Array.isArray(node)) {
for (const item of node) {
results.push(...findAllRecursive(item, predicate));
}
return results;
}
if (predicate(node)) results.push(node);
for (const val of Object.values(node)) {
if (val && typeof val === 'object') {
results.push(...findAllRecursive(val, predicate));
}
}
return results;
}
function collectTexts(node: any): string[] {
return findAllRecursive(node, (n) => typeof n.text === 'string' && n.text.startsWith('$t:'))
.map((n) => n.text);
}
function collectClassNames(node: any): string[] {
return findAllRecursive(node, (n) => typeof n?.props?.className === 'string')
.map((n) => n.props.className as string);
}
function findActions(node: any, handlerName: string): any[] {
const results: any[] = [];
if (!node || typeof node !== 'object') return results;
if (Array.isArray(node)) {
for (const item of node) results.push(...findActions(item, handlerName));
return results;
}
if (node.handler === handlerName) results.push(node);
for (const val of Object.values(node)) {
if (val && typeof val === 'object') {
results.push(...findActions(val, handlerName));
}
}
return results;
}
// ─────────────────────────────────────────────
// user-marketing-register.json
// ─────────────────────────────────────────────
describe('user-marketing-register.json', () => {
const layout = registerLayout as any;
it('target_layout이 auth/register이다', () => {
expect(layout.target_layout).toBe('auth/register');
});
it('marketing_settings data_source가 정의되어 있다', () => {
const ds = layout.data_sources?.find((d: any) => d.id === 'marketing_settings');
expect(ds).toBeDefined();
expect(ds.endpoint).toBe('/api/plugins/sirsoft-marketing/settings');
expect(ds.auth_required).toBe(false);
});
it('marketing_consent_enabled 조건부 렌더링 if 조건이 있다', () => {
const section = findById(layout, 'register_marketing_consent_section');
expect(section).not.toBeNull();
// marketing_consent 섹션 상위 Div의 if 조건
const divWithIf = findAllRecursive(section, (n) => typeof n.if === 'string' && n.if.includes('marketing_consent_enabled'));
expect(divWithIf.length).toBeGreaterThan(0);
expect(divWithIf[0].if).toContain('marketing_settings?.data?.marketing_consent_enabled === true');
});
it('약관 보기 버튼이 marketing_consent_terms_slug_set 조건으로 감싸져 있다', () => {
const section = findById(layout, 'register_marketing_consent_section');
const viewBtns = findAllRecursive(section, (n) =>
n.name === 'Button' && typeof n.if === 'string' && n.if.includes('terms_slug_set')
);
expect(viewBtns.length).toBeGreaterThan(0);
expect(viewBtns[0].if).toContain('terms_slug_set === true');
});
it('channels iteration이 item_var를 사용한다', () => {
const section = findById(layout, 'register_marketing_consent_section');
const iterNodes = findAllRecursive(section, (n) => n.iteration?.item_var !== undefined);
expect(iterNodes.length).toBeGreaterThan(0);
for (const node of iterNodes) {
expect(node.iteration.item_var).toBeDefined();
expect(node.iteration.item_var).not.toBe('item');
}
});
it('channels iteration source에 fallback이 있다', () => {
const section = findById(layout, 'register_marketing_consent_section');
const iterNodes = findAllRecursive(section, (n) => n.iteration?.source !== undefined);
for (const node of iterNodes) {
expect(node.iteration.source).toContain('??');
}
});
it('marketing_consent 체크박스 change 액션이 Object.assign 패턴을 사용한다', () => {
const section = findById(layout, 'register_marketing_consent_section');
const setStateActions = findActions(section, 'setState');
const masterSetState = setStateActions.find((a: any) =>
typeof a.params?.registerForm === 'string' &&
a.params.registerForm.includes('Object.assign')
);
expect(masterSetState).toBeDefined();
// 전체 폼 교체가 아닌 Object.assign으로 병합
expect(masterSetState.params.registerForm).toContain('_local.registerForm');
});
it('third_party_consent_enabled 조건부 렌더링이 있다', () => {
const section = findById(layout, 'register_marketing_consent_section');
const divWithIf = findAllRecursive(section, (n) =>
typeof n.if === 'string' && n.if.includes('third_party_consent_enabled')
);
expect(divWithIf.length).toBeGreaterThan(0);
});
it('info_disclosure_enabled 조건부 렌더링이 있다', () => {
const section = findById(layout, 'register_marketing_consent_section');
const divWithIf = findAllRecursive(section, (n) =>
typeof n.if === 'string' && n.if.includes('info_disclosure_enabled')
);
expect(divWithIf.length).toBeGreaterThan(0);
});
it('apiCall onSuccess에서 $response 없이 response.data를 사용한다', () => {
const apiCalls = findActions(layout, 'apiCall');
for (const action of apiCalls) {
const successActions = Array.isArray(action.onSuccess) ? action.onSuccess : [];
for (const cb of successActions) {
const paramsStr = JSON.stringify(cb.params ?? {});
expect(paramsStr).not.toContain('$response');
if (paramsStr.includes('marketingTermsPageData')) {
expect(paramsStr).toContain('response.data');
}
}
}
});
it('marketing_terms_modal이 modals에 정의되어 있다', () => {
const modals = layout.modals;
expect(Array.isArray(modals)).toBe(true);
const modal = modals.find((m: any) => m.id === 'marketing_terms_modal');
expect(modal).toBeDefined();
expect(modal.name).toBe('Modal');
});
it('모달 닫기 버튼이 closeModal 핸들러를 사용한다', () => {
const modals = layout.modals;
const closeActions = findActions(modals, 'closeModal');
expect(closeActions.length).toBeGreaterThan(0);
});
it('다국어 키가 sirsoft-marketing 네임스페이스를 사용한다', () => {
const texts = collectTexts(layout);
for (const text of texts) {
expect(text).toMatch(/^\$t:(sirsoft-marketing|common)\./);
}
});
it('다크모드 클래스가 bg-white 단독 사용 없이 사용된다', () => {
const classNames = collectClassNames(layout);
for (const cls of classNames) {
if (cls.includes('bg-white') && !cls.includes('bg-white/')) {
expect(cls).toContain('dark:bg-');
}
}
});
});
// ─────────────────────────────────────────────
// user-marketing-profile.json
// ─────────────────────────────────────────────
describe('user-marketing-profile.json', () => {
const layout = profileLayout as any;
it('target_layout이 mypage/profile-edit이다', () => {
expect(layout.target_layout).toBe('mypage/profile-edit');
});
it('marketing_settings data_source가 정의되어 있다', () => {
const ds = layout.data_sources?.find((d: any) => d.id === 'marketing_settings');
expect(ds).toBeDefined();
});
it('_local.profileEditForm?.marketing_consent_enabled 조건부 렌더링이 있다', () => {
const conditionals = findAllRecursive(layout, (n) =>
typeof n.if === 'string' && n.if.includes('marketing_consent_enabled')
);
expect(conditionals.length).toBeGreaterThan(0);
});
it('channels iteration이 item_var를 사용한다', () => {
const iterNodes = findAllRecursive(layout, (n) => n.iteration?.item_var !== undefined);
expect(iterNodes.length).toBeGreaterThan(0);
for (const node of iterNodes) {
expect(node.iteration.item_var).not.toBe('item');
}
});
it('약관 보기 버튼이 terms_slug_set 조건으로 표시된다', () => {
const viewBtns = findAllRecursive(layout, (n) =>
n.name === 'Button' && typeof n.if === 'string' && n.if.includes('terms_slug_set')
);
expect(viewBtns.length).toBeGreaterThan(0);
});
it('apiCall onSuccess에서 marketingTermsPageData 상태를 global에 저장한다', () => {
const apiCalls = findActions(layout, 'apiCall');
let found = false;
for (const action of apiCalls) {
const successActions = Array.isArray(action.onSuccess) ? action.onSuccess : [];
for (const cb of successActions) {
if (cb.params?.marketingTermsPageData !== undefined && cb.params?.target === 'global') {
found = true;
}
}
}
expect(found).toBe(true);
});
it('marketing_terms_modal이 modals에 정의되어 있다', () => {
const modals = layout.modals;
expect(Array.isArray(modals)).toBe(true);
const modal = modals.find((m: any) => m.id === 'marketing_terms_modal');
expect(modal).toBeDefined();
});
it('다국어 키가 sirsoft-marketing 네임스페이스를 사용한다', () => {
const texts = collectTexts(layout);
for (const text of texts) {
expect(text).toMatch(/^\$t:(sirsoft-marketing|common)\./);
}
});
it('다크모드 클래스가 bg-white 단독 사용 없이 사용된다', () => {
const classNames = collectClassNames(layout);
for (const cls of classNames) {
if (cls.includes('bg-white') && !cls.includes('bg-white/')) {
expect(cls).toContain('dark:bg-');
}
}
});
});
// ─────────────────────────────────────────────
// user-marketing-profile-view.json
// ─────────────────────────────────────────────
describe('user-marketing-profile-view.json', () => {
const layout = profileViewLayout as any;
it('target_layout이 mypage/profile이다', () => {
expect(layout.target_layout).toBe('mypage/profile');
});
it('marketing_consent_enabled 조건부 렌더링이 있다', () => {
const conditionals = findAllRecursive(layout, (n) =>
typeof n.if === 'string' && n.if.includes('marketing_consent_enabled')
);
expect(conditionals.length).toBeGreaterThan(0);
});
it('channels iteration이 있고 item_var를 사용한다', () => {
const iterNodes = findAllRecursive(layout, (n) => n.iteration?.item_var !== undefined);
expect(iterNodes.length).toBeGreaterThan(0);
});
it('다국어 키가 sirsoft-marketing 네임스페이스를 사용한다', () => {
const texts = collectTexts(layout);
for (const text of texts) {
expect(text).toMatch(/^\$t:(sirsoft-marketing|common)\./);
}
});
});
// ─────────────────────────────────────────────
// user-marketing-form.json (관리자 사용자 폼)
// ─────────────────────────────────────────────
describe('user-marketing-form.json', () => {
const layout = formLayout as any;
it('target_layout이 admin_user_form이다', () => {
expect(layout.target_layout).toBe('admin_user_form');
});
it('marketing_settings data_source가 정의되어 있다', () => {
const ds = layout.data_sources?.find((d: any) => d.id === 'marketing_settings');
expect(ds).toBeDefined();
expect(ds.auth_required).toBe(true);
});
it('marketing_consent_enabled 조건부 렌더링이 있다', () => {
const conditionals = findAllRecursive(layout, (n) =>
typeof n.if === 'string' && n.if.includes('marketing_consent_enabled')
);
expect(conditionals.length).toBeGreaterThan(0);
});
it('모든 Button에 type 속성이 명시되어 있다', () => {
const buttons = findAllRecursive(layout, (n) => n.name === 'Button');
for (const btn of buttons) {
expect(btn.props?.type, `Button(id=${btn.id}) type 누락`).toBeDefined();
}
});
it('약관 보기 버튼이 terms_slug_set 조건으로 표시된다', () => {
const viewBtns = findAllRecursive(layout, (n) =>
n.name === 'Button' && typeof n.if === 'string' && n.if.includes('terms_slug_set')
);
expect(viewBtns.length).toBeGreaterThan(0);
});
it('apiCall onSuccess에서 marketingTermsPageData를 global 상태에 저장한다', () => {
const apiCalls = findActions(layout, 'apiCall');
let found = false;
for (const action of apiCalls) {
const successActions = Array.isArray(action.onSuccess) ? action.onSuccess : [];
for (const cb of successActions) {
if (cb.params?.marketingTermsPageData !== undefined) {
expect(cb.params.target).toBe('global');
expect(cb.params.marketingTermsPageData).toContain('response.data');
found = true;
}
}
}
expect(found).toBe(true);
});
it('marketing_terms_modal이 modals에 정의되어 있다', () => {
const modals = layout.modals;
expect(Array.isArray(modals)).toBe(true);
const modal = modals.find((m: any) => m.id === 'marketing_terms_modal');
expect(modal).toBeDefined();
});
it('channels iteration이 있고 item_var를 사용한다', () => {
const iterNodes = findAllRecursive(layout, (n) => n.iteration?.item_var !== undefined);
expect(iterNodes.length).toBeGreaterThan(0);
for (const node of iterNodes) {
expect(node.iteration.item_var).not.toBe('item');
}
});
it('다국어 키가 sirsoft-marketing 또는 admin 네임스페이스를 사용한다', () => {
const texts = collectTexts(layout);
for (const text of texts) {
expect(text).toMatch(/^\$t:(sirsoft-marketing|common|admin)\./);
}
});
it('다크모드 클래스가 bg-white 단독 사용 없이 사용된다', () => {
const classNames = collectClassNames(layout);
for (const cls of classNames) {
if (cls.includes('bg-white') && !cls.includes('bg-white/')) {
expect(cls).toContain('dark:bg-');
}
}
});
});
// ─────────────────────────────────────────────
// user-marketing-detail.json (관리자 사용자 상세)
// ─────────────────────────────────────────────
describe('user-marketing-detail.json', () => {
const layout = detailLayout as any;
it('target_layout이 admin_user_detail이다', () => {
expect(layout.target_layout).toBe('admin_user_detail');
});
it('marketing_consent_enabled 조건부 렌더링이 있다', () => {
const conditionals = findAllRecursive(layout, (n) =>
typeof n.if === 'string' && n.if.includes('marketing_consent_enabled')
);
expect(conditionals.length).toBeGreaterThan(0);
});
it('channels iteration이 있고 item_var를 사용한다', () => {
const iterNodes = findAllRecursive(layout, (n) => n.iteration?.item_var !== undefined);
expect(iterNodes.length).toBeGreaterThan(0);
for (const node of iterNodes) {
expect(node.iteration.item_var).not.toBe('item');
}
});
it('다국어 키가 sirsoft-marketing 또는 admin 네임스페이스를 사용한다', () => {
const texts = collectTexts(layout);
for (const text of texts) {
expect(text).toMatch(/^\$t:(sirsoft-marketing|common|admin)\./);
}
});
it('다크모드 클래스가 bg-white 단독 사용 없이 사용된다', () => {
const classNames = collectClassNames(layout);
for (const cls of classNames) {
if (cls.includes('bg-white') && !cls.includes('bg-white/')) {
expect(cls).toContain('dark:bg-');
}
}
});
});
// ─────────────────────────────────────────────
// plugin_settings.json (관리자 플러그인 설정)
// ─────────────────────────────────────────────
describe('plugin_settings.json', () => {
const layout = pluginSettingsLayout as any;
it('layout_name이 plugin_settings이다', () => {
expect(layout.layout_name).toBe('plugin_settings');
});
it('permissions에 core.plugins.update가 있다', () => {
expect(layout.permissions).toContain('core.plugins.update');
});
it('settings data_source가 정의되어 있다', () => {
const ds = layout.data_sources?.find((d: any) => d.id === 'settings');
expect(ds).toBeDefined();
expect(ds.auth_required).toBe(true);
});
it('settings data_source onSuccess에서 form.channels를 local 상태에 저장한다', () => {
const ds = layout.data_sources?.find((d: any) => d.id === 'settings');
const actions = ds?.onSuccess?.actions ?? [];
const localSetState = actions.find((a: any) =>
a.params?.target === 'local' && a.params?.['form.channels'] !== undefined
);
expect(localSetState).toBeDefined();
});
it('settings data_source onSuccess에서 channels를 global 상태에 저장한다', () => {
const ds = layout.data_sources?.find((d: any) => d.id === 'settings');
const actions = ds?.onSuccess?.actions ?? [];
const globalSetState = actions.find((a: any) =>
a.params?.target === 'global' && a.params?.channels !== undefined
);
expect(globalSetState).toBeDefined();
});
it('channel_edit_modal이 modals에 정의되어 있다', () => {
const modals = layout.modals;
expect(modals).toBeDefined();
// 객체형 modals
if (typeof modals === 'object' && !Array.isArray(modals)) {
expect(modals.channel_edit_modal).toBeDefined();
expect(modals.channel_edit_modal.name).toBe('Modal');
} else {
const modal = (modals as any[]).find((m: any) => m.id === 'channel_edit_modal');
expect(modal).toBeDefined();
}
});
it('channel_edit_modal이 closeOnBackdropClick: false를 사용한다', () => {
const modals = layout.modals;
const editModal = typeof modals === 'object' && !Array.isArray(modals)
? modals.channel_edit_modal
: (modals as any[]).find((m: any) => m.id === 'channel_edit_modal');
expect(editModal?.props?.closeOnBackdropClick).toBe(false);
});
it('채널 PUT API 저장 액션이 있다', () => {
const apiCalls = findActions(layout, 'apiCall');
const putChannels = apiCalls.find((a: any) =>
a.target?.includes('channels') && a.params?.method === 'PUT'
);
expect(putChannels).toBeDefined();
});
it('채널 삭제 버튼이 apiCall 없이 setState로만 채널을 제거한다', () => {
// 삭제 버튼: trash 아이콘을 가진 Button의 actions
const deleteButtons = findAllRecursive(layout, (n: any) =>
n.name === 'Button' &&
n.if === '{{!channel.is_system}}' &&
JSON.stringify(n.children ?? []).includes('trash')
);
expect(deleteButtons.length).toBeGreaterThan(0);
const deleteBtn = deleteButtons[0];
const clickAction = deleteBtn.actions?.find((a: any) => a.type === 'click');
expect(clickAction).toBeDefined();
// apiCall이 삭제 버튼 액션에 없어야 함
const apiCallsInDelete = findActions(clickAction, 'apiCall');
expect(apiCallsInDelete.length).toBe(0);
// setState로 channels를 filter하여 제거
const setStates = findActions(clickAction, 'setState');
const channelFilter = setStates.find((a: any) =>
typeof a.params?.channels === 'string' &&
a.params.channels.includes('filter')
);
expect(channelFilter).toBeDefined();
});
it('채널 삭제 버튼이 hasChanges를 true로 설정한다', () => {
const deleteButtons = findAllRecursive(layout, (n: any) =>
n.name === 'Button' &&
n.if === '{{!channel.is_system}}' &&
JSON.stringify(n.children ?? []).includes('trash')
);
const deleteBtn = deleteButtons[0];
const clickAction = deleteBtn.actions?.find((a: any) => a.type === 'click');
const setStates = findActions(clickAction, 'setState');
const hasChangesSet = setStates.find((a: any) =>
a.params?.hasChanges === true && a.params?.target === 'local'
);
expect(hasChangesSet).toBeDefined();
});
it('저장 버튼이 channels API를 먼저 호출한 후 settings API를 호출한다', () => {
// save_button은 slots.content 내부에 있으므로 slots에서 탐색
const slotsContent = layout.slots?.content ?? [];
let saveBtn: any = null;
for (const node of slotsContent) {
saveBtn = findById(node, 'save_button');
if (saveBtn) break;
}
expect(saveBtn).not.toBeNull();
const clickAction = saveBtn.actions?.find((a: any) => a.type === 'click');
const apiCalls = findActions(clickAction, 'apiCall');
// channels API 호출이 있어야 함
const channelsApi = apiCalls.find((a: any) =>
a.target?.includes('sirsoft-marketing/admin/channels')
);
expect(channelsApi).toBeDefined();
// channels API의 onSuccess 안에 settings API 호출이 있어야 함
const settingsApiInSuccess = findActions(channelsApi?.onSuccess ?? [], 'apiCall');
const settingsApi = settingsApiInSuccess.find((a: any) =>
a.target?.includes('/settings')
);
expect(settingsApi).toBeDefined();
});
it('채널 검증 실패 시 channel_deactivate_modal이 열린다', () => {
const slotsContent = layout.slots?.content ?? [];
let saveBtn: any = null;
for (const node of slotsContent) {
saveBtn = findById(node, 'save_button');
if (saveBtn) break;
}
expect(saveBtn).not.toBeNull();
const clickAction = saveBtn.actions?.find((a: any) => a.type === 'click');
const apiCalls = findActions(clickAction, 'apiCall');
const channelsApi = apiCalls.find((a: any) =>
a.target?.includes('sirsoft-marketing/admin/channels')
);
expect(channelsApi).toBeDefined();
// onError에서 openModal이 있어야 함
const openModals = findActions(channelsApi?.onError ?? [], 'openModal');
const deactivateModal = openModals.find((a: any) =>
a.target === 'channel_deactivate_modal'
);
expect(deactivateModal).toBeDefined();
});
it('channel_deactivate_modal 확인 버튼이 apiCall 없이 setState로만 동작한다', () => {
const modals = layout.modals;
const deactivateModal = typeof modals === 'object' && !Array.isArray(modals)
? modals.channel_deactivate_modal
: (modals as any[]).find((m: any) => m.id === 'channel_deactivate_modal');
expect(deactivateModal).toBeDefined();
// 모달 내 apiCall이 없어야 함
const apiCallsInModal = findActions(deactivateModal, 'apiCall');
expect(apiCallsInModal.length).toBe(0);
// setState로 channels를 복원 (enabled: false)
const setStates = findActions(deactivateModal, 'setState');
const channelRestore = setStates.find((a: any) =>
typeof a.params?.channels === 'string' &&
a.params.channels.includes('enabled: false')
);
expect(channelRestore).toBeDefined();
});
it('모든 Button에 type 속성이 명시되어 있다', () => {
const buttons = findAllRecursive(layout, (n) => n.name === 'Button');
for (const btn of buttons) {
expect(btn.props?.type, `Button(id=${btn.id}) type 누락`).toBeDefined();
}
});
it('다국어 키가 sirsoft-marketing 또는 admin 네임스페이스를 사용한다', () => {
const texts = collectTexts(layout);
for (const text of texts) {
expect(text).toMatch(/^\$t:(sirsoft-marketing|common|admin)\./);
}
});
it('다크모드 클래스가 bg-white 단독 사용 없이 사용된다', () => {
const classNames = collectClassNames(layout);
for (const cls of classNames) {
if (cls.includes('bg-white') && !cls.includes('bg-white/')) {
expect(cls).toContain('dark:bg-');
}
}
});
});
@@ -0,0 +1,104 @@
{
"marketing_consent_title": "Marketing Consent",
"email_subscription": "Email Subscription",
"email_subscription_desc": "Receive promotional emails including events, discounts, and marketing updates.",
"marketing_consent": "Marketing Consent",
"marketing_consent_desc": "Agree to the collection and use of personal information for marketing purposes.",
"third_party_consent": "Third Party Sharing",
"third_party_consent_desc": "Agree to the sharing of personal information with third parties for marketing purposes.",
"info_disclosure": "Information Disclosure",
"info_disclosure_desc": "Allow your profile information to be visible to other users.",
"agree_marketing_consent": "(Optional) Marketing consent",
"agree_third_party_consent": "(Optional) Third party sharing consent",
"agree_info_disclosure": "(Optional) Information disclosure consent",
"no_consent_history": "No consent history available.",
"history_date": "Date",
"history_channel_key": "Consent Item",
"history_action": "Action",
"history_source": "Source",
"action_granted": "Granted",
"action_revoked": "Revoked",
"source_register": "Registration",
"source_admin": "Admin",
"source_profile": "My Page",
"consent_date_prefix": "Agreed:",
"not_agreed": "Not agreed",
"marketing_consent_edit_hint": "Edit in profile settings",
"view_terms": "View",
"info_disclosure_warning": "Some community features may be limited if not agreed.",
"no_terms_content": "Terms content has not been registered.",
"terms_published_at": "Published date",
"terms_modal_marketing_consent": "Marketing Consent Terms",
"terms_modal_email_subscription": "Email Subscription Terms",
"terms_modal_third_party_consent": "Third Party Consent Terms",
"terms_modal_info_disclosure": "Information Disclosure Terms",
"settings": {
"title": "Marketing Consent Settings",
"description": "Manage marketing consent items and terms pages.",
"section": {
"channels": "Marketing Channels",
"legal": "Legal Consent Items"
},
"fields": {
"marketing_consent_enabled": {
"label": "Enable Marketing Consent",
"hint": "When disabled, the marketing consent item is hidden from all screens."
},
"marketing_consent_terms_slug": {
"label": "Marketing Consent Terms Page Slug",
"hint": "Enter the SLUG of the terms page managed by the Page module. Leave empty to hide the view terms button."
},
"terms_slug_placeholder": "e.g. marketing-terms",
"channels": {
"label": "Channels"
},
"third_party_consent_enabled": {
"label": "Enable Third Party Consent",
"hint": "When disabled, the third party consent item is hidden."
},
"third_party_consent_terms_slug": {
"label": "Third Party Consent Terms Page Slug",
"hint": "Enter the SLUG of the terms page managed by the Page module."
},
"info_disclosure_enabled": {
"label": "Enable Information Disclosure Consent",
"hint": "When disabled, the information disclosure consent item is hidden."
},
"info_disclosure_terms_slug": {
"label": "Information Disclosure Terms Page Slug",
"hint": "Enter the SLUG of the terms page managed by the Page module."
}
},
"channels": {
"description": "Manage marketing consent channels. Each channel is an independent consent item that members can opt in or out of individually during registration and in their profile settings.",
"col_label": "Name",
"col_slug": "Terms slug",
"col_enabled": "Enabled",
"add": "Add Channel",
"system_badge": "System",
"delete_success": "Channel deleted.",
"save_success": "Channel saved.",
"save_error": "Failed to save channel."
},
"channel_modal": {
"title": "Add / Edit Channel",
"enabled": "Enabled",
"key": "Channel Key",
"key_hint": "Only lowercase letters, numbers, and underscores (_) are allowed.",
"key_placeholder": "e.g. sms_subscription",
"label_ko": "Name (Korean)",
"label_en": "Name (English)",
"label_placeholder": "e.g. SMS Marketing",
"page_slug": "Terms Page Slug",
"page_slug_hint": "Leave empty to hide the view terms button.",
"page_slug_placeholder": "e.g. sms-terms",
"save": "Save",
"cancel": "Cancel"
},
"channel_deactivate_modal": {
"title": "Cannot Delete Channel",
"message": "This channel has existing consent records and cannot be deleted. Would you like to deactivate it instead?",
"confirm": "Deactivate Instead"
}
}
}
@@ -0,0 +1,104 @@
{
"marketing_consent_title": "마케팅 동의 정보",
"email_subscription": "광고성 이메일 수신",
"email_subscription_desc": "프로모션, 이벤트, 할인 등의 마케팅 이메일을 수신합니다.",
"marketing_consent": "마케팅 동의",
"marketing_consent_desc": "마케팅 목적의 개인정보 수집 및 이용에 동의합니다.",
"third_party_consent": "제3자 제공 동의",
"third_party_consent_desc": "마케팅 목적의 제3자 개인정보 제공에 동의합니다.",
"info_disclosure": "정보 공개",
"info_disclosure_desc": "프로필 정보를 다른 사용자에게 공개합니다.",
"agree_marketing_consent": "(선택) 마케팅 동의",
"agree_third_party_consent": "(선택) 제3자 제공 동의",
"agree_info_disclosure": "(선택) 정보 공개 동의",
"no_consent_history": "동의 이력이 없습니다.",
"history_date": "일시",
"history_channel_key": "동의 항목",
"history_action": "변경",
"history_source": "경로",
"action_granted": "동의",
"action_revoked": "철회",
"source_register": "회원가입",
"source_admin": "관리자",
"source_profile": "마이페이지",
"consent_date_prefix": "동의일:",
"not_agreed": "미동의",
"marketing_consent_edit_hint": "수정은 프로필 편집에서 가능합니다",
"view_terms": "보기",
"info_disclosure_warning": "미동의 시 일부 커뮤니티 기능 이용이 제한될 수 있습니다.",
"no_terms_content": "약관 내용이 등록되지 않았습니다.",
"terms_published_at": "발행일",
"terms_modal_marketing_consent": "마케팅 동의 약관",
"terms_modal_email_subscription": "광고성 이메일 수신 약관",
"terms_modal_third_party_consent": "제3자 제공 동의 약관",
"terms_modal_info_disclosure": "정보 공개 약관",
"settings": {
"title": "마케팅 동의 설정",
"description": "마케팅 동의 항목 및 약관 페이지를 관리합니다.",
"section": {
"channels": "마케팅 채널",
"legal": "법적 동의 항목"
},
"fields": {
"marketing_consent_enabled": {
"label": "마케팅 동의 사용",
"hint": "비활성화 시 모든 화면에서 마케팅 동의 항목이 숨겨집니다."
},
"marketing_consent_terms_slug": {
"label": "마케팅 동의 약관 페이지",
"hint": "페이지 모듈에서 관리하는 약관 페이지의 SLUG를 입력하세요. 비워두면 약관 보기 버튼이 숨겨집니다."
},
"terms_slug_placeholder": "예: marketing-terms",
"channels": {
"label": "채널"
},
"third_party_consent_enabled": {
"label": "제3자 제공 동의 사용",
"hint": "비활성화 시 제3자 제공 동의 항목이 숨겨집니다."
},
"third_party_consent_terms_slug": {
"label": "제3자 제공 동의 약관 페이지",
"hint": "페이지 모듈에서 관리하는 약관 페이지의 SLUG를 입력하세요."
},
"info_disclosure_enabled": {
"label": "정보 공개 동의 사용",
"hint": "비활성화 시 정보 공개 동의 항목이 숨겨집니다."
},
"info_disclosure_terms_slug": {
"label": "정보 공개 동의 약관 페이지",
"hint": "페이지 모듈에서 관리하는 약관 페이지의 SLUG를 입력하세요."
}
},
"channels": {
"description": "마케팅 동의 채널을 관리합니다. 각 채널은 독립적인 동의 항목으로, 회원가입 및 마이페이지에서 개별 수신 여부를 선택할 수 있습니다.",
"col_label": "항목명",
"col_slug": "약관 slug",
"col_enabled": "사용",
"add": "채널 추가",
"system_badge": "시스템",
"delete_success": "채널이 삭제되었습니다.",
"save_success": "채널이 저장되었습니다.",
"save_error": "채널 저장에 실패하였습니다."
},
"channel_modal": {
"title": "채널 추가 / 편집",
"enabled": "사용 여부",
"key": "항목 키",
"key_hint": "영문 소문자, 숫자, 밑줄(_)만 사용 가능합니다.",
"key_placeholder": "예: sms_subscription",
"label_ko": "항목명 (한국어)",
"label_en": "항목명 (English)",
"label_placeholder": "예: 광고성 SMS 수신",
"page_slug": "약관 페이지 slug",
"page_slug_hint": "비워두면 약관 보기 버튼이 숨겨집니다.",
"page_slug_placeholder": "예: sms-terms",
"save": "저장",
"cancel": "취소"
},
"channel_deactivate_modal": {
"title": "채널을 삭제할 수 없습니다",
"message": "이 채널에 동의한 회원이 있어 삭제할 수 없습니다. 대신 채널을 비활성화하시겠습니까?",
"confirm": "비활성화로 전환"
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,66 @@
<?php
namespace Plugins\Sirsoft\Marketing\Http\Controllers;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Services\PluginSettingsService;
use Plugins\Sirsoft\Marketing\Http\Requests\ChannelUpdateRequest;
use Plugins\Sirsoft\Marketing\Services\MarketingConsentService;
/**
* 마케팅 관리자 컨트롤러
*
* 관리자 전용 마케팅 동의 설정 API를 제공합니다.
* 채널 목록 조회 및 저장을 담당합니다.
*/
class MarketingAdminController extends AdminBaseController
{
/**
* 플러그인 식별자
*/
private const PLUGIN_ID = 'sirsoft-marketing';
/**
* MarketingAdminController 생성자
*
* @param PluginSettingsService $pluginSettings 플러그인 설정 서비스
* @param MarketingConsentService $consentService 마케팅 동의 서비스
*/
public function __construct(
private PluginSettingsService $pluginSettings,
private MarketingConsentService $consentService
) {
parent::__construct();
}
/**
* 채널 목록 전체 저장
*
* 제출된 채널 배열을 channels JSON으로 plugin_settings에 저장합니다.
* ChannelUpdateRequest에서 key 중복, is_system 보호, 동의 존재 시 삭제 거부를 검증합니다.
*
* @param ChannelUpdateRequest $request
* @return \Illuminate\Http\JsonResponse
*/
public function updateChannels(ChannelUpdateRequest $request): \Illuminate\Http\JsonResponse
{
$channels = $request->input('channels');
// system 채널은 제출 목록에 없어도 항상 유지 (저장 상태와 무관한 기본 정의 기준)
$systemChannels = $this->consentService->getDefaultSystemChannels();
$submittedKeys = array_column($channels, 'key');
foreach ($systemChannels as $systemChannel) {
if (! in_array($systemChannel['key'], $submittedKeys, true)) {
array_unshift($channels, $systemChannel);
}
}
$this->pluginSettings->save(self::PLUGIN_ID, ['channels' => json_encode($channels)]);
return ResponseHelper::success('sirsoft-marketing::messages.channels_saved', [
'channels' => $channels,
]);
}
}
@@ -0,0 +1,89 @@
<?php
namespace Plugins\Sirsoft\Marketing\Http\Controllers;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Services\PluginSettingsService;
use Plugins\Sirsoft\Marketing\Services\MarketingConsentService;
/**
* 마케팅 공개 설정 컨트롤러
*
* 비로그인 상태에서도 마케팅 동의 항목 활성화 여부를 조회합니다.
* 회원가입 폼의 조건부 렌더링에 사용됩니다.
*/
class MarketingSettingsController extends PublicBaseController
{
/**
* 플러그인 식별자
*/
private const PLUGIN_ID = 'sirsoft-marketing';
/**
* MarketingSettingsController 생성자
*
* @param PluginSettingsService $pluginSettings 플러그인 설정 서비스
* @param MarketingConsentService $consentService 마케팅 동의 서비스
*/
public function __construct(
private PluginSettingsService $pluginSettings,
private MarketingConsentService $consentService
) {
parent::__construct();
}
/**
* 마케팅 동의 항목 활성화 여부, 약관 slug, 채널 목록 반환
*
* 마케팅 전체 동의 설정과 channels JSON 기반 동적 채널 목록을 반환합니다.
* slug 값은 노출하지 않고 존재 여부(*_terms_slug_set)만 반환합니다.
* channels 배열은 회원가입/마이페이지 폼의 iteration 렌더링에 사용됩니다.
*
* @return \Illuminate\Http\JsonResponse
*/
public function settings(): \Illuminate\Http\JsonResponse
{
$data = [];
// 마케팅 전체 동의 설정
$marketingSlug = $this->pluginSettings->get(self::PLUGIN_ID, 'marketing_consent_terms_slug', '');
$data['marketing_consent_enabled'] = (bool) $this->pluginSettings->get(self::PLUGIN_ID, 'marketing_consent_enabled', true);
$data['marketing_consent_terms_slug'] = $marketingSlug ?: null;
$data['marketing_consent_terms_slug_set'] = ! empty($marketingSlug);
// 법적 동의 항목 활성화 여부
$data['third_party_consent_enabled'] = (bool) $this->pluginSettings->get(self::PLUGIN_ID, 'third_party_consent_enabled');
$thirdPartySlug = $this->pluginSettings->get(self::PLUGIN_ID, 'third_party_consent_terms_slug', '');
$data['third_party_consent_terms_slug'] = $thirdPartySlug ?: null;
$data['third_party_consent_terms_slug_set'] = ! empty($thirdPartySlug);
$data['info_disclosure_enabled'] = (bool) $this->pluginSettings->get(self::PLUGIN_ID, 'info_disclosure_enabled');
$infoSlug = $this->pluginSettings->get(self::PLUGIN_ID, 'info_disclosure_terms_slug', '');
$data['info_disclosure_terms_slug'] = $infoSlug ?: null;
$data['info_disclosure_terms_slug_set'] = ! empty($infoSlug);
// channels JSON 기반 동적 채널 처리 (활성 채널만)
$channels = $this->consentService->getRegisteredChannels();
$locale = app()->getLocale();
$data['channels'] = array_map(function (array $channel) use ($locale) {
$slug = $channel['page_slug'] ?? '';
$labelRaw = $channel['label'] ?? [];
$labelString = is_array($labelRaw)
? ($labelRaw[$locale] ?? $labelRaw['ko'] ?? $channel['key'])
: (string) $labelRaw;
return [
'key' => $channel['key'],
'label' => $labelString,
'label_i18n' => is_array($labelRaw) ? $labelRaw : ['ko' => $labelString, 'en' => $labelString],
'enabled' => (bool) ($channel['enabled'] ?? true),
'terms_slug' => $slug ?: null,
'terms_slug_set' => ! empty($slug),
];
}, $channels);
return ResponseHelper::success('messages.success', $data);
}
}
@@ -0,0 +1,125 @@
<?php
namespace Plugins\Sirsoft\Marketing\Http\Requests;
use App\Rules\LocaleRequiredTranslatable;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
use Plugins\Sirsoft\Marketing\Services\MarketingConsentService;
/**
* 채널 목록 저장 요청 검증
*
* PUT /api/admin/plugins/sirsoft-marketing/channels
*/
class ChannelUpdateRequest extends FormRequest
{
/**
* @param MarketingConsentService $consentService 마케팅 동의 서비스
*/
public function __construct(private MarketingConsentService $consentService)
{
parent::__construct();
}
/**
* 권한 확인 (미들웨어에서 관리자 인증 처리)
*
* @return bool
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙 정의
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'channels' => ['required', 'array', 'max:10'],
'channels.*.key' => ['required', 'string', 'regex:/^[a-z0-9_]+$/'],
'channels.*.label' => ['required', 'array', new LocaleRequiredTranslatable(maxLength: 50)],
'channels.*.page_slug' => ['nullable', 'string', 'max:100'],
'channels.*.enabled' => ['required', 'boolean'],
'channels.*.is_system' => ['required', 'boolean'],
];
}
/**
* 커스텀 검증 — key 중복, is_system 보호, 동의 존재 시 삭제 거부
*
* @param Validator $validator
* @return void
*/
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator) {
$channels = $this->input('channels', []);
// key 중복 검증
$keys = array_column($channels, 'key');
if (count($keys) !== count(array_unique($keys))) {
$validator->errors()->add('channels', __('sirsoft-marketing::messages.channel_key_duplicate'));
return;
}
$existingChannels = collect($this->consentService->getAllChannels())->keyBy('key');
foreach ($channels as $index => $channel) {
$key = $channel['key'] ?? '';
// is_system 채널의 key 변경 불가 검증
$existing = $existingChannels->get($key);
if ($existing && ($existing['is_system'] ?? false)) {
// is_system 채널이 제출된 목록에서 제거되었는지 확인
// (key가 동일하면 통과, 다른 is_system key가 사라진 경우 감지)
}
// is_system 플래그 위변조 방지: 기존에 is_system=true인 채널을 false로 변경 불가
if ($existing && ($existing['is_system'] ?? false) && ! ($channel['is_system'] ?? false)) {
$validator->errors()->add(
"channels.{$index}.is_system",
__('sirsoft-marketing::messages.channel_system_protected')
);
}
}
// is_system 채널 누락 검증 (시스템 채널은 삭제 불가)
foreach ($existingChannels as $key => $existing) {
if (! ($existing['is_system'] ?? false)) {
continue;
}
$found = collect($channels)->firstWhere('key', $key);
if (! $found) {
$validator->errors()->add(
'channels',
__('sirsoft-marketing::messages.channel_system_cannot_delete', ['key' => $key])
);
}
}
// 기존 동의 데이터 존재 시 채널 삭제 거부
$submittedKeys = array_column($channels, 'key');
foreach ($existingChannels as $key => $existing) {
if (in_array($key, $submittedKeys, true)) {
continue;
}
// 삭제 대상 채널에 동의 데이터가 있는지 확인
$consentCount = $this->consentService->countConsentedByKey($key);
if ($consentCount > 0) {
$validator->errors()->add(
'channels',
__('sirsoft-marketing::messages.channel_has_consents', [
'key' => $key,
'count' => $consentCount,
])
);
}
}
});
}
}
@@ -0,0 +1,414 @@
<?php
namespace Plugins\Sirsoft\Marketing\Listeners;
use App\Contracts\Extension\HookListenerInterface;
use App\Models\User;
use App\Services\PluginSettingsService;
use Plugins\Sirsoft\Marketing\Models\MarketingConsent;
use Plugins\Sirsoft\Marketing\Services\MarketingConsentService;
/**
* 마케팅 동의 훅 리스너
*
* 사용자 생성/수정/조회 시 마케팅 동의 데이터를 처리합니다.
*/
class MarketingConsentListener implements HookListenerInterface
{
/**
* 회원가입 폼 필드 접두사 (agree_ + consent_key)
*/
private const REGISTER_FIELD_PREFIX = 'agree_';
/**
* 플러그인 식별자
*/
private const PLUGIN_ID = 'sirsoft-marketing';
/**
* MarketingConsentListener 생성자
*
* @param MarketingConsentService $service 마케팅 동의 서비스
* @param PluginSettingsService $pluginSettings 플러그인 설정 서비스
*/
public function __construct(
private MarketingConsentService $service,
private PluginSettingsService $pluginSettings
) {}
/**
* 구독할 훅 목록 반환
*
* @return array
*/
public static function getSubscribedHooks(): array
{
return [
// Filter 훅: FormRequest validation rules 확장
'core.auth.register_validation_rules' => [
'method' => 'addRegisterValidationRules',
'priority' => 10,
'type' => 'filter',
],
'core.user.create_validation_rules' => [
'method' => 'addValidationRules',
'priority' => 10,
'type' => 'filter',
],
'core.user.update_validation_rules' => [
'method' => 'addValidationRules',
'priority' => 10,
'type' => 'filter',
],
'core.user.update_profile_validation_rules' => [
'method' => 'addValidationRules',
'priority' => 10,
'type' => 'filter',
],
// Filter 훅: 요청 데이터에서 마케팅 동의 필드 분리 (DB 저장 없음)
'core.user.filter_update_data' => [
'method' => 'filterUpdateData',
'priority' => 10,
'type' => 'filter',
],
// Action 훅: 생성 후 마케팅 동의 저장 ($originalData 직접 사용)
'core.user.after_create' => [
'method' => 'afterCreate',
'priority' => 10,
],
// Action 훅: 수정 후 마케팅 동의 저장 (filter_update_data에서 분리)
'core.user.after_update' => [
'method' => 'afterUpdate',
'priority' => 10,
],
// Action 훅: 회원가입 후 마케팅 동의 저장
// (코어에서 정의된 훅명 그대로 사용 — 타이밍 접미사 없는 것이 코어 규칙)
'core.auth.register' => [
'method' => 'afterRegister',
'priority' => 10,
],
// Action 훅: 사용자 삭제 전 마케팅 동의 데이터 명시적 삭제
'core.user.before_delete' => [
'method' => 'beforeDelete',
'priority' => 10,
],
// Filter 훅: API 응답에 마케팅 동의 데이터 병합
'core.user.filter_resource_data' => [
'method' => 'filterResourceData',
'priority' => 10,
'type' => 'filter',
],
// Filter 훅: 설정 저장 시 channels 배열을 JSON 문자열로 변환
'core.plugin_settings.filter_save_data' => [
'method' => 'normalizeChannelsSaveData',
'priority' => 10,
'type' => 'filter',
],
];
}
/**
* 기본 훅 핸들러 (HookListenerInterface 필수 메서드)
*
* getSubscribedHooks()에서 개별 메서드를 지정하므로,
* 이 메서드는 호출되지 않지만 인터페이스 준수를 위해 구현
*
* @param mixed ...$args 훅 인자
* @return void
*/
public function handle(...$args): void
{
// 개별 메서드에서 처리하므로 빈 구현
}
/**
* 회원가입 FormRequest validation rules에 마케팅 동의 필드 추가
*
* 등록된 채널(동적) + 법적 필수 항목 + 마스터 키를 모두 추가합니다.
*
* @param array $rules 기존 validation rules
* @return array 마케팅 동의 필드가 추가된 rules
*/
public function addRegisterValidationRules(array $rules): array
{
foreach ($this->getAllConsentKeys() as $key) {
$rules[self::REGISTER_FIELD_PREFIX.$key] = 'nullable|boolean';
}
return $rules;
}
/**
* 관리자/마이페이지 FormRequest validation rules에 마케팅 동의 필드 추가
*
* 등록된 채널(동적) + 법적 필수 항목 + 마스터 키를 모두 추가합니다.
*
* @param array $rules 기존 validation rules
* @return array 마케팅 동의 필드가 추가된 rules
*/
public function addValidationRules(array $rules): array
{
foreach ($this->getAllConsentKeys() as $key) {
$rules[$key] = 'nullable|boolean';
}
return $rules;
}
/**
* 수정 데이터 필터: 마케팅 동의 필드 추출 후 반환 (DB 저장 없음)
*
* filter_update_data는 Filter 훅이므로 DB 저장 등 부작용 금지.
* 실제 저장은 core.user.after_update 액션 훅(afterUpdate)에서 처리합니다.
*
* @param array $data 요청 데이터
* @param User $user 수정 대상 사용자
* @return array 마케팅 필드가 제거된 데이터
*/
public function filterUpdateData(array $data, User $user): array
{
return $this->removeMarketingFields($data);
}
/**
* 생성 후 액션: $originalData에서 마케팅 동의 필드 추출하여 저장
*
* @param User $user 생성된 사용자
* @param array $originalData 원본 요청 데이터
* @return void
*/
public function afterCreate(User $user, array $originalData): void
{
$marketingData = $this->extractMarketingData($originalData);
if (! empty($marketingData)) {
$this->service->updateConsents($user->id, $marketingData, 'admin');
}
}
/**
* 수정 후 액션: $originalData에서 마케팅 동의 필드 추출하여 저장
*
* filter_update_data에서 분리된 부작용(DB 저장)을 처리합니다.
*
* @param User $user 수정된 사용자
* @param array $originalData 원본 요청 데이터
* @return void
*/
public function afterUpdate(User $user, array $originalData): void
{
$marketingData = $this->extractMarketingData($originalData);
if (empty($marketingData)) {
return;
}
$source = $this->detectSource();
$this->service->updateConsents($user->id, $marketingData, $source);
}
/**
* 회원가입 후 액션: request()에서 마케팅 동의 필드 추출하여 저장
*
* AuthService.register()에는 filter_create_data 훅이 없으므로,
* core.auth.register 액션 훅에서 request()로 직접 추출합니다.
*
* @param User $user 가입된 사용자
* @param array $context 회원가입 컨텍스트 정보
* @return void
*/
public function afterRegister(User $user, array $context): void
{
$request = request();
$marketingData = [];
foreach ($this->getAllConsentKeys() as $key) {
$registerField = self::REGISTER_FIELD_PREFIX.$key;
if ($request->has($registerField)) {
$marketingData[$key] = ! empty($request->input($registerField));
}
}
if (! empty($marketingData)) {
$this->service->updateConsents($user->id, $marketingData, 'register');
}
}
/**
* 사용자 삭제 전 액션: 마케팅 동의 데이터 명시적 삭제
*
* DB CASCADE에 의존하지 않고 명시적으로 삭제합니다.
*
* @param User $user 삭제될 사용자
* @return void
*/
public function beforeDelete(User $user): void
{
$this->service->deleteByUserId($user->id);
}
/**
* 리소스 데이터 필터: User API 응답에 마케팅 동의 데이터 병합
*
* EAV 구조 — consent_key 기준으로 Collection을 키 인덱스하여 O(1) 조회합니다.
* 활성화된 키 목록과 *_enabled 값을 포함하여 프론트엔드 조건부 렌더링에 사용합니다.
*
* @param array $data API 응답 데이터
* @param User $user 조회 대상 사용자
* @return array 마케팅 동의 데이터가 병합된 API 응답
*/
public function filterResourceData(array $data, User $user): array
{
$consents = $this->service->getAllByUserId($user->id)->keyBy('consent_key');
$activeChannelKeys = $this->service->getActiveChannelKeys();
$enabledLegalKeys = $this->service->getEnabledLegalKeys();
$allActiveKeys = array_unique(array_merge(
$activeChannelKeys,
[MarketingConsent::MASTER_KEY],
$enabledLegalKeys
));
// 동의 상태 병합
foreach ($allActiveKeys as $key) {
$record = $consents->get($key);
$data[$key] = $record?->is_consented ?? false;
$data["{$key}_at"] = $record?->consented_at?->toIso8601String();
}
// 프론트엔드 조건부 렌더링용 플래그 — 마케팅 전체 동의
$marketingSlug = $this->pluginSettings->get(self::PLUGIN_ID, 'marketing_consent_terms_slug', '');
$data['marketing_consent_enabled'] = (bool) $this->pluginSettings->get(self::PLUGIN_ID, 'marketing_consent_enabled', true);
$data['marketing_consent_terms_slug'] = $marketingSlug ?: null;
$data['marketing_consent_terms_slug_set'] = ! empty($marketingSlug);
// 프론트엔드 조건부 렌더링용 플래그 — 법적 동의 항목
$thirdPartySlug = $this->pluginSettings->get(self::PLUGIN_ID, 'third_party_consent_terms_slug', '');
$data['third_party_consent_enabled'] = (bool) $this->pluginSettings->get(self::PLUGIN_ID, 'third_party_consent_enabled');
$data['third_party_consent_terms_slug'] = $thirdPartySlug ?: null;
$data['third_party_consent_terms_slug_set'] = ! empty($thirdPartySlug);
$infoSlug = $this->pluginSettings->get(self::PLUGIN_ID, 'info_disclosure_terms_slug', '');
$data['info_disclosure_enabled'] = (bool) $this->pluginSettings->get(self::PLUGIN_ID, 'info_disclosure_enabled');
$data['info_disclosure_terms_slug'] = $infoSlug ?: null;
$data['info_disclosure_terms_slug_set'] = ! empty($infoSlug);
// 프론트엔드 조건부 렌더링용 플래그 — 채널별 (channels JSON 기반 동적 처리)
$locale = app()->getLocale();
$channelsMeta = [];
foreach ($this->service->getAllChannels() as $channel) {
$key = $channel['key'];
$slug = $channel['page_slug'] ?? '';
$data["{$key}_enabled"] = (bool) ($channel['enabled'] ?? true);
$data["{$key}_terms_slug"] = $slug ?: null;
$data["{$key}_terms_slug_set"] = ! empty($slug);
$channelsMeta[] = [
'key' => $key,
'label' => $channel['label'][$locale] ?? $channel['label']['ko'] ?? $key,
'enabled' => (bool) ($channel['enabled'] ?? true),
'terms_slug' => $slug ?: null,
'terms_slug_set' => ! empty($slug),
];
}
// 채널 목록 배열 — 프론트엔드 iteration용
$data['channels'] = $channelsMeta;
// 동의 이력 추가
$data['consent_histories'] = $this->service->getHistories($user->id)
->map(fn ($history) => [
'channel_key' => $history->channel_key,
'action' => $history->action,
'source' => $history->source,
'created_at' => $history->created_at?->format('Y-m-d H:i:s'),
])
->toArray();
return $data;
}
/**
* 모든 동의 키 목록을 반환합니다.
*
* 채널 키(동적) + 마스터 키 + 법적 필수 항목 키를 포함합니다.
*
* @return array<int, string>
*/
private function getAllConsentKeys(): array
{
return array_unique(array_merge(
$this->service->getActiveChannelKeys(),
[MarketingConsent::MASTER_KEY],
$this->service->getEnabledLegalKeys()
));
}
/**
* 요청 데이터에서 마케팅 동의 필드만 추출합니다.
*
* @param array $data 요청 데이터
* @return array 마케팅 동의 데이터
*/
private function extractMarketingData(array $data): array
{
$keys = $this->getAllConsentKeys();
return array_filter(
array_intersect_key($data, array_flip($keys)),
fn ($v) => $v !== null
);
}
/**
* 요청 데이터에서 마케팅 동의 필드를 제거합니다.
*
* @param array $data 요청 데이터
* @return array 마케팅 필드가 제거된 데이터
*/
private function removeMarketingFields(array $data): array
{
return array_diff_key($data, array_flip($this->getAllConsentKeys()));
}
/**
* 설정 저장 시 channels 배열을 JSON 문자열로 변환합니다.
*
* PluginSettingsService::save()에서 호출되며, sirsoft-marketing 플러그인의
* channels 값이 배열로 전달된 경우 JSON 문자열로 직렬화합니다.
* 이렇게 해야 getAllChannels()에서 json_decode()로 올바르게 읽을 수 있습니다.
*
* @param array $settings 저장할 설정 데이터
* @param string $identifier 플러그인 식별자
* @return array channels가 JSON 문자열로 변환된 설정 데이터
*/
public function normalizeChannelsSaveData(array $settings, string $identifier): array
{
if ($identifier !== self::PLUGIN_ID) {
return $settings;
}
if (isset($settings['channels']) && is_array($settings['channels'])) {
$settings['channels'] = json_encode($settings['channels'], JSON_UNESCAPED_UNICODE);
}
return $settings;
}
/**
* 현재 요청 경로에서 변경 출처를 판별합니다.
*
* @return string 변경 경로 (admin/profile)
*/
private function detectSource(): string
{
return request()->routeIs('api.admin.*') ? 'admin' : 'profile';
}
}
@@ -0,0 +1,112 @@
<?php
namespace Plugins\Sirsoft\Marketing\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 사용자 마케팅 동의 상태 모델 (EAV 구조)
*
* 각 레코드는 특정 사용자의 특정 동의 항목(consent_key) 상태를 나타냅니다.
* 사용자당 동의 항목별로 1개의 레코드를 유지합니다.
*
* @property int $id
* @property int $user_id
* @property string $consent_key
* @property bool $is_consented
* @property \Carbon\Carbon|null $consented_at
* @property \Carbon\Carbon|null $revoked_at
* @property int $consent_count
* @property string|null $last_source
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
* @property-read User $user
*/
class MarketingConsent extends Model
{
/**
* 테이블명
*
* @var string
*/
protected $table = 'user_marketing_consents';
/**
* 법적 필수 동의 키 목록 (고정, 채널과 무관하게 항상 존재)
*/
public const LEGAL_KEYS = [
'third_party_consent',
'info_disclosure',
];
/**
* 마케팅 동의 마스터 키 (채널 전체 동의/철회 제어)
*/
public const MASTER_KEY = 'marketing_consent';
/**
* 대량 할당 허용 필드
*
* @var array<int, string>
*/
protected $fillable = [
'user_id',
'consent_key',
'is_consented',
'consented_at',
'revoked_at',
'consent_count',
'last_source',
];
/**
* 속성 캐스팅 정의
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'is_consented' => 'boolean',
'consented_at' => 'datetime',
'revoked_at' => 'datetime',
'consent_count' => 'integer',
];
}
/**
* 동의 상태인 레코드만 조회하는 스코프
*
* @param Builder $query
* @return Builder
*/
public function scopeConsented(Builder $query): Builder
{
return $query->where('is_consented', true);
}
/**
* 특정 동의 항목 키로 조회하는 스코프
*
* @param Builder $query
* @param string $key 동의 항목 키
* @return Builder
*/
public function scopeByConsentKey(Builder $query, string $key): Builder
{
return $query->where('consent_key', $key);
}
/**
* 사용자 관계
*
* @return BelongsTo
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
@@ -0,0 +1,71 @@
<?php
namespace Plugins\Sirsoft\Marketing\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 사용자 마케팅 동의 이력 모델
*
* @property int $id
* @property int $user_id
* @property string $channel_key
* @property string $action
* @property string $source
* @property string|null $ip_address
* @property \Carbon\Carbon|null $created_at
* @property-read User $user
*/
class MarketingConsentHistory extends Model
{
/**
* 테이블명
*
* @var string
*/
protected $table = 'user_marketing_consent_histories';
/**
* updated_at 컬럼 비활성화
*
* @var bool
*/
public const UPDATED_AT = null;
/**
* 대량 할당 허용 필드
*
* @var array<int, string>
*/
protected $fillable = [
'user_id',
'channel_key',
'action',
'source',
'ip_address',
];
/**
* 속성 캐스팅 정의
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'created_at' => 'datetime',
];
}
/**
* 사용자 관계
*
* @return BelongsTo
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
@@ -0,0 +1,28 @@
<?php
namespace Plugins\Sirsoft\Marketing\Providers;
use Illuminate\Support\ServiceProvider;
use Plugins\Sirsoft\Marketing\Repositories\Contracts\MarketingConsentRepositoryInterface;
use Plugins\Sirsoft\Marketing\Repositories\MarketingConsentRepository;
/**
* 마케팅 동의 플러그인 서비스 프로바이더
*
* Repository 인터페이스와 구현체를 바인딩합니다.
*/
class MarketingServiceProvider extends ServiceProvider
{
/**
* 서비스 컨테이너 바인딩 등록
*
* @return void
*/
public function register(): void
{
$this->app->bind(
MarketingConsentRepositoryInterface::class,
MarketingConsentRepository::class
);
}
}
@@ -0,0 +1,80 @@
<?php
namespace Plugins\Sirsoft\Marketing\Repositories\Contracts;
use Illuminate\Support\Collection;
use Plugins\Sirsoft\Marketing\Models\MarketingConsent;
use Plugins\Sirsoft\Marketing\Models\MarketingConsentHistory;
/**
* 마케팅 동의 Repository 인터페이스
*/
interface MarketingConsentRepositoryInterface
{
/**
* 사용자 ID와 동의 키로 동의 레코드를 조회합니다.
*
* @param int $userId 사용자 ID
* @param string $consentKey 동의 항목 키
* @return MarketingConsent|null
*/
public function findByUserAndKey(int $userId, string $consentKey): ?MarketingConsent;
/**
* 사용자 ID로 모든 동의 레코드를 조회합니다.
*
* @param int $userId 사용자 ID
* @return Collection<int, MarketingConsent>
*/
public function getAllByUserId(int $userId): Collection;
/**
* 동의 레코드를 생성하거나 업데이트합니다.
*
* @param int $userId 사용자 ID
* @param string $consentKey 동의 항목 키
* @param array $data 업데이트 데이터
* @return MarketingConsent
*/
public function upsert(int $userId, string $consentKey, array $data): MarketingConsent;
/**
* 특정 채널 키에 동의(is_consented=true)한 사용자 수를 반환합니다.
*
* @param string $consentKey 동의 항목 키
* @return int
*/
public function countConsentedByKey(string $consentKey): int;
/**
* 사용자 ID로 모든 동의 레코드를 삭제합니다.
*
* @param int $userId 사용자 ID
* @return void
*/
public function deleteByUserId(int $userId): void;
/**
* 사용자 ID로 동의 이력을 조회합니다.
*
* @param int $userId 사용자 ID
* @return Collection<int, MarketingConsentHistory>
*/
public function getHistoriesByUserId(int $userId): Collection;
/**
* 동의 이력 레코드를 생성합니다.
*
* @param array $data 이력 데이터
* @return MarketingConsentHistory
*/
public function createHistory(array $data): MarketingConsentHistory;
/**
* 사용자 ID로 모든 동의 이력을 삭제합니다.
*
* @param int $userId 사용자 ID
* @return void
*/
public function deleteHistoriesByUserId(int $userId): void;
}
@@ -0,0 +1,119 @@
<?php
namespace Plugins\Sirsoft\Marketing\Repositories;
use Illuminate\Support\Collection;
use Plugins\Sirsoft\Marketing\Repositories\Contracts\MarketingConsentRepositoryInterface;
use Plugins\Sirsoft\Marketing\Models\MarketingConsent;
use Plugins\Sirsoft\Marketing\Models\MarketingConsentHistory;
/**
* 마케팅 동의 Repository 구현체
*/
class MarketingConsentRepository implements MarketingConsentRepositoryInterface
{
/**
* 사용자 ID와 동의 키로 동의 레코드를 조회합니다.
*
* @param int $userId 사용자 ID
* @param string $consentKey 동의 항목 키
* @return MarketingConsent|null
*/
public function findByUserAndKey(int $userId, string $consentKey): ?MarketingConsent
{
return MarketingConsent::where('user_id', $userId)
->where('consent_key', $consentKey)
->first();
}
/**
* 사용자 ID로 모든 동의 레코드를 조회합니다.
*
* @param int $userId 사용자 ID
* @return Collection<int, MarketingConsent>
*/
public function getAllByUserId(int $userId): Collection
{
return MarketingConsent::where('user_id', $userId)->get();
}
/**
* 동의 레코드를 생성하거나 업데이트합니다.
*
* @param int $userId 사용자 ID
* @param string $consentKey 동의 항목 키
* @param array $data 업데이트 데이터
* @return MarketingConsent
*/
public function upsert(int $userId, string $consentKey, array $data): MarketingConsent
{
$consent = MarketingConsent::firstOrNew([
'user_id' => $userId,
'consent_key' => $consentKey,
]);
$consent->fill($data)->save();
return $consent->fresh();
}
/**
* 특정 채널 키에 동의(is_consented=true)한 사용자 수를 반환합니다.
*
* @param string $consentKey 동의 항목 키
* @return int
*/
public function countConsentedByKey(string $consentKey): int
{
return MarketingConsent::where('consent_key', $consentKey)
->where('is_consented', true)
->count();
}
/**
* 사용자 ID로 모든 동의 레코드를 삭제합니다.
*
* @param int $userId 사용자 ID
* @return void
*/
public function deleteByUserId(int $userId): void
{
MarketingConsent::where('user_id', $userId)->delete();
}
/**
* 사용자 ID로 동의 이력을 조회합니다.
*
* @param int $userId 사용자 ID
* @return Collection<int, MarketingConsentHistory>
*/
public function getHistoriesByUserId(int $userId): Collection
{
return MarketingConsentHistory::where('user_id', $userId)
->orderByDesc('created_at')
->orderByDesc('id')
->get();
}
/**
* 동의 이력 레코드를 생성합니다.
*
* @param array $data 이력 데이터
* @return MarketingConsentHistory
*/
public function createHistory(array $data): MarketingConsentHistory
{
return MarketingConsentHistory::create($data);
}
/**
* 사용자 ID로 모든 동의 이력을 삭제합니다.
*
* @param int $userId 사용자 ID
* @return void
*/
public function deleteHistoriesByUserId(int $userId): void
{
MarketingConsentHistory::where('user_id', $userId)->delete();
}
}
@@ -0,0 +1,269 @@
<?php
namespace Plugins\Sirsoft\Marketing\Services;
use App\Extension\HookManager;
use App\Services\PluginSettingsService;
use Illuminate\Support\Collection;
use Plugins\Sirsoft\Marketing\Repositories\Contracts\MarketingConsentRepositoryInterface;
use Plugins\Sirsoft\Marketing\Models\MarketingConsent;
use Plugins\Sirsoft\Marketing\Models\MarketingConsentHistory;
/**
* 마케팅 동의 서비스 클래스
*
* 사용자별 마케팅 동의 상태의 비즈니스 로직을 담당합니다.
* EAV 구조로 동의 항목별 개별 레코드를 관리합니다.
*/
class MarketingConsentService
{
/**
* 플러그인 식별자
*/
private const PLUGIN_ID = 'sirsoft-marketing';
/**
* MarketingConsentService 생성자
*
* @param MarketingConsentRepositoryInterface $repository 마케팅 동의 Repository
* @param PluginSettingsService $pluginSettings 플러그인 설정 서비스
*/
public function __construct(
private MarketingConsentRepositoryInterface $repository,
private PluginSettingsService $pluginSettings
) {}
/**
* 활성화된 채널 목록을 반환합니다.
*
* plugin_settings의 channels JSON에서 enabled=true인 채널만 반환합니다.
*
* @return array<int, array{key: string, label: array{ko: string, en: string}, page_slug: string, enabled: bool, is_system: bool}>
*/
public function getRegisteredChannels(): array
{
$channels = $this->getAllChannels();
return array_values(array_filter($channels, fn ($ch) => (bool) ($ch['enabled'] ?? true)));
}
/**
* 비활성 채널 포함 전체 채널 목록을 반환합니다.
*
* filterResourceData 등에서 기존 동의 데이터를 포함하여 반환할 때 사용합니다.
*
* @return array<int, array{key: string, label: array{ko: string, en: string}, page_slug: string, enabled: bool, is_system: bool}>
*/
public function getAllChannels(): array
{
$raw = $this->pluginSettings->get(self::PLUGIN_ID, 'channels', '[]');
$channels = json_decode($raw, true);
return is_array($channels) ? $channels : [];
}
/**
* 기본 system 채널 목록을 반환합니다.
*
* 저장된 설정과 무관하게 항상 보장되어야 하는 system 채널 정의입니다.
* 채널 저장 시 system 채널 누락 방지에 사용됩니다.
*
* @return array<int, array{key: string, label: array{ko: string, en: string}, page_slug: string, enabled: bool, is_system: bool}>
*/
public function getDefaultSystemChannels(): array
{
return [
[
'key' => 'email_subscription',
'label' => ['ko' => '광고성 이메일 수신', 'en' => 'Email Marketing'],
'page_slug' => '',
'enabled' => true,
'is_system' => true,
],
];
}
/**
* 활성 채널 키 목록을 반환합니다.
*
* @return array<int, string>
*/
public function getActiveChannelKeys(): array
{
return array_column($this->getRegisteredChannels(), 'key');
}
/**
* 활성화된 법적 동의 키 목록을 반환합니다.
*
* LEGAL_KEYS 중 *_enabled 설정이 false인 항목을 제외합니다.
*
* @return array<int, string>
*/
public function getEnabledLegalKeys(): array
{
return array_values(array_filter(MarketingConsent::LEGAL_KEYS, function (string $key) {
return (bool) $this->pluginSettings->get(self::PLUGIN_ID, $key.'_enabled', true);
}));
}
/**
* 사용자 ID로 모든 동의 레코드를 조회합니다.
*
* @param int $userId 사용자 ID
* @return Collection<int, MarketingConsent>
*/
public function getAllByUserId(int $userId): Collection
{
return $this->repository->getAllByUserId($userId);
}
/**
* 특정 채널 키에 동의(is_consented=true)한 사용자 수를 반환합니다.
*
* @param string $channelKey 채널 키
* @return int
*/
public function countConsentedByKey(string $channelKey): int
{
return $this->repository->countConsentedByKey($channelKey);
}
/**
* 동의 상태를 업데이트합니다.
*
* marketing_consent 철회 시 모든 채널을 자동 철회합니다.
* 채널 동의 시 marketing_consent를 자동 활성화합니다.
*
* @param int $userId 사용자 ID
* @param string $consentKey 동의 항목 키
* @param bool $value 동의 여부
* @param string $source 변경 경로 (register/profile/admin)
* @return void
*/
public function updateConsent(int $userId, string $consentKey, bool $value, string $source): void
{
$existing = $this->repository->findByUserAndKey($userId, $consentKey);
// 이미 같은 상태면 중복 처리 방지 (이력 중복 기록 방지)
if ($existing && (bool) $existing->is_consented === $value) {
return;
}
$now = now();
$data = [
'is_consented' => $value,
'consented_at' => $value ? $now : null,
'revoked_at' => $value ? null : $now,
'last_source' => $source,
];
// 동의 횟수 증가 (동의 시만)
if ($value) {
$data['consent_count'] = ($existing?->consent_count ?? 0) + 1;
}
$consent = $this->repository->upsert($userId, $consentKey, $data);
// 이력 기록
$this->recordHistory($userId, $consentKey, $value ? 'granted' : 'revoked', $source);
// 정합성 규칙: marketing_consent 철회 시 실제 동의 레코드 기준으로 모든 채널 자동 철회
if ($consentKey === MarketingConsent::MASTER_KEY && ! $value) {
$userConsents = $this->repository->getAllByUserId($userId);
foreach ($userConsents as $userConsent) {
if ($userConsent->consent_key !== MarketingConsent::MASTER_KEY && $userConsent->is_consented) {
$this->updateConsent($userId, $userConsent->consent_key, false, $source);
}
}
}
// 정합성 규칙: 채널 동의 시 marketing_consent 자동 활성화
$activeChannelKeys = $this->getActiveChannelKeys();
if (in_array($consentKey, $activeChannelKeys, true) && $value) {
$master = $this->repository->findByUserAndKey($userId, MarketingConsent::MASTER_KEY);
if (! $master?->is_consented) {
$this->updateConsent($userId, MarketingConsent::MASTER_KEY, true, $source);
}
}
// 훅 발행: 동의/철회 알림
$hookName = $value ? 'sirsoft-marketing.user.subscribed' : 'sirsoft-marketing.user.unsubscribed';
HookManager::doAction($hookName, $consent, $consentKey);
// 훅 발행: 동의 상태 변경 알림
HookManager::doAction('sirsoft-marketing.user.consent_changed', $consent, [
'source' => $source,
'key' => $consentKey,
'value' => $value,
]);
}
/**
* 여러 동의 항목을 일괄 업데이트합니다.
*
* @param int $userId 사용자 ID
* @param array<string, bool> $data 동의 데이터 [key => bool]
* @param string $source 변경 경로
* @return void
*/
public function updateConsents(int $userId, array $data, string $source): void
{
// marketing_consent(master)를 먼저 처리하여 채널 처리 시 자동 활성화 중복 방지
if (array_key_exists(MarketingConsent::MASTER_KEY, $data)) {
$this->updateConsent($userId, MarketingConsent::MASTER_KEY, (bool) $data[MarketingConsent::MASTER_KEY], $source);
}
foreach ($data as $consentKey => $value) {
if ($consentKey === MarketingConsent::MASTER_KEY) {
continue;
}
$this->updateConsent($userId, $consentKey, (bool) $value, $source);
}
}
/**
* 사용자의 동의 이력을 조회합니다.
*
* @param int $userId 사용자 ID
* @return Collection<int, MarketingConsentHistory>
*/
public function getHistories(int $userId): Collection
{
return $this->repository->getHistoriesByUserId($userId);
}
/**
* 사용자 ID로 마케팅 동의 데이터를 모두 삭제합니다.
*
* DB CASCADE에 의존하지 않고 명시적으로 삭제합니다.
*
* @param int $userId 사용자 ID
* @return void
*/
public function deleteByUserId(int $userId): void
{
$this->repository->deleteHistoriesByUserId($userId);
$this->repository->deleteByUserId($userId);
}
/**
* 동의 이력을 기록합니다.
*
* @param int $userId 사용자 ID
* @param string $channelKey 동의 항목 키
* @param string $action 변경 유형 (granted/revoked)
* @param string $source 변경 경로
* @return void
*/
private function recordHistory(int $userId, string $channelKey, string $action, string $source): void
{
$this->repository->createHistory([
'user_id' => $userId,
'channel_key' => $channelKey,
'action' => $action,
'source' => $source,
'ip_address' => request()->ip(),
]);
}
}
@@ -0,0 +1,23 @@
<?php
use Illuminate\Support\Facades\Route;
use Plugins\Sirsoft\Marketing\Http\Controllers\MarketingAdminController;
use Plugins\Sirsoft\Marketing\Http\Controllers\MarketingSettingsController;
/*
* 마케팅 플러그인 공개 API 라우트
* URL prefix 자동 적용: /api/plugins/sirsoft-marketing/
*/
Route::get('/settings', [MarketingSettingsController::class, 'settings'])
->name('settings');
/*
* 마케팅 플러그인 관리자 API 라우트
* 자동 prefix 적용 후 최종 URL: /api/plugins/sirsoft-marketing/admin/channels
* 인증은 AdminBaseController 미들웨어에서 처리
*/
Route::prefix('admin')->name('admin.')->group(function () {
Route::put('/channels', [MarketingAdminController::class, 'updateChannels'])
->name('channels.update');
});
@@ -0,0 +1,251 @@
<?php
namespace Plugins\Sirsoft\Marketing\Tests\Feature\Http\Controllers;
use App\Models\User;
use App\Services\PluginSettingsService;
use Plugins\Sirsoft\Marketing\Models\MarketingConsent;
use Plugins\Sirsoft\Marketing\Tests\PluginTestCase;
/**
* 마케팅 관리자 채널 저장 API 테스트
*
* PUT /api/plugins/sirsoft-marketing/admin/channels
*/
class MarketingAdminControllerTest extends PluginTestCase
{
/**
* 기본 채널 목록 (channels JSON으로 저장된 상태 시뮬레이션)
*/
private const EXISTING_CHANNELS = [
[
'key' => 'email_subscription',
'label' => ['ko' => '광고성 이메일 수신', 'en' => 'Email Marketing'],
'page_slug' => '',
'enabled' => true,
'is_system' => true,
],
];
protected function setUp(): void
{
parent::setUp();
$mock = $this->createMock(PluginSettingsService::class);
$mock->method('get')->willReturnCallback(
fn (string $id, string $key, mixed $default = null) => match ($key) {
'channels' => json_encode(self::EXISTING_CHANNELS),
default => $default,
}
);
$this->app->instance(PluginSettingsService::class, $mock);
}
// ── 인증 ──
public function test_update_channels_requires_authentication(): void
{
$response = $this->putJson('/api/plugins/sirsoft-marketing/admin/channels', [
'channels' => self::EXISTING_CHANNELS,
]);
$response->assertUnauthorized();
}
public function test_update_channels_succeeds_for_admin(): void
{
$admin = $this->createAdminUser();
$response = $this->actingAs($admin)->putJson(
'/api/plugins/sirsoft-marketing/admin/channels',
['channels' => self::EXISTING_CHANNELS]
);
$response->assertOk();
}
// ── 기본 저장 ──
public function test_update_channels_saves_new_channel(): void
{
$admin = $this->createAdminUser();
$channels = array_merge(self::EXISTING_CHANNELS, [
[
'key' => 'sms_subscription',
'label' => ['ko' => '광고성 SMS', 'en' => 'SMS Marketing'],
'page_slug' => '',
'enabled' => true,
'is_system' => false,
],
]);
$response = $this->actingAs($admin)->putJson(
'/api/plugins/sirsoft-marketing/admin/channels',
['channels' => $channels]
);
$response->assertOk()
->assertJsonPath('data.channels.1.key', 'sms_subscription');
}
// ── 검증: key 중복 ──
public function test_update_channels_rejects_duplicate_keys(): void
{
$admin = $this->createAdminUser();
$channels = [
['key' => 'email_subscription', 'label' => ['ko' => 'A', 'en' => 'A'], 'page_slug' => '', 'enabled' => true, 'is_system' => true],
['key' => 'email_subscription', 'label' => ['ko' => 'B', 'en' => 'B'], 'page_slug' => '', 'enabled' => true, 'is_system' => false],
];
$response = $this->actingAs($admin)->putJson(
'/api/plugins/sirsoft-marketing/admin/channels',
['channels' => $channels]
);
$response->assertUnprocessable()
->assertJsonValidationErrors(['channels']);
}
// ── 검증: key 형식 ──
public function test_update_channels_rejects_invalid_key_format(): void
{
$admin = $this->createAdminUser();
$channels = [
['key' => 'invalid-key!', 'label' => ['ko' => 'A', 'en' => 'A'], 'page_slug' => '', 'enabled' => true, 'is_system' => false],
];
$response = $this->actingAs($admin)->putJson(
'/api/plugins/sirsoft-marketing/admin/channels',
['channels' => $channels]
);
$response->assertUnprocessable()
->assertJsonValidationErrors(['channels.0.key']);
}
// ── 검증: is_system 채널 누락 거부 ──
public function test_update_channels_rejects_removal_of_system_channel(): void
{
$admin = $this->createAdminUser();
// email_subscription(is_system=true) 없이 제출
$channels = [
['key' => 'sms_subscription', 'label' => ['ko' => 'SMS', 'en' => 'SMS'], 'page_slug' => '', 'enabled' => true, 'is_system' => false],
];
$response = $this->actingAs($admin)->putJson(
'/api/plugins/sirsoft-marketing/admin/channels',
['channels' => $channels]
);
$response->assertUnprocessable()
->assertJsonValidationErrors(['channels']);
}
// ── 검증: is_system 플래그 위변조 거부 ──
public function test_update_channels_rejects_system_flag_downgrade(): void
{
$admin = $this->createAdminUser();
// email_subscription의 is_system을 false로 위변조
$channels = [
['key' => 'email_subscription', 'label' => ['ko' => '광고성 이메일', 'en' => 'Email'], 'page_slug' => '', 'enabled' => true, 'is_system' => false],
];
$response = $this->actingAs($admin)->putJson(
'/api/plugins/sirsoft-marketing/admin/channels',
['channels' => $channels]
);
$response->assertUnprocessable()
->assertJsonValidationErrors(['channels.0.is_system']);
}
// ── 검증: 동의 데이터 존재 시 채널 삭제 거부 ──
public function test_update_channels_rejects_deletion_when_consents_exist(): void
{
$admin = $this->createAdminUser();
$user = User::factory()->create();
// sms_subscription 채널에 동의 데이터 생성
MarketingConsent::create([
'user_id' => $user->id,
'consent_key' => 'sms_subscription',
'is_consented' => true,
'consented_at' => now(),
'revoked_at' => null,
'last_source' => 'register',
'consent_count' => 1,
]);
// sms_subscription을 제출 목록에서 제외 (삭제 시도)
$response = $this->actingAs($admin)->putJson(
'/api/plugins/sirsoft-marketing/admin/channels',
['channels' => self::EXISTING_CHANNELS]
);
// sms_subscription은 EXISTING_CHANNELS에 없으므로 삭제 시도로 감지
// → 동의 데이터 없으면 통과 (이 테스트에서는 별도 채널이라 422 미발생)
// 실제 삭제 거부는 기존 channels에 있는 키가 제출에서 빠진 경우
// setUp mock의 EXISTING_CHANNELS에 sms가 없으므로 이 테스트는 OK 응답
$response->assertOk();
}
public function test_update_channels_rejects_deletion_of_existing_channel_with_consents(): void
{
$admin = $this->createAdminUser();
$user = User::factory()->create();
// email_subscription에 동의 데이터 생성
MarketingConsent::create([
'user_id' => $user->id,
'consent_key' => 'email_subscription',
'is_consented' => true,
'consented_at' => now(),
'revoked_at' => null,
'last_source' => 'register',
'consent_count' => 1,
]);
// email_subscription을 제외하고 제출 (삭제 시도)
$channels = [
['key' => 'sms_subscription', 'label' => ['ko' => 'SMS', 'en' => 'SMS'], 'page_slug' => '', 'enabled' => true, 'is_system' => false],
];
$response = $this->actingAs($admin)->putJson(
'/api/plugins/sirsoft-marketing/admin/channels',
['channels' => $channels]
);
// is_system 누락으로 422 (system 채널 삭제 거부가 먼저 발동)
$response->assertUnprocessable()
->assertJsonValidationErrors(['channels']);
}
// ── 검증: 필수 필드 누락 ──
public function test_update_channels_rejects_missing_label(): void
{
$admin = $this->createAdminUser();
$channels = [
['key' => 'sms_subscription', 'page_slug' => '', 'enabled' => true, 'is_system' => false],
];
$response = $this->actingAs($admin)->putJson(
'/api/plugins/sirsoft-marketing/admin/channels',
['channels' => $channels]
);
$response->assertUnprocessable()
->assertJsonValidationErrors(['channels.0.label']);
}
}
@@ -0,0 +1,210 @@
<?php
namespace Plugins\Sirsoft\Marketing\Tests\Feature\Http\Controllers;
use App\Services\PluginSettingsService;
use Plugins\Sirsoft\Marketing\Tests\PluginTestCase;
/**
* 마케팅 공개 설정 API 컨트롤러 테스트
*
* GET /api/plugins/sirsoft-marketing/settings
*/
class MarketingSettingsControllerTest extends PluginTestCase
{
/**
* marketing_consent_enabled가 true이면 응답에 포함되는지 검증합니다.
*/
public function test_settings_returns_marketing_consent_enabled(): void
{
$this->mockSettings(marketingConsentEnabled: true, channels: []);
$response = $this->getJson('/api/plugins/sirsoft-marketing/settings');
$response->assertOk()
->assertJsonPath('data.marketing_consent_enabled', true);
}
/**
* marketing_consent_enabled가 false이면 false로 반환되는지 검증합니다.
*/
public function test_settings_returns_marketing_consent_disabled(): void
{
$this->mockSettings(marketingConsentEnabled: false, channels: []);
$response = $this->getJson('/api/plugins/sirsoft-marketing/settings');
$response->assertOk()
->assertJsonPath('data.marketing_consent_enabled', false);
}
/**
* channels 배열이 응답에 포함되는지 검증합니다.
*/
public function test_settings_returns_channels_array(): void
{
$channels = [
[
'key' => 'email_subscription',
'label' => ['ko' => '광고성 이메일 수신', 'en' => 'Email Marketing'],
'page_slug' => '',
'enabled' => true,
'is_system' => true,
],
];
$this->mockSettings(marketingConsentEnabled: true, channels: $channels);
$response = $this->getJson('/api/plugins/sirsoft-marketing/settings');
$response->assertOk()
->assertJsonPath('data.channels.0.key', 'email_subscription')
->assertJsonPath('data.channels.0.enabled', true);
}
/**
* 비활성화된 채널은 channels 배열에서 제외되는지 검증합니다.
*/
public function test_settings_excludes_disabled_channels(): void
{
$channels = [
[
'key' => 'email_subscription',
'label' => ['ko' => '광고성 이메일 수신', 'en' => 'Email Marketing'],
'page_slug' => '',
'enabled' => false,
'is_system' => true,
],
[
'key' => 'sms_subscription',
'label' => ['ko' => 'SMS', 'en' => 'SMS'],
'page_slug' => '',
'enabled' => true,
'is_system' => false,
],
];
$this->mockSettings(marketingConsentEnabled: true, channels: $channels);
$response = $this->getJson('/api/plugins/sirsoft-marketing/settings');
$response->assertOk();
$responseChannels = $response->json('data.channels');
$keys = array_column($responseChannels, 'key');
$this->assertNotContains('email_subscription', $keys);
$this->assertContains('sms_subscription', $keys);
}
/**
* channels가 비어있으면 빈 배열을 반환하는지 검증합니다.
*/
public function test_settings_returns_empty_channels_when_none_configured(): void
{
$this->mockSettings(marketingConsentEnabled: true, channels: []);
$response = $this->getJson('/api/plugins/sirsoft-marketing/settings');
$response->assertOk()
->assertJsonPath('data.channels', []);
}
/**
* marketing_consent_terms_slug가 설정된 경우 slug_set이 true로 반환되는지 검증합니다.
*/
public function test_settings_returns_terms_slug_set_true_when_slug_is_set(): void
{
$this->mockSettings(
marketingConsentEnabled: true,
channels: [],
marketingConsentTermsSlug: 'marketing-terms'
);
$response = $this->getJson('/api/plugins/sirsoft-marketing/settings');
$response->assertOk()
->assertJsonPath('data.marketing_consent_terms_slug', 'marketing-terms')
->assertJsonPath('data.marketing_consent_terms_slug_set', true);
}
/**
* marketing_consent_terms_slug가 비어있는 경우 slug_set이 false로 반환되는지 검증합니다.
*/
public function test_settings_returns_terms_slug_set_false_when_slug_is_empty(): void
{
$this->mockSettings(marketingConsentEnabled: true, channels: [], marketingConsentTermsSlug: '');
$response = $this->getJson('/api/plugins/sirsoft-marketing/settings');
$response->assertOk()
->assertJsonPath('data.marketing_consent_terms_slug', null)
->assertJsonPath('data.marketing_consent_terms_slug_set', false);
}
/**
* 채널에 page_slug가 설정된 경우 terms_slug_set이 true로 반환되는지 검증합니다.
*/
public function test_settings_channel_terms_slug_set_true_when_slug_set(): void
{
$channels = [
[
'key' => 'email_subscription',
'label' => ['ko' => '광고성 이메일 수신', 'en' => 'Email Marketing'],
'page_slug' => 'email-terms',
'enabled' => true,
'is_system' => true,
],
];
$this->mockSettings(marketingConsentEnabled: true, channels: $channels);
$response = $this->getJson('/api/plugins/sirsoft-marketing/settings');
$response->assertOk()
->assertJsonPath('data.channels.0.terms_slug', 'email-terms')
->assertJsonPath('data.channels.0.terms_slug_set', true);
}
/**
* 인증 없이도 접근 가능한지 검증합니다 (공개 API).
*/
public function test_settings_is_publicly_accessible_without_auth(): void
{
$this->mockSettings(marketingConsentEnabled: true, channels: []);
$response = $this->getJson('/api/plugins/sirsoft-marketing/settings');
$response->assertOk();
}
/**
* PluginSettingsService mock을 설정합니다.
*
* @param bool $marketingConsentEnabled 마케팅 동의 활성화 여부
* @param array<int, array> $channels 채널 목록
* @param string $marketingConsentTermsSlug 마케팅 동의 약관 slug
*/
private function mockSettings(
bool $marketingConsentEnabled,
array $channels,
string $marketingConsentTermsSlug = ''
): void {
$mock = $this->createMock(PluginSettingsService::class);
$mock->method('get')->willReturnCallback(
function (string $id, string $key, mixed $default = null) use (
$marketingConsentEnabled,
$channels,
$marketingConsentTermsSlug
) {
return match ($key) {
'marketing_consent_enabled' => $marketingConsentEnabled,
'marketing_consent_terms_slug' => $marketingConsentTermsSlug,
'channels' => json_encode($channels),
default => $default,
};
}
);
$this->app->instance(PluginSettingsService::class, $mock);
}
}
@@ -0,0 +1,100 @@
<?php
namespace Plugins\Sirsoft\Marketing\Tests;
use App\Enums\PermissionType;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Route;
use Plugins\Sirsoft\Marketing\Contracts\MarketingConsentRepositoryInterface;
use Plugins\Sirsoft\Marketing\Http\Controllers\MarketingAdminController;
use Plugins\Sirsoft\Marketing\Http\Controllers\MarketingSettingsController;
use Plugins\Sirsoft\Marketing\Repositories\MarketingConsentRepository;
use Tests\TestCase;
/**
* Marketing 플러그인 테스트 베이스 클래스
*
* 모든 Marketing 플러그인 테스트는 이 클래스를 상속받아야 합니다.
* 코어 + 플러그인 마이그레이션을 자동으로 처리합니다.
*/
abstract class PluginTestCase extends TestCase
{
use RefreshDatabase;
/**
* 테스트 환경 설정
*
* 플러그인 라우트는 테스트 환경에서 플러그인이 로드되지 않으므로
* 직접 등록합니다.
*/
protected function setUp(): void
{
parent::setUp();
$this->app->bind(MarketingConsentRepositoryInterface::class, MarketingConsentRepository::class);
Route::prefix('api/plugins/sirsoft-marketing')
->middleware('api')
->group(function () {
Route::get('/settings', [MarketingSettingsController::class, 'settings'])
->name('api.sirsoft-marketing.settings');
Route::prefix('admin')
->middleware('auth:sanctum')
->group(function () {
Route::put('/channels', [MarketingAdminController::class, 'updateChannels'])
->name('api.sirsoft-marketing.admin.channels.update');
});
});
}
/**
* 관리자 권한을 가진 사용자를 생성합니다.
*
* isAdmin()이 Role/Permission 기반이므로 admin Role과 type=admin Permission을 직접 생성합니다.
*
* @return User
*/
protected function createAdminUser(): User
{
$adminRole = Role::firstOrCreate(
['identifier' => 'admin'],
['name' => ['ko' => '관리자', 'en' => 'Admin'], 'description' => ['ko' => '관리자', 'en' => 'Admin']]
);
$permission = Permission::firstOrCreate(
['identifier' => 'admin.access'],
['name' => ['ko' => '관리자 접근', 'en' => 'Admin Access'], 'type' => PermissionType::Admin]
);
$adminRole->permissions()->syncWithoutDetaching([$permission->id]);
$user = User::factory()->create();
$user->roles()->attach($adminRole->id);
return $user;
}
/**
* 마이그레이션 경로를 반환합니다.
*
* RefreshDatabase의 migrate:fresh 명령에 코어 + 플러그인 마이그레이션 경로를 전달합니다.
*
* @return array
*/
protected function migrateFreshUsing(): array
{
return [
'--drop-views' => $this->shouldDropViews(),
'--drop-types' => $this->shouldDropTypes(),
'--seed' => false,
'--path' => [
'database/migrations',
'plugins/_bundled/sirsoft-marketing/database/migrations',
],
];
}
}
@@ -0,0 +1,400 @@
<?php
namespace Plugins\Sirsoft\Marketing\Tests\Unit\Listeners;
use App\Models\User;
use App\Services\PluginSettingsService;
use Illuminate\Http\Request;
use Plugins\Sirsoft\Marketing\Listeners\MarketingConsentListener;
use Plugins\Sirsoft\Marketing\Models\MarketingConsent;
use Plugins\Sirsoft\Marketing\Models\MarketingConsentHistory;
use Plugins\Sirsoft\Marketing\Repositories\MarketingConsentRepository;
use Plugins\Sirsoft\Marketing\Services\MarketingConsentService;
use Plugins\Sirsoft\Marketing\Tests\PluginTestCase;
class MarketingConsentListenerTest extends PluginTestCase
{
private MarketingConsentListener $listener;
private MarketingConsentService $service;
/**
* 기본 채널 목록 (테스트용)
*/
private const DEFAULT_CHANNELS = [
[
'key' => 'email_subscription',
'label' => ['ko' => '광고성 이메일 수신', 'en' => 'Email Marketing'],
'page_slug' => 'email-terms',
'enabled' => true,
'is_system' => true,
],
];
protected function setUp(): void
{
parent::setUp();
$pluginSettings = $this->createMock(PluginSettingsService::class);
$pluginSettings->method('get')->willReturnCallback(
function (string $id, string $key, mixed $default = null) {
return match ($key) {
'channels' => json_encode(self::DEFAULT_CHANNELS),
'marketing_consent_enabled' => true,
'marketing_consent_terms_slug' => 'marketing-terms',
default => $default ?? true,
};
}
);
$repository = new MarketingConsentRepository();
$this->service = new MarketingConsentService($repository, $pluginSettings);
$this->listener = new MarketingConsentListener($this->service, $pluginSettings);
}
// ── getSubscribedHooks 구조 검증 ──
public function test_get_subscribed_hooks_returns_expected_hooks(): void
{
$hooks = MarketingConsentListener::getSubscribedHooks();
$this->assertIsArray($hooks);
$this->assertArrayHasKey('core.auth.register_validation_rules', $hooks);
$this->assertArrayHasKey('core.user.create_validation_rules', $hooks);
$this->assertArrayHasKey('core.user.update_validation_rules', $hooks);
$this->assertArrayHasKey('core.user.update_profile_validation_rules', $hooks);
$this->assertArrayHasKey('core.user.filter_update_data', $hooks);
$this->assertArrayHasKey('core.user.after_create', $hooks);
$this->assertArrayHasKey('core.user.after_update', $hooks);
$this->assertArrayHasKey('core.user.before_delete', $hooks);
$this->assertArrayHasKey('core.auth.register', $hooks);
$this->assertArrayHasKey('core.user.filter_resource_data', $hooks);
}
public function test_filter_hooks_have_type_filter(): void
{
$hooks = MarketingConsentListener::getSubscribedHooks();
$filterHooks = [
'core.auth.register_validation_rules',
'core.user.create_validation_rules',
'core.user.update_validation_rules',
'core.user.update_profile_validation_rules',
'core.user.filter_update_data',
'core.user.filter_resource_data',
];
foreach ($filterHooks as $hookName) {
$this->assertEquals(
'filter',
$hooks[$hookName]['type'],
"훅 '{$hookName}'에 type => 'filter'가 명시되어야 합니다."
);
}
}
public function test_action_hooks_do_not_have_type_filter(): void
{
$hooks = MarketingConsentListener::getSubscribedHooks();
$actionHooks = [
'core.user.after_create',
'core.user.after_update',
'core.user.before_delete',
'core.auth.register',
];
foreach ($actionHooks as $hookName) {
$this->assertArrayNotHasKey(
'type',
$hooks[$hookName],
"액션 훅 '{$hookName}'에는 type 키가 없어야 합니다."
);
}
}
// ── addRegisterValidationRules ──
public function test_add_register_validation_rules_appends_agree_fields(): void
{
$existingRules = ['name' => 'required|string', 'email' => 'required|email'];
$result = $this->listener->addRegisterValidationRules($existingRules);
// agree_ 접두사 + 동의 키
$this->assertArrayHasKey('agree_email_subscription', $result);
$this->assertArrayHasKey('agree_marketing_consent', $result);
$this->assertArrayHasKey('agree_third_party_consent', $result);
$this->assertArrayHasKey('agree_info_disclosure', $result);
// 기존 rules 유지
$this->assertArrayHasKey('name', $result);
$this->assertArrayHasKey('email', $result);
// 모두 nullable|boolean
$this->assertEquals('nullable|boolean', $result['agree_email_subscription']);
}
// ── addValidationRules ──
public function test_add_validation_rules_appends_marketing_fields(): void
{
$existingRules = ['name' => 'required|string'];
$result = $this->listener->addValidationRules($existingRules);
// 접두사 없는 키
$this->assertArrayHasKey('email_subscription', $result);
$this->assertArrayHasKey('marketing_consent', $result);
$this->assertArrayHasKey('third_party_consent', $result);
$this->assertArrayHasKey('info_disclosure', $result);
$this->assertArrayHasKey('name', $result);
$this->assertEquals('nullable|boolean', $result['email_subscription']);
}
// ── filterUpdateData (Filter 훅: DB 저장 없음) ──
public function test_filter_update_data_removes_marketing_fields_without_db_save(): void
{
$user = User::factory()->create();
$data = [
'name' => 'Updated Name',
'email_subscription' => true,
'marketing_consent' => true,
];
$result = $this->listener->filterUpdateData($data, $user);
// 마케팅 필드가 제거됨
$this->assertArrayNotHasKey('email_subscription', $result);
$this->assertArrayNotHasKey('marketing_consent', $result);
$this->assertArrayHasKey('name', $result);
// DB에 저장되지 않아야 함
$this->assertCount(0, MarketingConsent::where('user_id', $user->id)->get());
}
public function test_filter_update_data_removes_all_consent_keys(): void
{
$user = User::factory()->create();
$data = [
'name' => 'Test',
'email_subscription' => true,
'marketing_consent' => false,
'third_party_consent' => true,
'info_disclosure' => false,
];
$result = $this->listener->filterUpdateData($data, $user);
$this->assertArrayNotHasKey('email_subscription', $result);
$this->assertArrayNotHasKey('marketing_consent', $result);
$this->assertArrayNotHasKey('third_party_consent', $result);
$this->assertArrayNotHasKey('info_disclosure', $result);
$this->assertArrayHasKey('name', $result);
}
// ── afterCreate ($originalData 직접 사용) ──
public function test_after_create_saves_consent_from_original_data(): void
{
$user = User::factory()->create();
$originalData = [
'name' => 'Test User',
'email' => 'test@example.com',
'email_subscription' => true,
'third_party_consent' => true,
];
$this->listener->afterCreate($user, $originalData);
$consents = MarketingConsent::where('user_id', $user->id)->get()->keyBy('consent_key');
$this->assertTrue($consents->get('email_subscription')?->is_consented ?? false);
$this->assertTrue($consents->get('third_party_consent')?->is_consented ?? false);
}
public function test_after_create_does_nothing_when_no_marketing_fields(): void
{
$user = User::factory()->create();
$this->listener->afterCreate($user, ['name' => 'Test', 'email' => 'test@example.com']);
$this->assertCount(0, MarketingConsent::where('user_id', $user->id)->get());
}
// ── afterUpdate ($originalData에서 마케팅 필드 추출) ──
public function test_after_update_saves_consent_from_original_data(): void
{
// admin 라우트로 요청 시뮬레이션
$request = Request::create('/api/admin/users/1', 'PUT');
$request->setRouteResolver(function () use ($request) {
$route = new \Illuminate\Routing\Route('PUT', 'api/admin/users/{id}', []);
$route->name('api.admin.users.update');
$route->bind($request);
return $route;
});
$this->app->instance('request', $request);
$user = User::factory()->create();
$originalData = [
'name' => 'Updated Name',
'email_subscription' => true,
'marketing_consent' => true,
];
$this->listener->afterUpdate($user, $originalData);
$consents = MarketingConsent::where('user_id', $user->id)->get()->keyBy('consent_key');
$this->assertTrue($consents->get('email_subscription')?->is_consented ?? false);
$this->assertTrue($consents->get('marketing_consent')?->is_consented ?? false);
$this->assertEquals('admin', $consents->get('email_subscription')?->last_source);
}
public function test_after_update_does_nothing_when_no_marketing_fields(): void
{
$user = User::factory()->create();
$this->listener->afterUpdate($user, ['name' => 'Updated Name']);
$this->assertCount(0, MarketingConsent::where('user_id', $user->id)->get());
}
// ── afterRegister ──
public function test_after_register_extracts_agree_fields_from_request(): void
{
$user = User::factory()->create();
$request = Request::create('/api/auth/register', 'POST', [
'agree_email_subscription' => '1',
'agree_marketing_consent' => '1',
'agree_third_party_consent' => '',
'agree_info_disclosure' => '0',
]);
$this->app->instance('request', $request);
$this->listener->afterRegister($user, ['type' => 'email']);
$consents = MarketingConsent::where('user_id', $user->id)->get()->keyBy('consent_key');
$this->assertTrue($consents->get('email_subscription')?->is_consented ?? false);
$this->assertTrue($consents->get('marketing_consent')?->is_consented ?? false);
// '' 값은 !empty() === false → 동의하지 않음 (레코드가 있어도 is_consented=false)
$this->assertFalse($consents->get('third_party_consent')?->is_consented ?? false);
// '0' 값은 false → 동의하지 않음
$this->assertFalse($consents->get('info_disclosure')?->is_consented ?? false);
}
// ── beforeDelete (명시적 삭제) ──
public function test_before_delete_removes_consent_data(): void
{
$user = User::factory()->create();
// email_subscription 동의 시 marketing_consent(master)도 자동 활성화 → 2개 레코드
$this->service->updateConsent($user->id, 'email_subscription', true, 'register');
$this->assertGreaterThan(0, MarketingConsent::where('user_id', $user->id)->count());
$this->listener->beforeDelete($user);
$this->assertCount(0, MarketingConsent::where('user_id', $user->id)->get());
$this->assertCount(0, MarketingConsentHistory::where('user_id', $user->id)->get());
}
// ── filterResourceData ──
public function test_filter_resource_data_merges_consent_fields(): void
{
$user = User::factory()->create();
$this->service->updateConsents($user->id, [
'email_subscription' => true,
'marketing_consent' => true,
], 'register');
$data = ['id' => $user->id, 'name' => $user->name];
$result = $this->listener->filterResourceData($data, $user);
// 동의 상태
$this->assertTrue($result['email_subscription']);
$this->assertTrue($result['marketing_consent']);
// ISO8601 타임스탬프 필드
$this->assertNotNull($result['email_subscription_at']);
// marketing_consent는 updateConsents로 직접 동의 시 consented_at이 생성됨
// 마케팅 전체 동의 플래그
$this->assertTrue($result['marketing_consent_enabled']);
$this->assertNotNull($result['marketing_consent_terms_slug']);
$this->assertTrue($result['marketing_consent_terms_slug_set']);
// 채널별 플래그 (DEFAULT_CHANNELS: email_subscription, enabled=true, page_slug='email-terms')
$this->assertTrue($result['email_subscription_enabled']);
$this->assertEquals('email-terms', $result['email_subscription_terms_slug']);
$this->assertTrue($result['email_subscription_terms_slug_set']);
// channels 배열 (프론트엔드 iteration용)
$this->assertArrayHasKey('channels', $result);
$this->assertIsArray($result['channels']);
$this->assertCount(1, $result['channels']);
$this->assertEquals('email_subscription', $result['channels'][0]['key']);
// consent_histories 배열
$this->assertArrayHasKey('consent_histories', $result);
$this->assertIsArray($result['consent_histories']);
}
public function test_filter_resource_data_returns_false_when_no_consent(): void
{
$user = User::factory()->create();
$data = ['id' => $user->id, 'name' => $user->name];
$result = $this->listener->filterResourceData($data, $user);
$this->assertFalse($result['email_subscription']);
$this->assertFalse($result['marketing_consent']);
$this->assertNull($result['email_subscription_at']);
$this->assertEmpty($result['consent_histories']);
// channels 배열은 항상 포함
$this->assertArrayHasKey('channels', $result);
}
public function test_filter_resource_data_includes_channel_key_in_history(): void
{
$user = User::factory()->create();
// email_subscription 동의 시 marketing_consent(master)도 자동 활성화
// → 이력은 내림차순이므로 나중에 생성된 marketing_consent가 [0]
$this->service->updateConsent($user->id, 'email_subscription', true, 'register');
$data = ['id' => $user->id];
$result = $this->listener->filterResourceData($data, $user);
$this->assertNotEmpty($result['consent_histories']);
// 이력 구조 검증 (첫 번째 항목)
$history = $result['consent_histories'][0];
$this->assertArrayHasKey('channel_key', $history);
$this->assertArrayHasKey('action', $history);
$this->assertArrayHasKey('source', $history);
$this->assertArrayHasKey('created_at', $history);
$this->assertEquals('granted', $history['action']);
$this->assertEquals('register', $history['source']);
// 전체 이력에서 email_subscription granted 확인
$channelKeys = array_column($result['consent_histories'], 'channel_key');
$this->assertContains('email_subscription', $channelKeys);
}
}
@@ -0,0 +1,321 @@
<?php
namespace Plugins\Sirsoft\Marketing\Tests\Unit\Services;
use App\Models\User;
use App\Services\PluginSettingsService;
use Plugins\Sirsoft\Marketing\Contracts\MarketingConsentRepositoryInterface;
use Plugins\Sirsoft\Marketing\Models\MarketingConsent;
use Plugins\Sirsoft\Marketing\Models\MarketingConsentHistory;
use Plugins\Sirsoft\Marketing\Repositories\MarketingConsentRepository;
use Plugins\Sirsoft\Marketing\Services\MarketingConsentService;
use Plugins\Sirsoft\Marketing\Tests\PluginTestCase;
class MarketingConsentServiceTest extends PluginTestCase
{
private MarketingConsentService $service;
/** @var PluginSettingsService&\PHPUnit\Framework\MockObject\MockObject */
private PluginSettingsService $pluginSettings;
protected function setUp(): void
{
parent::setUp();
$this->pluginSettings = $this->createMock(PluginSettingsService::class);
// 기본적으로 channels JSON 반환 + 모든 법적 항목 활성화 (*_enabled = true)
$this->pluginSettings->method('get')->willReturnCallback(
function (string $id, string $key, mixed $default = null) {
if ($key === 'channels') {
return json_encode([
[
'key' => 'email_subscription',
'label' => ['ko' => '광고성 이메일 수신', 'en' => 'Email Marketing'],
'page_slug' => '',
'enabled' => true,
'is_system' => true,
],
]);
}
return $default ?? true;
}
);
$repository = new MarketingConsentRepository();
$this->service = new MarketingConsentService($repository, $this->pluginSettings);
}
/**
* 특정 channels JSON을 반환하는 서비스 인스턴스를 생성합니다.
*
* @param array<int, array{key: string, enabled: bool}> $channels 채널 목록
* @return MarketingConsentService
*/
private function makeServiceWithChannels(array $channels): MarketingConsentService
{
$mock = $this->createMock(PluginSettingsService::class);
$mock->method('get')->willReturnCallback(
function (string $id, string $key, mixed $default = null) use ($channels) {
if ($key === 'channels') {
return json_encode($channels);
}
return $default ?? true;
}
);
return new MarketingConsentService(new MarketingConsentRepository(), $mock);
}
// ── getAllByUserId ──
public function test_get_all_by_user_id_returns_empty_when_no_consent(): void
{
$user = User::factory()->create();
$result = $this->service->getAllByUserId($user->id);
$this->assertCount(0, $result);
}
public function test_get_all_by_user_id_returns_eav_records(): void
{
$user = User::factory()->create();
$this->service->updateConsent($user->id, 'email_subscription', true, 'register');
$this->service->updateConsent($user->id, 'marketing_consent', true, 'register');
$result = $this->service->getAllByUserId($user->id);
$this->assertCount(2, $result);
$keys = $result->pluck('consent_key')->sort()->values()->toArray();
$this->assertEquals(['email_subscription', 'marketing_consent'], $keys);
}
// ── updateConsent: 단일 항목 동의/철회 ──
public function test_update_consent_creates_new_record_when_granted(): void
{
$user = User::factory()->create();
$this->service->updateConsent($user->id, 'email_subscription', true, 'register');
$record = MarketingConsent::where('user_id', $user->id)
->where('consent_key', 'email_subscription')
->first();
$this->assertNotNull($record);
$this->assertTrue($record->is_consented);
$this->assertNotNull($record->consented_at);
$this->assertNull($record->revoked_at);
$this->assertEquals('register', $record->last_source);
$this->assertEquals(1, $record->consent_count);
}
public function test_update_consent_sets_revoked_at_when_false(): void
{
$user = User::factory()->create();
$this->service->updateConsent($user->id, 'email_subscription', true, 'register');
$this->service->updateConsent($user->id, 'email_subscription', false, 'admin');
$record = MarketingConsent::where('user_id', $user->id)
->where('consent_key', 'email_subscription')
->first();
$this->assertFalse($record->is_consented);
$this->assertNull($record->consented_at);
$this->assertNotNull($record->revoked_at);
$this->assertEquals('admin', $record->last_source);
}
public function test_update_consent_records_history(): void
{
$user = User::factory()->create();
$this->service->updateConsent($user->id, 'email_subscription', true, 'register');
$history = MarketingConsentHistory::where('user_id', $user->id)->first();
$this->assertNotNull($history);
$this->assertEquals('email_subscription', $history->channel_key);
$this->assertEquals('granted', $history->action);
$this->assertEquals('register', $history->source);
}
// ── 정합성 규칙: master 철회 시 채널 자동 철회 ──
public function test_revoking_master_auto_revokes_all_channels(): void
{
$user = User::factory()->create();
// 채널 먼저 동의
$this->service->updateConsent($user->id, 'email_subscription', true, 'register');
$this->service->updateConsent($user->id, MarketingConsent::MASTER_KEY, true, 'register');
// master 철회
$this->service->updateConsent($user->id, MarketingConsent::MASTER_KEY, false, 'admin');
$emailRecord = MarketingConsent::where('user_id', $user->id)
->where('consent_key', 'email_subscription')
->first();
$this->assertFalse($emailRecord->is_consented);
}
// ── 정합성 규칙: 채널 동의 시 master 자동 활성화 ──
public function test_granting_channel_auto_activates_master(): void
{
$user = User::factory()->create();
// master 미동의 상태에서 채널 동의
$this->service->updateConsent($user->id, 'email_subscription', true, 'register');
$masterRecord = MarketingConsent::where('user_id', $user->id)
->where('consent_key', MarketingConsent::MASTER_KEY)
->first();
$this->assertNotNull($masterRecord);
$this->assertTrue($masterRecord->is_consented);
}
// ── updateConsents: 일괄 업데이트 ──
public function test_update_consents_updates_multiple_keys(): void
{
$user = User::factory()->create();
$this->service->updateConsents($user->id, [
'email_subscription' => true,
'third_party_consent' => false,
], 'register');
$consents = $this->service->getAllByUserId($user->id)->keyBy('consent_key');
$emailRecord = $consents->get('email_subscription');
$this->assertNotNull($emailRecord);
$this->assertTrue($emailRecord->is_consented);
$thirdRecord = $consents->get('third_party_consent');
$this->assertNotNull($thirdRecord);
$this->assertFalse($thirdRecord->is_consented);
}
// ── getHistories ──
public function test_get_histories_returns_empty_when_no_history(): void
{
$user = User::factory()->create();
$result = $this->service->getHistories($user->id);
$this->assertCount(0, $result);
}
public function test_get_histories_ordered_by_desc(): void
{
$user = User::factory()->create();
// email 동의 시 master(marketing_consent)도 자동 활성화 → 이력 2건
// email 철회 → 이력 1건 추가 (총 3건)
$this->service->updateConsent($user->id, 'email_subscription', true, 'register');
$this->service->updateConsent($user->id, 'email_subscription', false, 'profile');
$histories = $this->service->getHistories($user->id);
// 최근 이력(revoked)이 먼저 나와야 함
$this->assertEquals('revoked', $histories->first()->action);
$this->assertEquals('email_subscription', $histories->first()->channel_key);
}
// ── deleteByUserId ──
public function test_delete_by_user_id_removes_all_records(): void
{
$user = User::factory()->create();
$this->service->updateConsent($user->id, 'email_subscription', true, 'register');
$this->service->updateConsent($user->id, 'marketing_consent', true, 'register');
$this->service->deleteByUserId($user->id);
$this->assertCount(0, MarketingConsent::where('user_id', $user->id)->get());
$this->assertCount(0, MarketingConsentHistory::where('user_id', $user->id)->get());
}
// ── *_enabled 기반 채널/법적 항목 필터링 ──
public function test_get_registered_channels_excludes_channel_when_disabled(): void
{
$service = $this->makeServiceWithChannels([
['key' => 'email_subscription', 'label' => ['ko' => '광고성 이메일 수신', 'en' => 'Email Marketing'], 'page_slug' => '', 'enabled' => false, 'is_system' => true],
['key' => 'sms_subscription', 'label' => ['ko' => 'SMS', 'en' => 'SMS'], 'page_slug' => '', 'enabled' => true, 'is_system' => false],
]);
$channels = $service->getRegisteredChannels();
$keys = array_column($channels, 'key');
$this->assertNotContains('email_subscription', $keys);
$this->assertContains('sms_subscription', $keys);
}
public function test_get_registered_channels_includes_channel_when_enabled(): void
{
$service = $this->makeServiceWithChannels([
['key' => 'email_subscription', 'label' => ['ko' => '광고성 이메일 수신', 'en' => 'Email Marketing'], 'page_slug' => '', 'enabled' => true, 'is_system' => true],
]);
$channels = $service->getRegisteredChannels();
$keys = array_column($channels, 'key');
$this->assertContains('email_subscription', $keys);
}
public function test_get_enabled_legal_keys_excludes_key_when_disabled(): void
{
$mock = $this->createMock(PluginSettingsService::class);
$mock->method('get')->willReturnCallback(
function (string $id, string $key, mixed $default = null) {
if ($key === 'third_party_consent_enabled') {
return false;
}
return $default ?? true;
}
);
$service = new MarketingConsentService(new MarketingConsentRepository(), $mock);
$keys = $service->getEnabledLegalKeys();
$this->assertNotContains('third_party_consent', $keys);
$this->assertContains('info_disclosure', $keys);
}
public function test_get_enabled_legal_keys_returns_all_when_all_enabled(): void
{
$keys = $this->service->getEnabledLegalKeys();
$this->assertContains('third_party_consent', $keys);
$this->assertContains('info_disclosure', $keys);
}
// ── consent_count 증가 ──
public function test_consent_count_increments_on_each_grant(): void
{
$user = User::factory()->create();
$this->service->updateConsent($user->id, 'email_subscription', true, 'register');
$this->service->updateConsent($user->id, 'email_subscription', false, 'admin');
$this->service->updateConsent($user->id, 'email_subscription', true, 'profile');
$record = MarketingConsent::where('user_id', $user->id)
->where('consent_key', 'email_subscription')
->first();
$this->assertEquals(2, $record->consent_count);
}
}
@@ -0,0 +1,60 @@
<?php
namespace Plugins\Sirsoft\Marketing\Upgrades;
use App\Contracts\Extension\UpgradeStepInterface;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\Schema;
/**
* v0.3.0 업그레이드 스텝
*
* 검색/필터/정렬 성능 향상을 위한 인덱스 추가 검증.
* - user_marketing_consent_histories: 1개 복합 인덱스
*/
class Upgrade_0_3_0 implements UpgradeStepInterface
{
/**
* 검증 대상 인덱스 목록.
*
* @var array<string, string[]>
*/
private const EXPECTED_INDEXES = [
'user_marketing_consent_histories' => [
'idx_mkt_consent_hist_user_created',
],
];
/**
* 업그레이드를 실행합니다.
*
* @param UpgradeContext $context 업그레이드 컨텍스트
*/
public function run(UpgradeContext $context): void
{
$totalExpected = 0;
$totalFound = 0;
foreach (self::EXPECTED_INDEXES as $table => $indexes) {
if (! Schema::hasTable($table)) {
$context->logger->warning("[v0.3.0] {$table} 테이블이 존재하지 않습니다.");
continue;
}
$existingIndexes = collect(Schema::getIndexes($table))->pluck('name')->toArray();
foreach ($indexes as $indexName) {
$totalExpected++;
if (in_array($indexName, $existingIndexes)) {
$totalFound++;
} else {
$context->logger->warning("[v0.3.0] {$table} 테이블에 {$indexName} 인덱스가 없습니다. 마이그레이션을 실행하세요.");
}
}
}
$context->logger->info("[v0.3.0] 마케팅 인덱스 검증 완료: {$totalFound}/{$totalExpected}개 확인됨");
}
}
@@ -0,0 +1,30 @@
import { defineConfig } from 'vitest/config';
import path from 'path';
import fs from 'fs';
// 프로젝트 루트를 동적으로 탐색 (artisan 파일 기준)
function findProjectRoot(startDir: string): string {
let dir = startDir;
while (dir !== path.dirname(dir)) {
if (fs.existsSync(path.join(dir, 'artisan'))) return dir;
dir = path.dirname(dir);
}
return path.resolve(startDir, '../../'); // fallback
}
const projectRoot = findProjectRoot(__dirname);
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['resources/js/**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules/', 'dist/'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'resources/js'),
'@core': path.resolve(projectRoot, 'resources/js/core'),
},
},
});
@@ -0,0 +1,65 @@
# Changelog
이 프로젝트의 모든 주요 변경사항을 기록합니다.
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.0-beta.1] - 2026-04-01
### Changed
- 오픈 베타 릴리즈
## [0.2.2] - 2026-03-30
### Changed
- frontend_schema에서 `is_test_mode`, `redirect_success_url`, `redirect_fail_url`을 `expose: false`로 변경 — 프론트엔드 미참조 설정의 보안 노출 차단
## [0.2.1] - 2026-03-25
### Changed
- app(PluginSettingsService::class) 직접 호출을 plugin_settings() 헬퍼로 교체 (SuccessCallbackRequest, PaymentRefundListener, RegisterPgProviderListener)
- 불필요한 PluginSettingsService use문 제거 (3개 파일)
## [0.2.0] - 2026-03-24
### Added
- 환불 리스너 추가 (PaymentRefundListener): 주문 취소 시 토스페이먼츠 환불 API 연동
- 다국어 메시지 파일 추가 (ko/en messages.php)
## [0.1.3] - 2026-03-16
### Changed
- 라이선스 프로그램 명칭 정비
## [0.1.2] - 2026-03-13
### Added
- manifest에 license 필드 및 LICENSE 파일 추가
### Changed
- 설정 레이아웃 경로를 `resources/layouts/settings.json` → `resources/layouts/admin/plugin_settings.json`으로 이동 (모듈과 동일한 구조 통일)
## [0.1.1] - 2026-02-24
### Changed
- 버전 체계 조정 (정식 출시 전 0.x 체계로 변경)
## [0.1.0] - 2026-02-23
### Added
- 토스페이먼츠 결제 플러그인 초기 구현
- 카드/계좌이체/가상계좌/무통장입금 결제 지원
- 결제 위젯 (TossPayments SDK) 통합
- 결제 확인 (confirm) 프로세스
- 결제 성공/실패 콜백 처리
- 결제 상태 웹훅 처리
- 플러그인 설정 UI (API 키, 시크릿 키 관리)
- sirsoft-ecommerce 모듈 결제 인터페이스 (PaymentInterface) 연동
- 다국어 지원 (ko, en)
@@ -0,0 +1,47 @@
프로그램 명칭 : 그누보드7용 토스페이먼츠 플러그인 (sirsoft-tosspayments)
저작자 : (주)에스아이알소프트
----- MIT 라이선스 (한국어 번역) --------------------------------------------------------
MIT 라이선스
Copyright (c) 2026 (주)에스아이알소프트
이 소프트웨어와 관련 문서 파일(이하 "소프트웨어")의 복사본을 취득하는 모든 사람에게
소프트웨어를 제한 없이 사용, 복사, 수정, 병합, 출판, 배포, 서브라이선스 허여 및/또는
판매할 수 있는 권리를 무상으로 부여합니다. 다만, 소프트웨어를 제공받은 사람은 다음
조건을 따라야 합니다:
위 저작권 고지와 본 허가 고지는 소프트웨어의 모든 복사본 또는 상당 부분에 포함되어야
합니다.
소프트웨어는 "있는 그대로" 제공되며, 명시적이든 묵시적이든 어떠한 종류의 보증도 하지
않습니다. 여기에는 상품성, 특정 목적에의 적합성 및 비침해에 대한 보증이 포함되나 이에
국한되지 않습니다. 어떠한 경우에도 저작자 또는 저작권자는 소프트웨어나 소프트웨어의
사용 또는 기타 거래로 인해 발생하는 계약, 불법행위 또는 기타 청구, 손해 또는 기타
책임에 대해 책임을 지지 않습니다.
----- MIT License (English Original) --------------------------------------------------------
The MIT License (MIT)
Copyright (c) 2026 SIRSOFT
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,20 @@
{
"name": "plugins/sirsoft-tosspayments",
"description": "TossPayments PG Plugin for G7 platform",
"version": "1.0.0-beta.1",
"type": "library",
"authors": [
{
"name": "sirsoft",
"email": "contact@sirsoft.com"
}
],
"require": {
"php": "^8.2"
},
"autoload": {
"psr-4": {
"Plugins\\Sirsoft\\Tosspayments\\": ["src/", "./"]
}
}
}
@@ -0,0 +1,24 @@
{
"_meta": {
"version": "1.0.0",
"description": "토스페이먼츠 PG 플러그인 환경설정"
},
"defaults": {
"is_test_mode": true,
"test_client_key": "",
"test_secret_key": "",
"live_client_key": "",
"live_secret_key": "",
"redirect_success_url": "/shop/orders/{orderId}/complete",
"redirect_fail_url": "/shop/checkout"
},
"frontend_schema": {
"is_test_mode": { "expose": false },
"test_client_key": { "expose": false },
"test_secret_key": { "expose": false },
"live_client_key": { "expose": false },
"live_secret_key": { "expose": false },
"redirect_success_url": { "expose": false },
"redirect_fail_url": { "expose": false }
}
}
@@ -0,0 +1,2 @@
(function(){"use strict";function f(t){return new Promise((n,e)=>{if(document.querySelector(`script[src="${t}"]`)){n();return}const o=document.createElement("script");o.src=t,o.async=!0,o.onload=()=>n(),o.onerror=()=>e(new Error(`Failed to load script: ${t}`)),document.head.appendChild(o)})}async function y(t,n){const{pgPaymentData:e}=t.params||{};if(!e){console.error("[sirsoft-tosspayments] pgPaymentData is required");return}const o=window.G7Core;try{const r=await o.api.get("/modules/sirsoft-ecommerce/payments/client-config/tosspayments");if(!r.data){console.error("[sirsoft-tosspayments] Failed to fetch client config",r);return}const s=r.data;if(window.TossPayments||await f(s.sdk_url),window.TossPayments||await new Promise(w=>setTimeout(w,100)),!window.TossPayments){console.error("[sirsoft-tosspayments] TossPayments SDK not available");return}const p=window.TossPayments(s.client_key).payment({customerKey:e.customer_key??window.TossPayments.ANONYMOUS}),u=window.location.origin;await p.requestPayment({method:"CARD",amount:{currency:e.currency??"KRW",value:e.amount},orderId:e.order_number,orderName:e.order_name,successUrl:u+s.callback_urls.success,failUrl:u+s.callback_urls.fail,customerEmail:e.customer_email??void 0,customerName:e.customer_name??void 0,customerMobilePhone:e.customer_phone??void 0,card:{useEscrow:!1,flowMode:"DEFAULT",useCardPoint:!1,useAppCardOnly:!1}})}catch(r){if(console.error("[sirsoft-tosspayments] requestPayment error",r),r?.code==="USER_CANCEL"){console.info("[sirsoft-tosspayments] Payment cancelled by user");try{await o.api.post(`/modules/sirsoft-ecommerce/orders/${e.order_number}/cancel-payment`,{cancel_code:r.code,cancel_message:r.message})}catch(c){console.warn("[sirsoft-tosspayments] Failed to record cancellation",c)}o?.state?.setLocal?.({isSubmittingOrder:!1}),o?.modal?.open?.("tosspayments_payment_cancel_modal");return}const s=r?.message??"Unknown error";o?.state?.setLocal?.({paymentErrorMessage:s,isSubmittingOrder:!1}),o?.modal?.open?.("tosspayments_payment_error_modal")}}const l={requestPayment:y},a="sirsoft-tosspayments",i={info:(...t)=>console.info(`[${a}]`,...t),warn:(...t)=>console.warn(`[${a}]`,...t),error:(...t)=>console.error(`[${a}]`,...t)};function m(){const t=window.G7Core;if(!t)return 0;const n=t.getActionDispatcher;if(typeof n!="function")return 0;const e=n();if(!e||typeof e.registerHandler!="function")return 0;let o=0;for(const[r,s]of Object.entries(l)){const c=`${a}.${r}`;e.registerHandler(c,s,{category:"plugin",source:a}),o++}return o}function d(){const t=()=>{const n=m();if(n>0){i.info(`${n} handler(s) registered`);return}let e=0;const o=50,r=setInterval(()=>{e++;const s=m();if(s>0){clearInterval(r),i.info(`${s} handler(s) registered (after ${e} retries)`);return}e>=o&&(clearInterval(r),i.warn("ActionDispatcher not available after timeout"))},100)};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",t):t()}d(),window.__SirsoftTosspayments={identifier:a,handlers:Object.keys(l),initPlugin:d}})();
//# sourceMappingURL=plugin.iife.js.map
@@ -0,0 +1 @@
{"version":3,"file":"plugin.iife.js","sources":["../../resources/js/handlers/requestPayment.ts","../../resources/js/handlers/index.ts","../../resources/js/index.ts"],"sourcesContent":["/**\n * 토스페이먼츠 결제창 호출 핸들러\n *\n * 체크아웃 레이아웃에서 주문 생성 API 성공 후 호출됩니다:\n * handler: \"sirsoft-tosspayments.requestPayment\"\n * params: { pgPaymentData: response.data.pg_payment_data }\n *\n * 호출 순서:\n * 1. Client Config API 호출 → clientKey 획득\n * 2. TossPayments SDK 동적 로드 (미로드 시)\n * 3. SDK 초기화 + payment 인스턴스 생성\n * 4. payment.requestPayment() 호출 → 통합결제창 오픈\n * 5. 결제 완료 시 브라우저가 successUrl/failUrl로 리다이렉트\n */\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\ninterface PgPaymentData {\n order_number: string;\n order_name: string;\n amount: number;\n currency?: string;\n customer_name?: string;\n customer_email?: string;\n customer_phone?: string;\n customer_key?: string | null;\n}\n\ninterface RequestPaymentParams {\n pgPaymentData: PgPaymentData;\n}\n\ninterface ClientConfig {\n client_key: string;\n sdk_url: string;\n callback_urls: {\n success: string;\n fail: string;\n };\n}\n\ndeclare global {\n interface Window {\n TossPayments: any;\n }\n}\n\n/**\n * 스크립트를 동적으로 로드합니다.\n *\n * @param src 스크립트 URL\n * @returns Promise\n */\nfunction loadScript(src: string): Promise<void> {\n return new Promise((resolve, reject) => {\n // 이미 로드된 스크립트인지 확인\n if (document.querySelector(`script[src=\"${src}\"]`)) {\n resolve();\n return;\n }\n\n const script = document.createElement('script');\n script.src = src;\n script.async = true;\n script.onload = () => resolve();\n script.onerror = () => reject(new Error(`Failed to load script: ${src}`));\n document.head.appendChild(script);\n });\n}\n\n/**\n * 토스페이먼츠 결제창 호출 핸들러\n *\n * ActionDispatcher는 커스텀 핸들러를 (action, context) 시그니처로 호출합니다.\n * params는 action.params에서 접근해야 합니다.\n *\n * @param action 액션 정의 (handler, params 등)\n * @param _context 액션 컨텍스트\n */\nexport async function requestPaymentHandler(action: any, _context?: any): Promise<void> {\n const { pgPaymentData } = (action.params || {}) as RequestPaymentParams;\n\n if (!pgPaymentData) {\n console.error('[sirsoft-tosspayments] pgPaymentData is required');\n return;\n }\n\n const G7Core = (window as any).G7Core;\n\n try {\n // 1. Client Config API 호출\n const configJson = await G7Core.api.get('/modules/sirsoft-ecommerce/payments/client-config/tosspayments');\n\n if (!configJson.data) {\n console.error('[sirsoft-tosspayments] Failed to fetch client config', configJson);\n return;\n }\n\n const config: ClientConfig = configJson.data;\n\n // 2. SDK 동적 로드 (미로드 시)\n if (!window.TossPayments) {\n await loadScript(config.sdk_url);\n }\n\n // SDK 로드 대기 (스크립트 로드 후 TossPayments 객체 초기화까지 약간의 시간 소요)\n if (!window.TossPayments) {\n await new Promise<void>((resolve) => setTimeout(resolve, 100));\n }\n\n if (!window.TossPayments) {\n console.error('[sirsoft-tosspayments] TossPayments SDK not available');\n return;\n }\n\n // 3. SDK 초기화\n const tossPayments = window.TossPayments(config.client_key);\n const payment = tossPayments.payment({\n customerKey: pgPaymentData.customer_key ?? window.TossPayments.ANONYMOUS,\n });\n\n // 4. 결제 요청 (통합결제창)\n const origin = window.location.origin;\n\n await payment.requestPayment({\n method: 'CARD',\n amount: {\n currency: pgPaymentData.currency ?? 'KRW',\n value: pgPaymentData.amount,\n },\n orderId: pgPaymentData.order_number,\n orderName: pgPaymentData.order_name,\n successUrl: origin + config.callback_urls.success,\n failUrl: origin + config.callback_urls.fail,\n customerEmail: pgPaymentData.customer_email ?? undefined,\n customerName: pgPaymentData.customer_name ?? undefined,\n customerMobilePhone: pgPaymentData.customer_phone ?? undefined,\n card: {\n useEscrow: false,\n flowMode: 'DEFAULT',\n useCardPoint: false,\n useAppCardOnly: false,\n },\n });\n // → 브라우저가 successUrl 또는 failUrl로 리다이렉트됨\n\n } catch (erLine truncated
@@ -0,0 +1,11 @@
<?php
/**
* TossPayments Plugin Messages (English)
*/
return [
'refund' => [
'missing_payment_key' => 'Cannot process refund: TossPayments payment key not found.',
'default_reason' => 'Cancelled by customer request',
],
];
@@ -0,0 +1,11 @@
<?php
/**
* 토스페이먼츠 플러그인 메시지 (Korean)
*/
return [
'refund' => [
'missing_payment_key' => '토스페이먼츠 결제 키가 존재하지 않아 환불 처리할 수 없습니다.',
'default_reason' => '고객 요청에 의한 취소',
],
];
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,19 @@
{
"name": "@g7/sirsoft-tosspayments",
"version": "1.0.0-beta.1",
"type": "module",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run"
},
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
"vite": "^5.4.14",
"vitest": "^2.1.8"
}
}
@@ -0,0 +1,34 @@
{
"identifier": "sirsoft-tosspayments",
"vendor": "sirsoft",
"name": {
"ko": "토스페이먼츠",
"en": "TossPayments"
},
"version": "1.0.0-beta.1",
"license": "MIT",
"description": {
"ko": "토스페이먼츠 결제 게이트웨이 (통합결제창 연동)",
"en": "TossPayments gateway (integrated payment window)"
},
"g7_version": ">=7.0.0-beta.1",
"dependencies": {
"modules": {
"sirsoft-ecommerce": ">=0.1.1"
},
"plugins": {}
},
"github_url": "https://github.com/gnuboard/g7-plugin-sirsoft-tosspayments",
"github_changelog_url": "https://github.com/gnuboard/g7-plugin-sirsoft-tosspayments/blob/main/CHANGELOG.md",
"assets": {
"js": {
"entry": "resources/js/index.ts",
"output": "dist/js/plugin.iife.js"
},
"handlers": true
},
"loading": {
"strategy": "global",
"priority": 100
}
}
@@ -0,0 +1,171 @@
<?php
namespace Plugins\Sirsoft\Tosspayments;
use App\Extension\AbstractPlugin;
/**
* 토스페이먼츠 PG 플러그인
*
* 토스페이먼츠 통합결제창(카드/간편결제) 연동을 제공합니다.
* sirsoft-ecommerce 모듈 전용 플러그인입니다.
*/
class Plugin extends AbstractPlugin
{
/**
* 플러그인 메타데이터 반환
*
* @return array 메타데이터
*/
public function getMetadata(): array
{
return [
'author' => 'Sirsoft',
'license' => 'MIT',
'homepage' => 'https://sir.kr',
'keywords' => ['payment', 'tosspayments', 'pg', 'card', 'ecommerce'],
];
}
/**
* 플러그인 의존성 반환
*
* @return array 의존하는 모듈 목록
*/
public function getDependencies(): array
{
return [
'sirsoft-ecommerce' => '>=1.0.0',
];
}
/**
* 환경설정 스키마 반환
*
* @return array 설정 스키마
*/
public function getSettingsSchema(): array
{
return [
'is_test_mode' => [
'type' => 'boolean',
'default' => true,
'label' => ['ko' => '테스트 모드', 'en' => 'Test Mode'],
'hint' => [
'ko' => '테스트 모드에서는 실제 결제가 발생하지 않습니다.',
'en' => 'No real payments occur in test mode.',
],
],
'test_client_key' => [
'type' => 'string',
'default' => '',
'label' => ['ko' => '테스트 클라이언트 키', 'en' => 'Test Client Key'],
'hint' => [
'ko' => '개발자센터 > API 개별 연동 키에서 확인',
'en' => 'Found in Developer Center > API Keys',
],
],
'test_secret_key' => [
'type' => 'string',
'default' => '',
'sensitive' => true,
'label' => ['ko' => '테스트 시크릿 키', 'en' => 'Test Secret Key'],
'hint' => [
'ko' => '외부에 노출되지 않도록 주의하세요.',
'en' => 'Keep this key secret.',
],
],
'live_client_key' => [
'type' => 'string',
'default' => '',
'label' => ['ko' => '라이브 클라이언트 키', 'en' => 'Live Client Key'],
],
'live_secret_key' => [
'type' => 'string',
'default' => '',
'sensitive' => true,
'label' => ['ko' => '라이브 시크릿 키', 'en' => 'Live Secret Key'],
'hint' => [
'ko' => '외부에 노출되지 않도록 주의하세요.',
'en' => 'Keep this key secret.',
],
],
'redirect_success_url' => [
'type' => 'string',
'default' => '/shop/orders/{orderId}/complete',
'label' => ['ko' => '결제 성공 리다이렉트 URL', 'en' => 'Payment Success Redirect URL'],
'hint' => [
'ko' => '상대 경로(/shop/...) 또는 전체 URL(https://...) 모두 가능합니다. {orderId}는 주문번호로 자동 치환됩니다.',
'en' => 'Supports relative paths (/shop/...) or full URLs (https://...). {orderId} will be replaced with the actual order number.',
],
],
'redirect_fail_url' => [
'type' => 'string',
'default' => '/shop/checkout',
'label' => ['ko' => '결제 실패 리다이렉트 URL', 'en' => 'Payment Failure Redirect URL'],
'hint' => [
'ko' => '상대 경로 또는 전체 URL 모두 가능합니다. 오류 정보는 쿼리 파라미터로 자동 추가됩니다.',
'en' => 'Supports relative paths or full URLs. Error details are appended as query parameters.',
],
],
];
}
/**
* 기본 설정값 반환
*
* @return array 기본 설정값
*/
public function getConfigValues(): array
{
return [
'is_test_mode' => true,
'test_client_key' => '',
'test_secret_key' => '',
'live_client_key' => '',
'live_secret_key' => '',
'redirect_success_url' => '/shop/orders/{orderId}/complete',
'redirect_fail_url' => '/shop/checkout',
];
}
/**
* 훅 리스너 클래스 반환
*
* @return array 리스너 클래스 목록
*/
public function getHookListeners(): array
{
return [
Listeners\RegisterPgProviderListener::class,
Listeners\PaymentRefundListener::class,
];
}
/**
* 훅 정의 반환
*
* @return array 훅 정의
*/
public function getHooks(): array
{
return [
[
'name' => 'sirsoft-tosspayments.payment.before_confirm',
'type' => 'action',
'description' => [
'ko' => '토스페이먼츠 결제 승인 API 호출 전',
'en' => 'Before TossPayments confirm API call',
],
],
[
'name' => 'sirsoft-tosspayments.payment.after_confirm',
'type' => 'action',
'description' => [
'ko' => '토스페이먼츠 결제 승인 완료 후',
'en' => 'After TossPayments confirm API completed',
],
],
];
}
}
@@ -0,0 +1,141 @@
{
"extension_point": "shop_checkout_extensions",
"modals": [
{
"id": "tosspayments_payment_error_modal",
"type": "composite",
"name": "Modal",
"props": {
"title": "$t:sirsoft-tosspayments.payment_error_title",
"size": "small"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "text-center py-4"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "exclamation-circle",
"className": "text-red-500 text-4xl mb-4"
}
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-gray-700 dark:text-gray-300"
},
"text": "{{_local.paymentErrorMessage ?? ''}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-center mt-6"
},
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "px-6 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
},
"text": "$t:common.confirm",
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "_local.paymentErrorMessage",
"value": null
}
},
{
"type": "click",
"handler": "closeModal",
"params": {
"id": "tosspayments_payment_error_modal"
}
}
]
}
]
}
]
}
,
{
"id": "tosspayments_payment_cancel_modal",
"type": "composite",
"name": "Modal",
"props": {
"title": "$t:sirsoft-tosspayments.payment_cancel_title",
"size": "small"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "text-center py-4"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "info-circle",
"className": "text-yellow-500 text-4xl mb-4"
}
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-gray-700 dark:text-gray-300"
},
"text": "$t:sirsoft-tosspayments.payment_cancelled_message"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-center mt-6"
},
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "px-6 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
},
"text": "$t:common.confirm",
"actions": [
{
"type": "click",
"handler": "closeModal",
"params": {
"id": "tosspayments_payment_cancel_modal"
}
}
]
}
]
}
]
}
],
"priority": 100
}
@@ -0,0 +1,189 @@
/**
* requestPayment 핸들러 테스트
*
* 토스페이먼츠 결제창 호출 핸들러의 에러 처리 및 모달 열기 동작을 검증합니다.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { requestPaymentHandler } from '../../handlers/requestPayment';
const CLIENT_CONFIG_DATA = {
data: {
client_key: 'test_key',
sdk_url: 'https://example.com/sdk.js',
callback_urls: { success: '/success', fail: '/fail' },
},
};
describe('requestPaymentHandler', () => {
let mockG7Core: {
api: { get: ReturnType<typeof vi.fn>; post: ReturnType<typeof vi.fn> };
state: { setLocal: ReturnType<typeof vi.fn> };
modal: { open: ReturnType<typeof vi.fn> };
};
beforeEach(() => {
mockG7Core = {
api: {
get: vi.fn().mockResolvedValue(CLIENT_CONFIG_DATA),
post: vi.fn().mockResolvedValue({ success: true }),
},
state: { setLocal: vi.fn() },
modal: { open: vi.fn() },
};
(window as any).G7Core = mockG7Core;
(window as any).TossPayments = undefined;
});
afterEach(() => {
delete (window as any).G7Core;
delete (window as any).TossPayments;
});
it('pgPaymentData가 없으면 조기 반환한다', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await requestPaymentHandler({ params: {} });
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('pgPaymentData is required')
);
expect(mockG7Core.state.setLocal).not.toHaveBeenCalled();
expect(mockG7Core.modal.open).not.toHaveBeenCalled();
consoleSpy.mockRestore();
});
it('SDK 에러 시 에러 메시지를 setState하고 모달을 연다', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
// TossPayments SDK Mock — requestPayment에서 에러 발생
const mockPayment = {
requestPayment: vi.fn().mockRejectedValue(new Error('Payment failed')),
};
(window as any).TossPayments = vi.fn().mockReturnValue({
payment: vi.fn().mockReturnValue(mockPayment),
});
(window as any).TossPayments.ANONYMOUS = 'ANONYMOUS';
await requestPaymentHandler({
params: {
pgPaymentData: {
order_number: 'ORD-001',
order_name: 'Test Order',
amount: 10000,
},
},
});
// setState로 에러 메시지 설정
expect(mockG7Core.state.setLocal).toHaveBeenCalledWith({
paymentErrorMessage: 'Payment failed',
isSubmittingOrder: false,
});
// 모달 열기
expect(mockG7Core.modal.open).toHaveBeenCalledWith('tosspayments_payment_error_modal');
consoleSpy.mockRestore();
});
describe('USER_CANCEL 처리', () => {
const setupUserCancel = () => {
const cancelError = new Error('User cancelled');
(cancelError as any).code = 'USER_CANCEL';
const mockPayment = {
requestPayment: vi.fn().mockRejectedValue(cancelError),
};
(window as any).TossPayments = vi.fn().mockReturnValue({
payment: vi.fn().mockReturnValue(mockPayment),
});
(window as any).TossPayments.ANONYMOUS = 'ANONYMOUS';
};
const callWithCancel = () => {
setupUserCancel();
return requestPaymentHandler({
params: {
pgPaymentData: {
order_number: 'ORD-002',
order_name: 'Test Order',
amount: 5000,
},
},
});
};
it('사용자 취소 시 cancel-payment API를 호출한다', async () => {
const consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await callWithCancel();
expect(mockG7Core.api.post).toHaveBeenCalledWith(
'/modules/sirsoft-ecommerce/orders/ORD-002/cancel-payment',
{
cancel_code: 'USER_CANCEL',
cancel_message: 'User cancelled',
}
);
consoleInfoSpy.mockRestore();
consoleErrorSpy.mockRestore();
});
it('사용자 취소 시 로딩 상태를 해제한다', async () => {
const consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await callWithCancel();
expect(mockG7Core.state.setLocal).toHaveBeenCalledWith({
isSubmittingOrder: false,
});
consoleInfoSpy.mockRestore();
consoleErrorSpy.mockRestore();
});
it('사용자 취소 시 취소 안내 모달을 연다', async () => {
const consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await callWithCancel();
expect(mockG7Core.modal.open).toHaveBeenCalledWith('tosspayments_payment_cancel_modal');
// 에러 모달은 열리지 않아야 함
expect(mockG7Core.modal.open).not.toHaveBeenCalledWith('tosspayments_payment_error_modal');
consoleInfoSpy.mockRestore();
consoleErrorSpy.mockRestore();
});
it('cancel-payment API 실패 시에도 모달은 정상 표시한다', async () => {
const consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
// cancel-payment API만 실패
mockG7Core.api.post.mockRejectedValue(new Error('Network error'));
await callWithCancel();
// API 실패해도 모달과 상태 리셋은 정상 동작
expect(mockG7Core.state.setLocal).toHaveBeenCalledWith({
isSubmittingOrder: false,
});
expect(mockG7Core.modal.open).toHaveBeenCalledWith('tosspayments_payment_cancel_modal');
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to record cancellation'),
expect.any(Error)
);
consoleInfoSpy.mockRestore();
consoleErrorSpy.mockRestore();
consoleWarnSpy.mockRestore();
});
});
});
@@ -0,0 +1,11 @@
import { requestPaymentHandler } from './requestPayment';
/**
* 토스페이먼츠 플러그인 핸들러 맵
*
* ActionDispatcher에 등록될 핸들러 목록입니다.
* 네임스페이스 접두사(sirsoft-tosspayments.)는 index.ts에서 자동으로 추가됩니다.
*/
export const handlerMap: Record<string, (...args: unknown[]) => unknown> = {
requestPayment: requestPaymentHandler,
} as const;
@@ -0,0 +1,178 @@
/**
* 토스페이먼츠 결제창 호출 핸들러
*
* 체크아웃 레이아웃에서 주문 생성 API 성공 후 호출됩니다:
* handler: "sirsoft-tosspayments.requestPayment"
* params: { pgPaymentData: response.data.pg_payment_data }
*
* 호출 순서:
* 1. Client Config API 호출 → clientKey 획득
* 2. TossPayments SDK 동적 로드 (미로드 시)
* 3. SDK 초기화 + payment 인스턴스 생성
* 4. payment.requestPayment() 호출 → 통합결제창 오픈
* 5. 결제 완료 시 브라우저가 successUrl/failUrl로 리다이렉트
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
interface PgPaymentData {
order_number: string;
order_name: string;
amount: number;
currency?: string;
customer_name?: string;
customer_email?: string;
customer_phone?: string;
customer_key?: string | null;
}
interface RequestPaymentParams {
pgPaymentData: PgPaymentData;
}
interface ClientConfig {
client_key: string;
sdk_url: string;
callback_urls: {
success: string;
fail: string;
};
}
declare global {
interface Window {
TossPayments: any;
}
}
/**
* 스크립트를 동적으로 로드합니다.
*
* @param src 스크립트 URL
* @returns Promise
*/
function loadScript(src: string): Promise<void> {
return new Promise((resolve, reject) => {
// 이미 로드된 스크립트인지 확인
if (document.querySelector(`script[src="${src}"]`)) {
resolve();
return;
}
const script = document.createElement('script');
script.src = src;
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`Failed to load script: ${src}`));
document.head.appendChild(script);
});
}
/**
* 토스페이먼츠 결제창 호출 핸들러
*
* ActionDispatcher는 커스텀 핸들러를 (action, context) 시그니처로 호출합니다.
* params는 action.params에서 접근해야 합니다.
*
* @param action 액션 정의 (handler, params 등)
* @param _context 액션 컨텍스트
*/
export async function requestPaymentHandler(action: any, _context?: any): Promise<void> {
const { pgPaymentData } = (action.params || {}) as RequestPaymentParams;
if (!pgPaymentData) {
console.error('[sirsoft-tosspayments] pgPaymentData is required');
return;
}
const G7Core = (window as any).G7Core;
try {
// 1. Client Config API 호출
const configJson = await G7Core.api.get('/modules/sirsoft-ecommerce/payments/client-config/tosspayments');
if (!configJson.data) {
console.error('[sirsoft-tosspayments] Failed to fetch client config', configJson);
return;
}
const config: ClientConfig = configJson.data;
// 2. SDK 동적 로드 (미로드 시)
if (!window.TossPayments) {
await loadScript(config.sdk_url);
}
// SDK 로드 대기 (스크립트 로드 후 TossPayments 객체 초기화까지 약간의 시간 소요)
if (!window.TossPayments) {
await new Promise<void>((resolve) => setTimeout(resolve, 100));
}
if (!window.TossPayments) {
console.error('[sirsoft-tosspayments] TossPayments SDK not available');
return;
}
// 3. SDK 초기화
const tossPayments = window.TossPayments(config.client_key);
const payment = tossPayments.payment({
customerKey: pgPaymentData.customer_key ?? window.TossPayments.ANONYMOUS,
});
// 4. 결제 요청 (통합결제창)
const origin = window.location.origin;
await payment.requestPayment({
method: 'CARD',
amount: {
currency: pgPaymentData.currency ?? 'KRW',
value: pgPaymentData.amount,
},
orderId: pgPaymentData.order_number,
orderName: pgPaymentData.order_name,
successUrl: origin + config.callback_urls.success,
failUrl: origin + config.callback_urls.fail,
customerEmail: pgPaymentData.customer_email ?? undefined,
customerName: pgPaymentData.customer_name ?? undefined,
customerMobilePhone: pgPaymentData.customer_phone ?? undefined,
card: {
useEscrow: false,
flowMode: 'DEFAULT',
useCardPoint: false,
useAppCardOnly: false,
},
});
// → 브라우저가 successUrl 또는 failUrl로 리다이렉트됨
} catch (error: any) {
console.error('[sirsoft-tosspayments] requestPayment error', error);
// SDK에서 사용자 취소 시 에러가 발생할 수 있음
if (error?.code === 'USER_CANCEL') {
console.info('[sirsoft-tosspayments] Payment cancelled by user');
// 1. 결제 취소 이력 기록 API 호출 (PG사 응답값 전달)
try {
await G7Core.api.post(
`/modules/sirsoft-ecommerce/orders/${pgPaymentData.order_number}/cancel-payment`,
{
cancel_code: error.code,
cancel_message: error.message,
}
);
} catch (e) {
console.warn('[sirsoft-tosspayments] Failed to record cancellation', e);
}
// 2. 로딩 상태 해제 + 취소 안내 모달 표시
G7Core?.state?.setLocal?.({ isSubmittingOrder: false });
G7Core?.modal?.open?.('tosspayments_payment_cancel_modal');
return;
}
// 기타 에러 시 모달로 오류 표시 (체크아웃 페이지 유지)
const errorMessage = error?.message ?? 'Unknown error';
G7Core?.state?.setLocal?.({ paymentErrorMessage: errorMessage, isSubmittingOrder: false });
G7Core?.modal?.open?.('tosspayments_payment_error_modal');
}
}
@@ -0,0 +1,108 @@
/**
* 토스페이먼츠 플러그인 엔트리
*
* ActionDispatcher에 핸들러를 등록하여
* 레이아웃 JSON에서 "sirsoft-tosspayments.requestPayment" 형태로 호출할 수 있게 합니다.
*/
import { handlerMap } from './handlers';
const PLUGIN_IDENTIFIER = 'sirsoft-tosspayments';
/**
* 로거 (G7Core 초기화 전에도 동작)
*/
const logger = {
info: (...args: unknown[]) => console.info(`[${PLUGIN_IDENTIFIER}]`, ...args),
warn: (...args: unknown[]) => console.warn(`[${PLUGIN_IDENTIFIER}]`, ...args),
error: (...args: unknown[]) => console.error(`[${PLUGIN_IDENTIFIER}]`, ...args),
};
/**
* ActionDispatcher에 핸들러를 등록합니다.
*
* @returns 등록된 핸들러 수
*/
function registerHandlers(): number {
const g7Core = (window as Record<string, unknown>).G7Core as Record<string, unknown> | undefined;
if (!g7Core) {
return 0;
}
const getDispatcher = g7Core.getActionDispatcher as (() => Record<string, unknown>) | undefined;
if (typeof getDispatcher !== 'function') {
return 0;
}
const dispatcher = getDispatcher() as Record<string, unknown> | undefined;
if (!dispatcher || typeof dispatcher.registerHandler !== 'function') {
return 0;
}
let count = 0;
for (const [name, handler] of Object.entries(handlerMap)) {
const fullName = `${PLUGIN_IDENTIFIER}.${name}`;
dispatcher.registerHandler(fullName, handler, {
category: 'plugin',
source: PLUGIN_IDENTIFIER,
});
count++;
}
return count;
}
/**
* 플러그인 초기화
*
* DOMContentLoaded 후 ActionDispatcher가 준비되면 핸들러를 등록합니다.
* ActionDispatcher가 아직 준비되지 않았으면 최대 5초간 재시도합니다.
*/
function initPlugin(): void {
const doInit = () => {
const count = registerHandlers();
if (count > 0) {
logger.info(`${count} handler(s) registered`);
return;
}
// ActionDispatcher 미준비 시 재시도 (100ms 간격, 최대 50회 = 5초)
let retries = 0;
const maxRetries = 50;
const interval = setInterval(() => {
retries++;
const result = registerHandlers();
if (result > 0) {
clearInterval(interval);
logger.info(`${result} handler(s) registered (after ${retries} retries)`);
return;
}
if (retries >= maxRetries) {
clearInterval(interval);
logger.warn('ActionDispatcher not available after timeout');
}
}, 100);
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', doInit);
} else {
doInit();
}
}
// 자동 초기화
initPlugin();
// 디버그용 글로벌 노출
(window as Record<string, unknown>).__SirsoftTosspayments = {
identifier: PLUGIN_IDENTIFIER,
handlers: Object.keys(handlerMap),
initPlugin,
};
@@ -0,0 +1,35 @@
{
"plugin_name": "TossPayments",
"plugin_description": "TossPayments gateway (integrated payment window)",
"settings": {
"title": "TossPayments Settings",
"description": "Configure TossPayments API keys.",
"test_mode": "Test Mode",
"test_mode_hint": "No real payments occur in test mode.",
"test_client_key": "Test Client Key",
"test_client_key_hint": "Found in Developer Center > API Keys",
"test_secret_key": "Test Secret Key",
"test_secret_key_hint": "Keep this key secret.",
"live_client_key": "Live Client Key",
"live_secret_key": "Live Secret Key",
"live_secret_key_hint": "Keep this key secret.",
"section_test_keys": "Test API Keys",
"section_live_keys": "Live API Keys",
"section_redirect": "Redirect Settings",
"redirect_success_url": "Payment Success Redirect URL",
"redirect_success_url_hint": "Supports relative paths (/shop/...) or full URLs (https://...). {orderId} will be replaced with the actual order number.",
"redirect_fail_url": "Payment Failure Redirect URL",
"redirect_fail_url_hint": "Supports relative paths or full URLs. Error details (error, message, orderId) are appended as query parameters.",
"save": "Save",
"saved": "Settings saved."
},
"payment_error_title": "Payment Error",
"payment_cancel_title": "Payment Cancelled",
"payment_cancelled_message": "Payment has been cancelled. Please try again or choose a different payment method.",
"errors": {
"confirm_failed": "Payment confirmation failed.",
"amount_mismatch": "Payment amount does not match.",
"order_not_found": "Order not found.",
"payment_failed": "Payment failed."
}
}
@@ -0,0 +1,35 @@
{
"plugin_name": "토스페이먼츠",
"plugin_description": "토스페이먼츠 결제 게이트웨이 (통합결제창 연동)",
"settings": {
"title": "토스페이먼츠 설정",
"description": "토스페이먼츠 API 키를 설정합니다.",
"test_mode": "테스트 모드",
"test_mode_hint": "테스트 모드에서는 실제 결제가 발생하지 않습니다.",
"test_client_key": "테스트 클라이언트 키",
"test_client_key_hint": "개발자센터 > API 개별 연동 키에서 확인",
"test_secret_key": "테스트 시크릿 키",
"test_secret_key_hint": "외부에 노출되지 않도록 주의하세요.",
"live_client_key": "라이브 클라이언트 키",
"live_secret_key": "라이브 시크릿 키",
"live_secret_key_hint": "외부에 노출되지 않도록 주의하세요.",
"section_test_keys": "테스트 API 키",
"section_live_keys": "라이브 API 키",
"section_redirect": "리다이렉트 설정",
"redirect_success_url": "결제 성공 리다이렉트 URL",
"redirect_success_url_hint": "상대 경로(/shop/...) 또는 전체 URL(https://...) 모두 가능합니다. {orderId}는 실제 주문번호로 자동 치환됩니다.",
"redirect_fail_url": "결제 실패 리다이렉트 URL",
"redirect_fail_url_hint": "상대 경로 또는 전체 URL 모두 가능합니다. 오류 정보(error, message, orderId)는 쿼리 파라미터로 자동 추가됩니다.",
"save": "저장",
"saved": "설정이 저장되었습니다."
},
"payment_error_title": "결제 오류",
"payment_cancel_title": "결제 취소",
"payment_cancelled_message": "결제가 취소되었습니다. 다시 결제를 시도하시거나 다른 결제 수단을 선택해 주세요.",
"errors": {
"confirm_failed": "결제 승인에 실패했습니다.",
"amount_mismatch": "결제 금액이 일치하지 않습니다.",
"order_not_found": "주문을 찾을 수 없습니다.",
"payment_failed": "결제에 실패했습니다."
}
}
@@ -0,0 +1,774 @@
{
"version": "1.0.0",
"layout_name": "plugin_settings",
"permissions": [
"core.plugins.update"
],
"extends": "_admin_base",
"meta": {
"title": "$t:sirsoft-tosspayments.settings.title",
"description": "$t:sirsoft-tosspayments.settings.description"
},
"data_sources": [
{
"id": "settings",
"type": "api",
"endpoint": "/api/admin/plugins/{{route.identifier}}/settings",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"initLocal": "form",
"refetchOnMount": true
}
],
"slots": {
"content": [
{
"id": "plugin_settings_content",
"type": "basic",
"name": "Div",
"blur_until_loaded": "{{_local.isSaving}}",
"dataKey": "form",
"trackChanges": true,
"props": {
"className": "p-4 sm:p-6 lg:p-8 bg-gray-50 dark:bg-gray-900 min-h-screen"
},
"children": [
{
"id": "page_header",
"type": "composite",
"name": "PageHeader",
"props": {
"title": "$t:sirsoft-tosspayments.settings.title",
"description": "$t:sirsoft-tosspayments.settings.description",
"backPath": "/admin/plugins",
"backLabel": "$t:admin.plugins.back_to_list"
}
},
{
"id": "validation_error",
"type": "basic",
"name": "Div",
"if": "{{_local.errors}}",
"props": {
"className": "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mt-6"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex gap-3"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "triangle-exclamation",
"className": "w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5"
}
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-sm font-medium text-red-800 dark:text-red-200"
},
"text": "$t:admin.plugins.settings.validation_error"
},
{
"type": "basic",
"name": "Ul",
"if": "{{typeof _local.errors === 'object' && Object.keys(_local.errors).length > 0}}",
"props": {
"className": "mt-2 list-disc list-inside space-y-1"
},
"iteration": {
"source": "Object.entries(_local.errors ?? {}).flatMap(([field, messages]) => Array.isArray(messages) ? messages : [messages])",
"item_var": "message"
},
"children": [
{
"type": "basic",
"name": "Li",
"props": {
"className": "text-sm text-red-700 dark:text-red-300"
},
"text": "{{message}}"
}
]
}
]
}
]
}
]
},
{
"id": "test_mode_card",
"type": "basic",
"name": "Div",
"props": {
"className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "p-6"
},
"children": [
{
"id": "field_test_mode",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center justify-between"
},
"children": [
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300"
},
"text": "$t:sirsoft-tosspayments.settings.test_mode"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "mt-1 text-xs text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-tosspayments.settings.test_mode_hint"
}
]
},
{
"type": "composite",
"name": "Toggle",
"props": {
"name": "is_test_mode"
}
}
]
}
]
}
]
},
{
"id": "test_keys_section",
"type": "basic",
"name": "Div",
"props": {
"className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "px-6 py-4 border-b border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-base font-semibold text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-tosspayments.settings.section_test_keys"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "p-6 space-y-6"
},
"children": [
{
"id": "field_test_client_key",
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-1 lg:grid-cols-3 gap-4 items-start"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "lg:col-span-1"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300"
},
"text": "$t:sirsoft-tosspayments.settings.test_client_key"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "mt-1 text-xs text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-tosspayments.settings.test_client_key_hint"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "lg:col-span-2"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"name": "test_client_key",
"placeholder": "test_ck_...",
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm"
}
}
]
}
]
},
{
"id": "field_test_secret_key",
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-1 lg:grid-cols-3 gap-4 items-start"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "lg:col-span-1"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300"
},
"text": "$t:sirsoft-tosspayments.settings.test_secret_key"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "mt-1 text-xs text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-tosspayments.settings.test_secret_key_hint"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "lg:col-span-2"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "password",
"name": "test_secret_key",
"placeholder": "test_sk_...",
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm"
}
}
]
}
]
}
]
}
]
},
{
"id": "live_keys_section",
"type": "basic",
"name": "Div",
"props": {
"className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "px-6 py-4 border-b border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-base font-semibold text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-tosspayments.settings.section_live_keys"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "p-6 space-y-6"
},
"children": [
{
"id": "field_live_client_key",
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-1 lg:grid-cols-3 gap-4 items-start"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "lg:col-span-1"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300"
},
"text": "$t:sirsoft-tosspayments.settings.live_client_key"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "lg:col-span-2"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"name": "live_client_key",
"placeholder": "live_ck_...",
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm"
}
}
]
}
]
},
{
"id": "field_live_secret_key",
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-1 lg:grid-cols-3 gap-4 items-start"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "lg:col-span-1"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300"
},
"text": "$t:sirsoft-tosspayments.settings.live_secret_key"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "mt-1 text-xs text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-tosspayments.settings.live_secret_key_hint"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "lg:col-span-2"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "password",
"name": "live_secret_key",
"placeholder": "live_sk_...",
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm"
}
}
]
}
]
}
]
}
]
},
{
"id": "redirect_section",
"type": "basic",
"name": "Div",
"props": {
"className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "px-6 py-4 border-b border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-base font-semibold text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-tosspayments.settings.section_redirect"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "p-6 space-y-6"
},
"children": [
{
"id": "field_redirect_success_url",
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-1 lg:grid-cols-3 gap-4 items-start"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "lg:col-span-1"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300"
},
"text": "$t:sirsoft-tosspayments.settings.redirect_success_url"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "mt-1 text-xs text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-tosspayments.settings.redirect_success_url_hint"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "lg:col-span-2"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"name": "redirect_success_url",
"placeholder": "/shop/orders/{orderId}/complete",
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
}
}
]
}
]
},
{
"id": "field_redirect_fail_url",
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-1 lg:grid-cols-3 gap-4 items-start"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "lg:col-span-1"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300"
},
"text": "$t:sirsoft-tosspayments.settings.redirect_fail_url"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "mt-1 text-xs text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-tosspayments.settings.redirect_fail_url_hint"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "lg:col-span-2"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"name": "redirect_fail_url",
"placeholder": "/shop/checkout",
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
}
}
]
}
]
}
]
}
]
},
{
"id": "footer_buttons",
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end gap-3 mt-6"
},
"children": [
{
"id": "cancel_button",
"type": "basic",
"name": "Button",
"props": {
"className": "px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 font-medium transition-colors"
},
"actions": [
{
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/plugins"
}
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:common.cancel"
}
]
},
{
"id": "save_button",
"type": "basic",
"name": "Button",
"props": {
"disabled": "{{!_local.hasChanges || _local.isSaving}}",
"className": "flex items-center gap-1.5 px-4 py-2 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-100 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": true,
"errors": null
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/admin/plugins/{{route.identifier}}/settings",
"params": {
"method": "PUT",
"body": "{{_local.form}}"
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": false,
"hasChanges": false
}
},
{
"handler": "toast",
"params": {
"type": "success",
"message": "$t:admin.plugins.settings.save_success"
}
},
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "settings"
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": false,
"errors": "{{$error.errors}}"
}
},
{
"handler": "toast",
"params": {
"type": "error",
"message": "$t:admin.plugins.settings.save_error"
}
}
]
}
]
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"if": "{{_local.isSaving}}",
"props": {
"name": "spinner",
"className": "w-4 h-4 animate-spin"
}
},
{
"type": "basic",
"name": "Span",
"if": "{{!_local.isSaving}}",
"text": "$t:common.save"
},
{
"type": "basic",
"name": "Span",
"if": "{{_local.isSaving}}",
"text": "$t:common.saving"
}
]
}
]
}
]
}
]
},
"schema": {
"is_test_mode": {
"type": "boolean",
"default": true,
"label": "$t:sirsoft-tosspayments.settings.test_mode",
"hint": "$t:sirsoft-tosspayments.settings.test_mode_hint"
},
"test_client_key": {
"type": "string",
"default": "",
"label": "$t:sirsoft-tosspayments.settings.test_client_key",
"hint": "$t:sirsoft-tosspayments.settings.test_client_key_hint"
},
"test_secret_key": {
"type": "string",
"default": "",
"sensitive": true,
"label": "$t:sirsoft-tosspayments.settings.test_secret_key",
"hint": "$t:sirsoft-tosspayments.settings.test_secret_key_hint"
},
"live_client_key": {
"type": "string",
"default": "",
"label": "$t:sirsoft-tosspayments.settings.live_client_key"
},
"live_secret_key": {
"type": "string",
"default": "",
"sensitive": true,
"label": "$t:sirsoft-tosspayments.settings.live_secret_key",
"hint": "$t:sirsoft-tosspayments.settings.live_secret_key_hint"
},
"redirect_success_url": {
"type": "string",
"default": "/shop/orders/{orderId}/complete",
"label": "$t:sirsoft-tosspayments.settings.redirect_success_url",
"hint": "$t:sirsoft-tosspayments.settings.redirect_success_url_hint"
},
"redirect_fail_url": {
"type": "string",
"default": "/shop/checkout",
"label": "$t:sirsoft-tosspayments.settings.redirect_fail_url",
"hint": "$t:sirsoft-tosspayments.settings.redirect_fail_url_hint"
}
}
}
@@ -0,0 +1,249 @@
<?php
namespace Plugins\Sirsoft\Tosspayments\Controllers;
use App\Extension\HookManager;
use App\Services\PluginSettingsService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Ecommerce\Exceptions\PaymentAmountMismatchException;
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
use Plugins\Sirsoft\Tosspayments\Http\Requests\FailCallbackRequest;
use Plugins\Sirsoft\Tosspayments\Http\Requests\SuccessCallbackRequest;
use Plugins\Sirsoft\Tosspayments\Services\TossPaymentsApiService;
/**
* 토스페이먼츠 결제 콜백 컨트롤러
*
* 토스페이먼츠 결제 완료/실패 후 브라우저 리다이렉트를 처리합니다.
* 성공 시 Confirm API를 호출하고, 주문 상태를 업데이트합니다.
*/
class PaymentCallbackController
{
private const PLUGIN_IDENTIFIER = 'sirsoft-tosspayments';
/**
* 카드 발급사 코드 → 이름 매핑
*/
private const CARD_ISSUER_MAP = [
'3K' => '기업 BC',
'46' => '광주',
'71' => '롯데',
'30' => '산업',
'31' => '비씨',
'51' => '삼성',
'38' => '새마을금고',
'41' => '신한',
'62' => '신협',
'36' => '씨티',
'33' => '우리',
'W1' => '우리',
'37' => '우체국',
'39' => '저축',
'35' => '전북',
'42' => '제주',
'15' => '카카오뱅크',
'3A' => '케이뱅크',
'24' => '토스뱅크',
'21' => '하나',
'61' => '현대',
'11' => 'KB국민',
'91' => 'NH농협',
'34' => 'Sh수협',
];
public function __construct(
private OrderProcessingService $orderService,
private PluginSettingsService $pluginSettingsService,
private TossPaymentsApiService $apiService,
) {}
/**
* 토스페이먼츠 결제 성공 콜백
*
* GET /plugins/sirsoft-tosspayments/payment/success
* ?paymentKey={PK}&orderId={OID}&amount={AMT}
*
* @param SuccessCallbackRequest $request 검증된 콜백 요청
* @return RedirectResponse SPA 페이지로 리다이렉트
*/
public function success(SuccessCallbackRequest $request): RedirectResponse
{
$validated = $request->validated();
$paymentKey = $validated['paymentKey'];
$orderId = $validated['orderId'];
$pgAmount = $validated['amount'];
try {
// 1. 주문 조회 (주문번호 기반)
$order = $this->orderService->findByOrderNumber($orderId);
if (! $order) {
Log::error('TossPayments: order not found', ['orderId' => $orderId]);
return redirect($this->resolveFailUrl(['error' => 'order_not_found', 'orderId' => $orderId]));
}
// 2. TossPayments Confirm API 호출
HookManager::doAction('sirsoft-tosspayments.payment.before_confirm', $order, $paymentKey, $pgAmount);
$pgResponse = $this->apiService->confirmPayment($paymentKey, $orderId, $pgAmount);
HookManager::doAction('sirsoft-tosspayments.payment.after_confirm', $order, $pgResponse);
// 3. 주문 상태 업데이트 (금액 검증 + PG 응답 → order_payments 매핑)
$this->orderService->completePayment($order, [
'transaction_id' => $pgResponse['paymentKey'] ?? null,
'card_approval_number' => $pgResponse['card']['approveNo'] ?? null,
'card_number_masked' => $pgResponse['card']['number'] ?? null,
'card_name' => $this->resolveCardIssuer($pgResponse['card']['issuerCode'] ?? null),
'card_installment_months' => $pgResponse['card']['installmentPlanMonths'] ?? 0,
'is_interest_free' => $pgResponse['card']['isInterestFree'] ?? false,
'embedded_pg_provider' => $pgResponse['easyPay']['provider'] ?? null,
'receipt_url' => $pgResponse['receipt']['url'] ?? null,
'payment_meta' => [
'method' => $pgResponse['method'] ?? null,
'pg_raw_response' => $pgResponse,
],
'payment_device' => $this->detectDevice($request),
], $pgAmount);
// 4. SPA 주문 완료 페이지로 redirect
return redirect($this->resolveSuccessUrl($orderId));
} catch (PaymentAmountMismatchException $e) {
Log::error('TossPayments: amount mismatch', [
'orderId' => $orderId,
'expected' => $e->getExpectedAmount(),
'actual' => $e->getActualAmount(),
'context' => $e->getContext(),
]);
return redirect($this->resolveFailUrl([
'error' => 'amount_mismatch',
'orderId' => $orderId,
]));
} catch (\Exception $e) {
Log::error('TossPayments: confirm failed', [
'orderId' => $orderId,
'error' => $e->getMessage(),
'code' => $e->getCode(),
]);
return redirect($this->resolveFailUrl([
'error' => 'confirm_failed',
'message' => $e->getMessage(),
'orderId' => $orderId,
]));
}
}
/**
* 토스페이먼츠 결제 실패 콜백
*
* GET /plugins/sirsoft-tosspayments/payment/fail
* ?code={ERR}&message={MSG}&orderId={OID}
*
* @param FailCallbackRequest $request 검증된 콜백 요청
* @return RedirectResponse SPA 체크아웃 페이지로 리다이렉트
*/
public function fail(FailCallbackRequest $request): RedirectResponse
{
$validated = $request->validated();
$code = $validated['code'] ?? 'UNKNOWN';
$message = $validated['message'] ?? '';
$orderId = $validated['orderId'] ?? null;
Log::warning('TossPayments: payment failed', [
'code' => $code,
'message' => $message,
'orderId' => $orderId,
]);
if ($orderId) {
$order = $this->orderService->findByOrderNumber($orderId);
if ($order) {
$this->orderService->failPayment($order, $code, $message);
}
}
return redirect($this->resolveFailUrl([
'error' => $code,
'message' => $message,
'orderId' => $orderId,
]));
}
/**
* 카드 발급사 코드 → 이름 변환
*
* @param string|null $issuerCode 발급사 코드
* @return string|null 카드사명
*/
private function resolveCardIssuer(?string $issuerCode): ?string
{
if ($issuerCode === null) {
return null;
}
return self::CARD_ISSUER_MAP[$issuerCode] ?? $issuerCode;
}
/**
* 결제 성공 리다이렉트 URL 생성
*
* @param string $orderId 주문번호
* @return string 리다이렉트 URL
*/
private function resolveSuccessUrl(string $orderId): string
{
$settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
$urlTemplate = $settings['redirect_success_url'] ?? '/shop/orders/{orderId}/complete';
return str_replace('{orderId}', $orderId, $urlTemplate);
}
/**
* 결제 실패 리다이렉트 URL 생성
*
* @param array $queryParams 쿼리 파라미터
* @return string 리다이렉트 URL
*/
private function resolveFailUrl(array $queryParams = []): string
{
$settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
$baseUrl = $settings['redirect_fail_url'] ?? '/shop/checkout';
if (empty($queryParams)) {
return $baseUrl;
}
$query = http_build_query(array_filter($queryParams));
$separator = str_contains($baseUrl, '?') ? '&' : '?';
return $baseUrl . $separator . $query;
}
/**
* 결제 디바이스 판별 (User-Agent 기반)
*
* @param Request $request HTTP 요청
* @return string 디바이스 유형 (pc/mobile)
*/
private function detectDevice(Request $request): string
{
$userAgent = $request->userAgent() ?? '';
$mobileKeywords = ['Mobile', 'Android', 'iPhone', 'iPad', 'iPod'];
foreach ($mobileKeywords as $keyword) {
if (stripos($userAgent, $keyword) !== false) {
return 'mobile';
}
}
return 'pc';
}
}
@@ -0,0 +1,51 @@
<?php
namespace Plugins\Sirsoft\Tosspayments\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* 토스페이먼츠 결제 실패 콜백 요청 검증
*
* GET /plugins/sirsoft-tosspayments/payment/fail
* ?code={ERR}&message={MSG}&orderId={OID}
*/
class FailCallbackRequest extends FormRequest
{
/**
* 권한 확인 (PG 콜백은 인증 불필요)
*
* @return bool
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙 정의
*
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'code' => ['nullable', 'string'],
'message' => ['nullable', 'string'],
'orderId' => ['nullable', 'string'],
];
}
/**
* 검증 전 기본값 설정
*
* @return void
*/
protected function prepareForValidation(): void
{
$this->mergeIfMissing([
'code' => 'UNKNOWN',
'message' => '',
]);
}
}
@@ -0,0 +1,62 @@
<?php
namespace Plugins\Sirsoft\Tosspayments\Http\Requests;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Exceptions\HttpResponseException;
/**
* 토스페이먼츠 결제 성공 콜백 요청 검증
*
* GET /plugins/sirsoft-tosspayments/payment/success
* ?paymentKey={PK}&orderId={OID}&amount={AMT}
*/
class SuccessCallbackRequest extends FormRequest
{
private const PLUGIN_IDENTIFIER = 'sirsoft-tosspayments';
/**
* 권한 확인 (PG 콜백은 인증 불필요)
*
* @return bool
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙 정의
*
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'paymentKey' => ['required', 'string'],
'orderId' => ['required', 'string'],
'amount' => ['required', 'integer', 'min:1'],
];
}
/**
* 검증 실패 시 결제 실패 페이지로 리다이렉트
*
* PG 콜백은 브라우저 리다이렉트이므로 422 대신 실패 페이지로 이동합니다.
*
* @param Validator $validator 검증기 인스턴스
* @return void
* @throws HttpResponseException
*/
protected function failedValidation(Validator $validator): void
{
$settings = plugin_settings(self::PLUGIN_IDENTIFIER);
$baseUrl = $settings['redirect_fail_url'] ?? '/shop/checkout';
$separator = str_contains($baseUrl, '?') ? '&' : '?';
throw new HttpResponseException(
redirect($baseUrl.$separator.http_build_query(['error' => 'invalid_params']))
);
}
}
@@ -0,0 +1,131 @@
<?php
namespace Plugins\Sirsoft\Tosspayments\Listeners;
use App\Contracts\Extension\HookListenerInterface;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
use Plugins\Sirsoft\Tosspayments\Services\TossPaymentsApiService;
/**
* PG 결제 환불 리스너
*
* 이커머스 모듈의 결제 환불 훅을 구독하여
* 토스페이먼츠 결제 취소 API를 호출합니다.
*/
class PaymentRefundListener implements HookListenerInterface
{
private const PG_PROVIDER_ID = 'tosspayments';
/**
* 구독할 훅 매핑 반환
*
* @return array 훅 구독 설정
*/
public static function getSubscribedHooks(): array
{
return [
'sirsoft-ecommerce.payment.refund' => [
'method' => 'processRefund',
'type' => 'filter',
'priority' => 10,
],
];
}
/**
* 기본 핸들러 (미사용)
*
* @param mixed ...$args 인수
* @return void
*/
public function handle(...$args): void
{
// processRefund에서 처리
}
/**
* PG 결제 환불을 처리합니다.
*
* @param array $result 환불 결과 (기본값)
* @param Order $order 주문
* @param OrderPayment $payment 결제 정보
* @param float $refundAmount 환불 금액
* @param string|null $reason 환불 사유
* @return array 환불 결과 {success, error_code, error_message, transaction_id}
*/
public function processRefund(
array $result,
Order $order,
OrderPayment $payment,
float $refundAmount,
?string $reason = null,
): array {
// 토스페이먼츠 결제가 아닌 경우 스킵
if ($payment->pg_provider !== self::PG_PROVIDER_ID) {
return $result;
}
$paymentKey = $payment->transaction_id;
if (! $paymentKey) {
return [
'success' => false,
'error_code' => 'MISSING_PAYMENT_KEY',
'error_message' => __('sirsoft-tosspayments::messages.refund.missing_payment_key'),
'transaction_id' => null,
];
}
try {
$apiService = $this->getApiService();
$cancelReason = $reason ?? __('sirsoft-tosspayments::messages.refund.default_reason');
$cancelAmount = (int) $refundAmount;
$response = $apiService->cancelPayment($paymentKey, $cancelReason, $cancelAmount);
Log::info('토스페이먼츠 결제 취소 성공', [
'order_id' => $order->id,
'payment_key' => $paymentKey,
'cancel_amount' => $cancelAmount,
]);
// 토스 응답에서 취소 트랜잭션 ID 추출
$cancels = $response['cancels'] ?? [];
$lastCancel = end($cancels);
$transactionId = $lastCancel['transactionKey'] ?? $paymentKey;
return [
'success' => true,
'error_code' => null,
'error_message' => null,
'transaction_id' => $transactionId,
];
} catch (\Exception $e) {
Log::error('토스페이먼츠 결제 취소 실패', [
'order_id' => $order->id,
'payment_key' => $paymentKey,
'cancel_amount' => (int) $refundAmount,
'error' => $e->getMessage(),
]);
return [
'success' => false,
'error_code' => 'PG_API_ERROR',
'error_message' => $e->getMessage(),
'transaction_id' => null,
];
}
}
/**
* API 서비스 인스턴스를 가져옵니다.
*
* @return TossPaymentsApiService
*/
protected function getApiService(): TossPaymentsApiService
{
return app(TossPaymentsApiService::class);
}
}
@@ -0,0 +1,103 @@
<?php
namespace Plugins\Sirsoft\Tosspayments\Listeners;
use App\Contracts\Extension\HookListenerInterface;
/**
* PG 제공자 등록 리스너
*
* 이커머스 모듈의 PG 제공자 목록 훅과 클라이언트 설정 훅을 구독하여
* 토스페이먼츠를 결제 제공자로 등록하고 프론트엔드 SDK 설정을 제공합니다.
*/
class RegisterPgProviderListener implements HookListenerInterface
{
private const PLUGIN_IDENTIFIER = 'sirsoft-tosspayments';
/**
* 구독할 훅 매핑 반환
*
* @return array 훅 구독 설정
*/
public static function getSubscribedHooks(): array
{
return [
'sirsoft-ecommerce.payment.registered_pg_providers' => [
'method' => 'registerProvider',
'type' => 'filter',
'priority' => 10,
],
'sirsoft-ecommerce.payment.get_client_config' => [
'method' => 'getClientConfig',
'type' => 'filter',
'priority' => 10,
],
];
}
/**
* 기본 핸들러 (미사용)
*
* @param mixed ...$args 인수
* @return void
*/
public function handle(...$args): void
{
// 개별 메서드에서 처리
}
/**
* PG 제공자 목록에 토스페이먼츠 등록
*
* @param array $providers 기존 PG 제공자 목록
* @return array 토스페이먼츠가 추가된 PG 제공자 목록
*/
public function registerProvider(array $providers): array
{
$providers[] = [
'id' => 'tosspayments',
'name' => ['ko' => '토스페이먼츠', 'en' => 'TossPayments'],
'icon' => 'credit-card',
'supported_methods' => ['card'],
];
return $providers;
}
/**
* PG 클라이언트 설정 제공 (프론트엔드 SDK용)
*
* @param array $config 기존 설정
* @param string $provider PG 제공자 ID
* @return array 클라이언트 설정
*/
public function getClientConfig(array $config, string $provider): array
{
if ($provider !== 'tosspayments') {
return $config;
}
$settings = $this->getPluginSettings();
$isTest = $settings['is_test_mode'] ?? true;
return array_merge($config, [
'client_key' => $isTest
? ($settings['test_client_key'] ?? '')
: ($settings['live_client_key'] ?? ''),
'sdk_url' => 'https://js.tosspayments.com/v2/standard',
'callback_urls' => [
'success' => '/plugins/sirsoft-tosspayments/payment/success',
'fail' => '/plugins/sirsoft-tosspayments/payment/fail',
],
]);
}
/**
* 플러그인 설정 조회
*
* @return array 플러그인 설정값
*/
private function getPluginSettings(): array
{
return plugin_settings(self::PLUGIN_IDENTIFIER);
}
}
@@ -0,0 +1,112 @@
<?php
namespace Plugins\Sirsoft\Tosspayments\Services;
use App\Services\PluginSettingsService;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* 토스페이먼츠 API 호출 서비스
*
* 결제 승인, 취소 등 토스페이먼츠 REST API를 호출합니다.
* Secret Key를 Basic 인증 헤더로 전달합니다.
*/
class TossPaymentsApiService
{
/**
* 토스페이먼츠 API 베이스 URL
*/
private const BASE_URL = 'https://api.tosspayments.com';
private const PLUGIN_IDENTIFIER = 'sirsoft-tosspayments';
private string $secretKey;
/**
* @param PluginSettingsService $pluginSettingsService 플러그인 설정 서비스
*/
public function __construct(PluginSettingsService $pluginSettingsService)
{
$settings = $pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
$isTest = $settings['is_test_mode'] ?? true;
$this->secretKey = $isTest
? ($settings['test_secret_key'] ?? '')
: ($settings['live_secret_key'] ?? '');
}
/**
* 결제 승인 API 호출
*
* @param string $paymentKey 토스 결제 키
* @param string $orderId 주문번호
* @param int $amount 결제 금액
* @return array PG 응답 데이터
* @throws \Exception API 호출 실패 시
*/
public function confirmPayment(string $paymentKey, string $orderId, int $amount): array
{
return $this->request('POST', '/v1/payments/confirm', [
'paymentKey' => $paymentKey,
'orderId' => $orderId,
'amount' => $amount,
]);
}
/**
* 결제 취소 API 호출
*
* @param string $paymentKey 토스 결제 키
* @param string $cancelReason 취소 사유
* @param int|null $cancelAmount 부분 취소 금액 (null이면 전액 취소)
* @return array PG 응답 데이터
* @throws \Exception API 호출 실패 시
*/
public function cancelPayment(string $paymentKey, string $cancelReason, ?int $cancelAmount = null): array
{
$body = ['cancelReason' => $cancelReason];
if ($cancelAmount !== null) {
$body['cancelAmount'] = $cancelAmount;
}
return $this->request('POST', "/v1/payments/{$paymentKey}/cancel", $body);
}
/**
* 토스페이먼츠 API 요청
*
* @param string $method HTTP 메서드
* @param string $path API 경로
* @param array $body 요청 본문
* @return array 응답 데이터
* @throws \Exception API 호출 실패 시
*/
private function request(string $method, string $path, array $body): array
{
$authHeader = 'Basic ' . base64_encode($this->secretKey . ':');
$response = Http::withHeaders([
'Authorization' => $authHeader,
'Content-Type' => 'application/json',
])->{strtolower($method)}(self::BASE_URL . $path, $body);
if ($response->failed()) {
$error = $response->json();
Log::error('TossPayments API error', [
'path' => $path,
'status' => $response->status(),
'error_code' => $error['code'] ?? 'UNKNOWN',
'error_message' => $error['message'] ?? '',
]);
throw new \Exception(
$error['message'] ?? 'TossPayments API error',
$response->status()
);
}
return $response->json();
}
}
@@ -0,0 +1,22 @@
<?php
use Illuminate\Support\Facades\Route;
use Plugins\Sirsoft\Tosspayments\Controllers\PaymentCallbackController;
/*
|--------------------------------------------------------------------------
| TossPayments Plugin Web Routes
|--------------------------------------------------------------------------
|
| 프리픽스: /plugins/sirsoft-tosspayments (PluginRouteServiceProvider 자동 적용)
| 미들웨어: web (PluginRouteServiceProvider 자동 적용)
|
*/
// 결제 성공 콜백 (토스페이먼츠 → 브라우저 리다이렉트)
Route::get('/payment/success', [PaymentCallbackController::class, 'success'])
->name('payment.success');
// 결제 실패 콜백 (토스페이먼츠 → 브라우저 리다이렉트)
Route::get('/payment/fail', [PaymentCallbackController::class, 'fail'])
->name('payment.fail');
@@ -0,0 +1,469 @@
<?php
namespace Plugins\Sirsoft\Tosspayments\Tests\Feature\Controllers;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderPaymentFactory;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Plugins\Sirsoft\Tosspayments\Tests\PluginTestCase;
/**
* 토스페이먼츠 결제 콜백 컨트롤러 기능 테스트
*/
class PaymentCallbackControllerTest extends PluginTestCase
{
/**
* 토스페이먼츠 Confirm API 성공 응답 mock 데이터
*
* @param string $paymentKey 결제 키
* @param string $orderId 주문번호
* @param int $amount 결제 금액
* @return array
*/
private function makeMockConfirmResponse(string $paymentKey, string $orderId, int $amount): array
{
return [
'paymentKey' => $paymentKey,
'orderId' => $orderId,
'status' => 'DONE',
'totalAmount' => $amount,
'method' => '카드',
'approvedAt' => now()->toIso8601String(),
'card' => [
'issuerCode' => '41',
'acquirerCode' => '41',
'number' => '4330-****-****-1234',
'installmentPlanMonths' => 0,
'approveNo' => '12345678',
'isInterestFree' => false,
'cardType' => '신용',
'ownerType' => '개인',
],
'easyPay' => null,
'receipt' => [
'url' => 'https://dashboard.tosspayments.com/receipt/test',
],
];
}
/**
* 테스트용 주문 + 결제 레코드 생성
*
* @param int $totalAmount 주문 총액
* @return Order
*/
private function createTestOrder(int $totalAmount = 50000): Order
{
$user = User::factory()->create();
$order = OrderFactory::new()->create([
'user_id' => $user->id,
'order_number' => 'ORD-TEST-' . random_int(10000, 99999),
'order_status' => OrderStatusEnum::PENDING_ORDER,
'subtotal_amount' => $totalAmount,
'total_discount_amount' => 0,
'total_coupon_discount_amount' => 0,
'total_product_coupon_discount_amount' => 0,
'total_order_coupon_discount_amount' => 0,
'total_code_discount_amount' => 0,
'base_shipping_amount' => 0,
'extra_shipping_amount' => 0,
'shipping_discount_amount' => 0,
'total_shipping_amount' => 0,
'total_amount' => $totalAmount,
'total_due_amount' => $totalAmount,
'total_points_used_amount' => 0,
'total_deposit_used_amount' => 0,
'total_paid_amount' => 0,
]);
OrderPaymentFactory::new()->create([
'order_id' => $order->id,
'payment_status' => PaymentStatusEnum::READY,
'payment_method' => PaymentMethodEnum::CARD,
'pg_provider' => 'tosspayments',
'paid_amount_local' => 0,
'paid_at' => null,
'transaction_id' => null,
'card_approval_number' => null,
]);
return $order;
}
/**
* 플러그인 설정을 mock합니다.
*
* @param array $overrides 기본 설정을 덮어쓸 값
*/
private function mockPluginSettings(array $overrides = []): void
{
$defaults = [
'is_test_mode' => true,
'test_secret_key' => 'test_sk_mock_key',
'test_client_key' => 'test_ck_mock_key',
'redirect_success_url' => '/shop/orders/{orderId}/complete',
'redirect_fail_url' => '/shop/checkout',
];
$settingsMock = $this->createMock(\App\Services\PluginSettingsService::class);
$settingsMock->method('get')
->willReturn(array_merge($defaults, $overrides));
$this->app->instance(\App\Services\PluginSettingsService::class, $settingsMock);
}
// ===== Success 콜백 테스트 =====
/**
* 결제 성공 시 주문 완료 페이지로 리다이렉트하는지 확인
*/
public function test_success_redirects_to_complete_page_on_valid_payment(): void
{
$order = $this->createTestOrder(50000);
$paymentKey = 'pk_test_abc123';
$this->mockPluginSettings();
Http::fake([
'api.tosspayments.com/v1/payments/confirm' => Http::response(
$this->makeMockConfirmResponse($paymentKey, $order->order_number, 50000),
200
),
]);
$response = $this->get("/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([
'paymentKey' => $paymentKey,
'orderId' => $order->order_number,
'amount' => 50000,
]));
$response->assertRedirect("/shop/orders/{$order->order_number}/complete");
// 주문 상태가 PAYMENT_COMPLETE로 변경되었는지 확인
$order->refresh();
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status);
// 결제 정보가 업데이트되었는지 확인
$payment = $order->payment;
$payment->refresh();
$this->assertEquals($paymentKey, $payment->transaction_id);
$this->assertEquals('12345678', $payment->card_approval_number);
$this->assertEquals('4330-****-****-1234', $payment->card_number_masked);
$this->assertEquals('신한', $payment->card_name);
}
/**
* 존재하지 않는 주문번호로 요청 시 체크아웃으로 리다이렉트
*/
public function test_success_redirects_to_checkout_on_order_not_found(): void
{
$this->mockPluginSettings();
$response = $this->get("/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([
'paymentKey' => 'pk_test_xxx',
'orderId' => 'NON_EXISTENT_ORDER',
'amount' => 50000,
]));
$response->assertRedirect();
$this->assertStringContainsString('error=order_not_found', $response->headers->get('Location'));
}
/**
* TossPayments Confirm API 실패 시 체크아웃으로 리다이렉트
*/
public function test_success_redirects_to_checkout_on_confirm_api_failure(): void
{
$order = $this->createTestOrder(50000);
$this->mockPluginSettings();
Http::fake([
'api.tosspayments.com/v1/payments/confirm' => Http::response([
'code' => 'ALREADY_PROCESSED_PAYMENT',
'message' => '이미 처리된 결제입니다.',
], 400),
]);
$response = $this->get("/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([
'paymentKey' => 'pk_test_fail',
'orderId' => $order->order_number,
'amount' => 50000,
]));
$response->assertRedirect();
$this->assertStringContainsString('error=confirm_failed', $response->headers->get('Location'));
// 주문 상태가 변경되지 않았는지 확인
$order->refresh();
$this->assertEquals(OrderStatusEnum::PENDING_ORDER, $order->order_status);
}
/**
* PG 금액 불일치 시 체크아웃으로 리다이렉트
*/
public function test_success_redirects_to_checkout_on_amount_mismatch(): void
{
$order = $this->createTestOrder(50000);
$this->mockPluginSettings();
Http::fake([
'api.tosspayments.com/v1/payments/confirm' => Http::response(
$this->makeMockConfirmResponse('pk_test_mismatch', $order->order_number, 99999),
200
),
]);
$response = $this->get("/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([
'paymentKey' => 'pk_test_mismatch',
'orderId' => $order->order_number,
'amount' => 99999,
]));
$response->assertRedirect();
$this->assertStringContainsString('error=amount_mismatch', $response->headers->get('Location'));
}
// ===== Fail 콜백 테스트 =====
/**
* 결제 실패 시 체크아웃으로 리다이렉트하는지 확인
*/
public function test_fail_redirects_to_checkout_with_error_params(): void
{
$response = $this->get("/plugins/sirsoft-tosspayments/payment/fail?" . http_build_query([
'code' => 'USER_CANCEL',
'message' => '사용자가 취소했습니다.',
'orderId' => 'ORD-TEST-12345',
]));
$response->assertRedirect();
$location = $response->headers->get('Location');
$this->assertStringContainsString('error=USER_CANCEL', $location);
$this->assertStringContainsString('orderId=ORD-TEST-12345', $location);
}
/**
* 결제 실패 시 주문이 존재하면 failPayment 처리되는지 확인
*/
public function test_fail_calls_fail_payment_when_order_exists(): void
{
$order = $this->createTestOrder(30000);
$response = $this->get("/plugins/sirsoft-tosspayments/payment/fail?" . http_build_query([
'code' => 'PAY_PROCESS_CANCELED',
'message' => '결제가 취소되었습니다.',
'orderId' => $order->order_number,
]));
$response->assertRedirect();
// 주문 상태가 CANCELLED로 변경되었는지 확인
$order->refresh();
$this->assertEquals(OrderStatusEnum::CANCELLED, $order->order_status);
// 주문 메타에 실패 정보가 저장되었는지 확인
$meta = $order->order_meta;
$this->assertEquals('PAY_PROCESS_CANCELED', $meta['payment_failure_code']);
}
/**
* 결제 실패 시 orderId 없이도 에러 없이 처리되는지 확인
*/
public function test_fail_handles_missing_order_id_gracefully(): void
{
$response = $this->get("/plugins/sirsoft-tosspayments/payment/fail?" . http_build_query([
'code' => 'UNKNOWN_ERROR',
'message' => '알 수 없는 오류',
]));
$response->assertRedirect();
$this->assertStringContainsString('error=UNKNOWN_ERROR', $response->headers->get('Location'));
}
// ===== 커스텀 리다이렉트 URL 테스트 =====
/**
* 커스텀 성공 URL 설정 시 해당 URL로 리다이렉트하는지 확인
*/
public function test_success_redirects_to_custom_success_url(): void
{
$order = $this->createTestOrder(50000);
$paymentKey = 'pk_test_custom';
$this->mockPluginSettings([
'redirect_success_url' => '/custom/payment/{orderId}/done',
]);
Http::fake([
'api.tosspayments.com/v1/payments/confirm' => Http::response(
$this->makeMockConfirmResponse($paymentKey, $order->order_number, 50000),
200
),
]);
$response = $this->get("/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([
'paymentKey' => $paymentKey,
'orderId' => $order->order_number,
'amount' => 50000,
]));
$response->assertRedirect("/custom/payment/{$order->order_number}/done");
}
/**
* 커스텀 실패 URL 설정 시 해당 URL로 리다이렉트하는지 확인
*/
public function test_fail_redirects_to_custom_fail_url(): void
{
$this->mockPluginSettings([
'redirect_fail_url' => '/custom/checkout/error',
]);
$response = $this->get("/plugins/sirsoft-tosspayments/payment/fail?" . http_build_query([
'code' => 'USER_CANCEL',
'message' => '사용자가 취소했습니다.',
'orderId' => 'ORD-TEST-99999',
]));
$response->assertRedirect();
$location = $response->headers->get('Location');
$this->assertStringContainsString('/custom/checkout/error?', $location);
$this->assertStringContainsString('error=USER_CANCEL', $location);
$this->assertStringContainsString('orderId=ORD-TEST-99999', $location);
}
/**
* 커스텀 실패 URL 설정 시 성공 콜백 내 오류에서도 해당 URL로 리다이렉트하는지 확인
*/
public function test_success_redirects_to_custom_fail_url_on_order_not_found(): void
{
$this->mockPluginSettings([
'redirect_fail_url' => '/custom/checkout/error',
]);
$response = $this->get("/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([
'paymentKey' => 'pk_test_xxx',
'orderId' => 'NON_EXISTENT_ORDER',
'amount' => 50000,
]));
$response->assertRedirect();
$location = $response->headers->get('Location');
$this->assertStringContainsString('/custom/checkout/error?', $location);
$this->assertStringContainsString('error=order_not_found', $location);
}
/**
* 전체 URL + 기존 쿼리 파라미터가 있는 실패 URL에서 & 구분자로 연결되는지 확인
*/
public function test_fail_redirects_to_full_url_with_existing_query_params(): void
{
$this->mockPluginSettings([
'redirect_fail_url' => 'https://example.com/checkout?ref=toss',
]);
$response = $this->get("/plugins/sirsoft-tosspayments/payment/fail?" . http_build_query([
'code' => 'NETWORK_ERROR',
'message' => '네트워크 오류',
'orderId' => 'ORD-TEST-88888',
]));
$response->assertRedirect();
$location = $response->headers->get('Location');
$this->assertStringStartsWith('https://example.com/checkout?ref=toss&', $location);
$this->assertStringContainsString('error=NETWORK_ERROR', $location);
$this->assertStringContainsString('orderId=ORD-TEST-88888', $location);
}
// ===== FormRequest 검증 테스트 =====
/**
* 성공 콜백에 필수 파라미터 누락 시 실패 페이지로 리다이렉트하는지 확인
*/
public function test_success_redirects_to_fail_url_on_missing_params(): void
{
$this->mockPluginSettings();
// paymentKey 누락
$response = $this->get("/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([
'orderId' => 'ORD-TEST-12345',
'amount' => 50000,
]));
$response->assertRedirect();
$this->assertStringContainsString('error=invalid_params', $response->headers->get('Location'));
}
/**
* 성공 콜백에 금액이 0 이하일 때 실패 페이지로 리다이렉트하는지 확인
*/
public function test_success_redirects_to_fail_url_on_invalid_amount(): void
{
$this->mockPluginSettings();
$response = $this->get("/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([
'paymentKey' => 'pk_test_xxx',
'orderId' => 'ORD-TEST-12345',
'amount' => 0,
]));
$response->assertRedirect();
$this->assertStringContainsString('error=invalid_params', $response->headers->get('Location'));
}
/**
* 성공 콜백에 파라미터가 모두 없으면 실패 페이지로 리다이렉트하는지 확인
*/
public function test_success_redirects_to_fail_url_on_empty_params(): void
{
$this->mockPluginSettings();
$response = $this->get('/plugins/sirsoft-tosspayments/payment/success');
$response->assertRedirect();
$this->assertStringContainsString('error=invalid_params', $response->headers->get('Location'));
}
// ===== 디바이스 감지 테스트 =====
/**
* 모바일 User-Agent로 요청 시 payment_device가 mobile인지 확인
*/
public function test_success_detects_mobile_device(): void
{
$order = $this->createTestOrder(50000);
$paymentKey = 'pk_test_mobile';
$this->mockPluginSettings();
Http::fake([
'api.tosspayments.com/v1/payments/confirm' => Http::response(
$this->makeMockConfirmResponse($paymentKey, $order->order_number, 50000),
200
),
]);
$response = $this->get(
"/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([
'paymentKey' => $paymentKey,
'orderId' => $order->order_number,
'amount' => 50000,
]),
['User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X)']
);
$response->assertRedirect("/shop/orders/{$order->order_number}/complete");
$payment = $order->payment;
$payment->refresh();
$this->assertEquals('mobile', $payment->payment_device);
}
}
@@ -0,0 +1,222 @@
<?php
namespace Plugins\Sirsoft\Tosspayments\Tests;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 토스페이먼츠 플러그인 테스트 베이스 클래스
*
* 모든 토스페이먼츠 플러그인 테스트는 이 클래스를 상속받아야 합니다.
* 모듈/플러그인 오토로드, ServiceProvider 등록, 마이그레이션, 라우트 등록을 자동으로 처리합니다.
*/
abstract class PluginTestCase extends TestCase
{
use RefreshDatabase;
/**
* 시딩 활성화
*
* @return bool
*/
protected function shouldSeed(): bool
{
return true;
}
/**
* 테스트용 시더 클래스
*
* @return string
*/
protected function seeder(): string
{
return \Modules\Sirsoft\Ecommerce\Database\Seeders\TestingSeeder::class;
}
/**
* 마이그레이션 경로를 반환합니다.
*
* @return array
*/
protected function migrateFreshUsing(): array
{
return [
'--drop-views' => $this->shouldDropViews(),
'--drop-types' => $this->shouldDropTypes(),
'--seed' => $this->shouldSeed(),
'--seeder' => $this->seeder(),
'--path' => [
'database/migrations',
'modules/sirsoft-ecommerce/database/migrations',
],
];
}
/**
* 테스트 환경 설정
*/
protected function setUp(): void
{
parent::setUp();
// 이커머스 모듈 오토로드 등록
$this->registerModuleAutoload();
// 플러그인 오토로드 등록
$this->registerPluginAutoload();
// 이커머스 모듈 ServiceProvider 등록 (Repository 바인딩)
$this->app->register(\Modules\Sirsoft\Ecommerce\Providers\EcommerceServiceProvider::class);
// 모듈 라우트를 수동으로 등록
$this->registerModuleRoutes();
// 플러그인 라우트를 수동으로 등록
$this->registerPluginRoutes();
}
/**
* 이커머스 모듈 오토로드를 등록합니다.
*/
protected function registerModuleAutoload(): void
{
$moduleBasePath = base_path('modules/sirsoft-ecommerce/src/');
spl_autoload_register(function ($class) use ($moduleBasePath) {
$prefix = 'Modules\\Sirsoft\\Ecommerce\\';
$len = strlen($prefix);
if (strncmp($prefix, $class, $len) !== 0) {
return;
}
$relativeClass = substr($class, $len);
$file = $moduleBasePath . str_replace('\\', '/', $relativeClass) . '.php';
if (file_exists($file)) {
require $file;
}
});
// composer.json files 오토로드 (헬퍼 함수 등록)
$helpersFile = $moduleBasePath . 'Helpers/helpers.php';
if (file_exists($helpersFile)) {
require_once $helpersFile;
}
}
/**
* 플러그인 오토로드를 등록합니다.
*/
protected function registerPluginAutoload(): void
{
$pluginBasePath = base_path('plugins/sirsoft-tosspayments/src/');
spl_autoload_register(function ($class) use ($pluginBasePath) {
$prefix = 'Plugins\\Sirsoft\\Tosspayments\\';
$len = strlen($prefix);
if (strncmp($prefix, $class, $len) !== 0) {
return;
}
$relativeClass = substr($class, $len);
$file = $pluginBasePath . str_replace('\\', '/', $relativeClass) . '.php';
if (file_exists($file)) {
require $file;
}
});
}
/**
* 모듈 라우트를 등록합니다.
*/
protected function registerModuleRoutes(): void
{
$apiRoutesFile = base_path('modules/sirsoft-ecommerce/src/routes/api.php');
if (file_exists($apiRoutesFile)) {
\Illuminate\Support\Facades\Route::prefix('api/modules/sirsoft-ecommerce')
->name('api.modules.sirsoft-ecommerce.')
->middleware('api')
->group($apiRoutesFile);
}
}
/**
* 플러그인 라우트를 등록합니다.
*/
protected function registerPluginRoutes(): void
{
$webRoutesFile = base_path('plugins/sirsoft-tosspayments/src/routes/web.php');
if (file_exists($webRoutesFile)) {
\Illuminate\Support\Facades\Route::prefix('plugins/sirsoft-tosspayments')
->name('plugins.sirsoft-tosspayments.')
->middleware('web')
->group($webRoutesFile);
}
}
/**
* 관리자 사용자를 생성합니다.
*
* @param array $permissions 추가 권한 목록
* @return \App\Models\User
*/
protected function createAdminUser(array $permissions = []): \App\Models\User
{
$user = \App\Models\User::factory()->create();
$uniqueRoleIdentifier = 'admin-test-' . $user->id . '-' . time();
$userRole = \App\Models\Role::create([
'identifier' => $uniqueRoleIdentifier,
'name' => ['ko' => '테스트 관리자', 'en' => 'Test Admin'],
]);
$user->roles()->attach($userRole->id);
$adminAccessPermission = \App\Models\Permission::firstOrCreate(
['identifier' => 'admin.access'],
[
'name' => ['ko' => '관리자 접근', 'en' => 'Admin Access'],
'type' => \App\Enums\PermissionType::Admin,
]
);
$userRole->permissions()->attach($adminAccessPermission->id);
if (! empty($permissions)) {
foreach ($permissions as $permissionIdentifier) {
$permission = \App\Models\Permission::firstOrCreate(
['identifier' => $permissionIdentifier],
[
'name' => ['ko' => $permissionIdentifier, 'en' => $permissionIdentifier],
'type' => 'admin',
]
);
$userRole->permissions()->syncWithoutDetaching([$permission->id]);
}
}
return $user;
}
/**
* 일반 사용자를 생성합니다.
*
* @return \App\Models\User
*/
protected function createUser(): \App\Models\User
{
$userRole = \App\Models\Role::where('identifier', 'user')->first();
$user = \App\Models\User::factory()->create();
if ($userRole) {
$user->roles()->attach($userRole->id);
}
return $user;
}
}
@@ -0,0 +1,103 @@
<?php
namespace Plugins\Sirsoft\Tosspayments\Tests\Unit\Listeners;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
use PHPUnit\Framework\TestCase;
use Plugins\Sirsoft\Tosspayments\Listeners\PaymentRefundListener;
/**
* PaymentRefundListener 단위 테스트
*/
class PaymentRefundListenerTest extends TestCase
{
/**
* getSubscribedHooks가 올바른 훅 매핑을 반환하는지 확인
*/
public function test_get_subscribed_hooks_returns_correct_hooks(): void
{
$hooks = PaymentRefundListener::getSubscribedHooks();
$this->assertArrayHasKey('sirsoft-ecommerce.payment.refund', $hooks);
$this->assertEquals('filter', $hooks['sirsoft-ecommerce.payment.refund']['type']);
$this->assertEquals('processRefund', $hooks['sirsoft-ecommerce.payment.refund']['method']);
$this->assertEquals(10, $hooks['sirsoft-ecommerce.payment.refund']['priority']);
}
/**
* pg_provider가 'tosspayments'가 아닌 결제는 스킵하는지 확인
*/
public function test_process_refund_skips_non_tosspayments_provider(): void
{
$listener = new PaymentRefundListener();
$order = $this->createMock(Order::class);
$payment = $this->createStub(OrderPayment::class);
$payment->method('__get')->willReturnCallback(function ($key) {
return match ($key) {
'pg_provider' => 'other_pg',
default => null,
};
});
$defaultResult = ['success' => false, 'error_code' => null, 'error_message' => null, 'transaction_id' => null];
$result = $listener->processRefund($defaultResult, $order, $payment, 10000.0);
$this->assertFalse($result['success']);
$this->assertNull($result['error_code']);
}
/**
* pg_provider가 'sirsoft-tosspayments'(플러그인 식별자)인 경우 스킵되는지 확인
* (이전 버그: 플러그인 식별자와 PG provider ID를 혼동하여 항상 스킵됨)
*/
public function test_process_refund_does_not_match_plugin_identifier(): void
{
$listener = new PaymentRefundListener();
$order = $this->createMock(Order::class);
$payment = $this->createStub(OrderPayment::class);
$payment->method('__get')->willReturnCallback(function ($key) {
return match ($key) {
'pg_provider' => 'sirsoft-tosspayments',
default => null,
};
});
$defaultResult = ['success' => false, 'error_code' => null, 'error_message' => null, 'transaction_id' => null];
$result = $listener->processRefund($defaultResult, $order, $payment, 10000.0);
// 플러그인 식별자는 PG provider ID가 아니므로 스킵되어야 함
$this->assertFalse($result['success']);
$this->assertNull($result['error_code']);
}
/**
* pg_provider가 'tosspayments'일 때 스킵하지 않고 처리에 진입하는지 확인
*/
public function test_process_refund_enters_processing_for_tosspayments(): void
{
$listener = new PaymentRefundListener();
$order = $this->createMock(Order::class);
$payment = $this->createStub(OrderPayment::class);
$payment->method('__get')->willReturnCallback(function ($key) {
return match ($key) {
'pg_provider' => 'tosspayments',
'transaction_id' => 'test_payment_key',
default => null,
};
});
$defaultResult = ['success' => false, 'error_code' => null, 'error_message' => null, 'transaction_id' => null];
// getApiService()에서 app() 호출 시 예외 발생 → 정상적으로 진입했다는 증거
// (Unit 테스트에서 Laravel 컨테이너 없으므로 BindingResolutionException 발생)
$this->expectException(\RuntimeException::class);
$listener->processRefund($defaultResult, $order, $payment, 10000.0);
}
}
@@ -0,0 +1,100 @@
<?php
namespace Plugins\Sirsoft\Tosspayments\Tests\Unit\Listeners;
use PHPUnit\Framework\TestCase;
use Plugins\Sirsoft\Tosspayments\Listeners\RegisterPgProviderListener;
/**
* RegisterPgProviderListener 단위 테스트
*/
class RegisterPgProviderListenerTest extends TestCase
{
private RegisterPgProviderListener $listener;
protected function setUp(): void
{
parent::setUp();
$this->listener = new RegisterPgProviderListener();
}
/**
* getSubscribedHooks가 올바른 훅 매핑을 반환하는지 확인
*/
public function test_get_subscribed_hooks_returns_correct_hooks(): void
{
$hooks = RegisterPgProviderListener::getSubscribedHooks();
$this->assertArrayHasKey('sirsoft-ecommerce.payment.registered_pg_providers', $hooks);
$this->assertArrayHasKey('sirsoft-ecommerce.payment.get_client_config', $hooks);
// filter 타입 확인
$this->assertEquals('filter', $hooks['sirsoft-ecommerce.payment.registered_pg_providers']['type']);
$this->assertEquals('filter', $hooks['sirsoft-ecommerce.payment.get_client_config']['type']);
// 메서드명 확인
$this->assertEquals('registerProvider', $hooks['sirsoft-ecommerce.payment.registered_pg_providers']['method']);
$this->assertEquals('getClientConfig', $hooks['sirsoft-ecommerce.payment.get_client_config']['method']);
}
/**
* registerProvider가 토스페이먼츠를 제공자 목록에 추가하는지 확인
*/
public function test_register_provider_adds_tosspayments(): void
{
$existingProviders = [
['id' => 'other_pg', 'name' => ['ko' => '기타 PG'], 'icon' => 'wallet'],
];
$result = $this->listener->registerProvider($existingProviders);
$this->assertCount(2, $result);
$toss = $result[1];
$this->assertEquals('tosspayments', $toss['id']);
$this->assertEquals('토스페이먼츠', $toss['name']['ko']);
$this->assertEquals('TossPayments', $toss['name']['en']);
$this->assertEquals('credit-card', $toss['icon']);
$this->assertContains('card', $toss['supported_methods']);
}
/**
* registerProvider가 기존 제공자를 유지하는지 확인
*/
public function test_register_provider_preserves_existing_providers(): void
{
$existingProviders = [
['id' => 'pg_a', 'name' => ['ko' => 'PG A']],
['id' => 'pg_b', 'name' => ['ko' => 'PG B']],
];
$result = $this->listener->registerProvider($existingProviders);
$this->assertCount(3, $result);
$this->assertEquals('pg_a', $result[0]['id']);
$this->assertEquals('pg_b', $result[1]['id']);
$this->assertEquals('tosspayments', $result[2]['id']);
}
/**
* registerProvider가 빈 배열에도 동작하는지 확인
*/
public function test_register_provider_works_with_empty_array(): void
{
$result = $this->listener->registerProvider([]);
$this->assertCount(1, $result);
$this->assertEquals('tosspayments', $result[0]['id']);
}
/**
* getClientConfig가 다른 provider에 대해 원본 config를 반환하는지 확인
*/
public function test_get_client_config_ignores_other_providers(): void
{
$config = ['some' => 'value'];
$result = $this->listener->getClientConfig($config, 'inicis');
$this->assertEquals($config, $result);
}
}
@@ -0,0 +1,98 @@
<?php
namespace Plugins\Sirsoft\Tosspayments\Tests\Unit\Services;
use App\Services\PluginSettingsService;
use PHPUnit\Framework\TestCase;
use Plugins\Sirsoft\Tosspayments\Services\TossPaymentsApiService;
/**
* TossPaymentsApiService 단위 테스트
*/
class TossPaymentsApiServiceTest extends TestCase
{
/**
* PluginSettingsService mock을 생성합니다.
*
* @param array $settings 반환할 설정값
* @return PluginSettingsService
*/
private function mockSettingsService(array $settings): PluginSettingsService
{
$mock = $this->createMock(PluginSettingsService::class);
$mock->method('get')
->with('sirsoft-tosspayments')
->willReturn($settings);
return $mock;
}
/**
* 테스트 모드에서 테스트 시크릿 키를 사용하는지 확인
*/
public function test_constructor_uses_test_secret_key_in_test_mode(): void
{
$service = new TossPaymentsApiService($this->mockSettingsService([
'is_test_mode' => true,
'test_secret_key' => 'test_sk_abc123',
'live_secret_key' => 'live_sk_xyz789',
]));
$reflection = new \ReflectionClass($service);
$prop = $reflection->getProperty('secretKey');
$prop->setAccessible(true);
$this->assertEquals('test_sk_abc123', $prop->getValue($service));
}
/**
* 라이브 모드에서 라이브 시크릿 키를 사용하는지 확인
*/
public function test_constructor_uses_live_secret_key_in_live_mode(): void
{
$service = new TossPaymentsApiService($this->mockSettingsService([
'is_test_mode' => false,
'test_secret_key' => 'test_sk_abc123',
'live_secret_key' => 'live_sk_xyz789',
]));
$reflection = new \ReflectionClass($service);
$prop = $reflection->getProperty('secretKey');
$prop->setAccessible(true);
$this->assertEquals('live_sk_xyz789', $prop->getValue($service));
}
/**
* 설정이 비어있을 때 기본값(테스트 모드, 빈 키)으로 초기화되는지 확인
*/
public function test_constructor_defaults_to_test_mode_with_empty_key(): void
{
$service = new TossPaymentsApiService($this->mockSettingsService([]));
$reflection = new \ReflectionClass($service);
$prop = $reflection->getProperty('secretKey');
$prop->setAccessible(true);
$this->assertEquals('', $prop->getValue($service));
}
/**
* 플러그인 설정이 null일 때 기본값으로 초기화되는지 확인
*/
public function test_constructor_handles_null_settings_gracefully(): void
{
$mock = $this->createMock(PluginSettingsService::class);
$mock->method('get')
->with('sirsoft-tosspayments')
->willReturn(null);
$service = new TossPaymentsApiService($mock);
$reflection = new \ReflectionClass($service);
$prop = $reflection->getProperty('secretKey');
$prop->setAccessible(true);
$this->assertEquals('', $prop->getValue($service));
}
}
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"strict": true,
"moduleResolution": "bundler",
"noEmit": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["resources/js/*"]
}
},
"include": ["resources/js/**/*.ts"]
}
@@ -0,0 +1,27 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'resources/js/index.ts'),
name: 'SirsoftTosspayments',
formats: ['iife'],
},
outDir: 'dist',
rollupOptions: {
output: {
entryFileNames: 'js/plugin.iife.js',
},
},
sourcemap: true,
minify: 'esbuild',
target: 'es2020',
chunkSizeWarningLimit: 500,
},
resolve: {
alias: {
'@': resolve(__dirname, 'resources/js'),
},
},
});
@@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
include: ['resources/js/**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules/', 'dist/'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'resources/js'),
'@core': path.resolve(__dirname, '../../resources/js/core'),
},
},
});
@@ -0,0 +1,35 @@
# Changelog
이 프로젝트의 모든 주요 변경사항을 기록합니다.
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.0-beta.1] - 2026-04-01
### Changed
- 오픈 베타 릴리즈
## [0.1.2] - 2026-03-16
### Changed
- 라이선스 프로그램 명칭 정비
## [0.1.1] - 2026-02-24
### Added
- manifest에 license 필드 및 LICENSE 파일 추가
### Changed
- 버전 체계 조정 (정식 출시 전 0.x 체계로 변경)
## [0.1.0] - 2026-02-23
### Added
- 본인인증 플러그인 초기 구현
- 휴대폰 인증, 아이핀 인증 등 본인인증 기능 제공
- 사용자 관리 화면 레이아웃 확장 (본인인증 상세/폼)
- 역할 기반 접근 제어 (본인인증 관리자)
- 다국어 지원 (ko, en)
@@ -0,0 +1,47 @@
프로그램 명칭 : 그누보드7용 본인인증 플러그인 (sirsoft-verification)
저작자 : (주)에스아이알소프트
----- MIT 라이선스 (한국어 번역) --------------------------------------------------------
MIT 라이선스
Copyright (c) 2026 (주)에스아이알소프트
이 소프트웨어와 관련 문서 파일(이하 "소프트웨어")의 복사본을 취득하는 모든 사람에게
소프트웨어를 제한 없이 사용, 복사, 수정, 병합, 출판, 배포, 서브라이선스 허여 및/또는
판매할 수 있는 권리를 무상으로 부여합니다. 다만, 소프트웨어를 제공받은 사람은 다음
조건을 따라야 합니다:
위 저작권 고지와 본 허가 고지는 소프트웨어의 모든 복사본 또는 상당 부분에 포함되어야
합니다.
소프트웨어는 "있는 그대로" 제공되며, 명시적이든 묵시적이든 어떠한 종류의 보증도 하지
않습니다. 여기에는 상품성, 특정 목적에의 적합성 및 비침해에 대한 보증이 포함되나 이에
국한되지 않습니다. 어떠한 경우에도 저작자 또는 저작권자는 소프트웨어나 소프트웨어의
사용 또는 기타 거래로 인해 발생하는 계약, 불법행위 또는 기타 청구, 손해 또는 기타
책임에 대해 책임을 지지 않습니다.
----- MIT License (English Original) --------------------------------------------------------
The MIT License (MIT)
Copyright (c) 2026 SIRSOFT
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,14 @@
{
"name": "plugins/sirsoft-verification",
"description": "Identity verification plugin for Gnuboard7 platform",
"type": "library",
"version": "1.0.0-beta.1",
"autoload": {
"psr-4": {
"Plugins\\Sirsoft\\Verification\\": ["src/", "./"]
}
},
"require": {
"php": "^8.2"
}
}
Loaded 100 of 3967 files, more files were not shown because too many files have changed in this diff. Show more