v7.0.0-beta.1 release
This commit is contained in:
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
|
||||
+1002
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 - 필터링된 주소 데이터',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
+62
@@ -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 }
|
||||
}
|
||||
}
|
||||
+51
@@ -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');
|
||||
}
|
||||
}
|
||||
};
|
||||
+48
@@ -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');
|
||||
}
|
||||
}
|
||||
};
|
||||
+35
@@ -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명 있어 삭제할 수 없습니다.',
|
||||
];
|
||||
+2022
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
|
||||
}
|
||||
+385
@@ -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
|
||||
}
|
||||
+677
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
+89
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
+80
@@ -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');
|
||||
});
|
||||
+251
@@ -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']);
|
||||
}
|
||||
}
|
||||
+210
@@ -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',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
+400
@@ -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);
|
||||
}
|
||||
}
|
||||
+321
@@ -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' => '고객 요청에 의한 취소',
|
||||
],
|
||||
];
|
||||
+2014
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',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
+141
@@ -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
|
||||
}
|
||||
+189
@@ -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');
|
||||
+469
@@ -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;
|
||||
}
|
||||
}
|
||||
+103
@@ -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);
|
||||
}
|
||||
}
|
||||
+100
@@ -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);
|
||||
}
|
||||
}
|
||||
+98
@@ -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
Reference in new issue
Block a user